This commit is contained in:
Tomáš Dvořák
2025-10-16 17:10:13 +02:00
parent f5e7be92c7
commit 35d0954afd
84 changed files with 9571 additions and 4668 deletions
+143 -21
View File
@@ -44,6 +44,11 @@ import { Image } from '@chakra-ui/react';
import { getCategories, Category } from '../services/public';
import { FaSearch as FaSearchIcon } from 'react-icons/fa';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../services/navigation';
import { getEvents } from '../services/eventService';
import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -72,7 +77,7 @@ const normalizeSocialUrl = (network: 'facebook' | 'instagram' | 'youtube', raw?:
};
// Mobile menu component
const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, dynamicNavItems, navLoading }: {
const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings, categories, galleryHref, galleryLabel, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, dynamicNavItems, navLoading }: {
isOpen: boolean;
onClose: () => void;
isAdmin: boolean;
@@ -83,6 +88,11 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
galleryHref?: string | null;
galleryLabel?: string;
hasTables?: boolean | null;
hasActivities?: boolean | null;
hasPlayers?: boolean | null;
hasArticles?: boolean | null;
hasVideos?: boolean | null;
hasGallery?: boolean | null;
dynamicNavItems: NavigationItem[];
navLoading: boolean;
}) => (
@@ -150,8 +160,12 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
)}
<Button as={RouterLink} to="/kalendar" variant="ghost" justifyContent="flex-start">Kalendář</Button>
<Button as={RouterLink} to="/zapasy" variant="ghost" justifyContent="flex-start">Zápasy</Button>
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
{hasActivities !== false && (
<Button as={RouterLink} to="/aktivity" variant="ghost" justifyContent="flex-start">Aktivity</Button>
)}
{hasPlayers !== false && (
<Button as={RouterLink} to="/hraci" variant="ghost" justifyContent="flex-start">Hráči</Button>
)}
{hasTables ? (
<Button as={RouterLink} to="/tabulky" variant="ghost" justifyContent="flex-start">Tabulky</Button>
) : null}
@@ -173,24 +187,32 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
</Button>
);
})}
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog');
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
{hasArticles !== false && (
<>
<Button as={RouterLink} to="/blog" variant="ghost" justifyContent="flex-start" fontWeight="bold">Články</Button>
{Array.isArray(categories) && categories.length > 0 && (
<VStack align="stretch" pl={4} spacing={1}>
{categories.map((cat: any) => {
const catIsExternal = typeof cat.url === 'string' && /^https?:\/\//i.test(cat.url);
const catHref = cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog');
const catLinkProps = catIsExternal ? { href: catHref } : { to: catHref };
return (
<Button key={cat.slug || cat.name} as={catIsExternal ? 'a' : RouterLink} {...(catLinkProps as any)} variant="ghost" justifyContent="flex-start" fontWeight="normal" size="sm">
{cat.name}
</Button>
);
})}
</VStack>
)}
</>
)}
{hasVideos !== false && (
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
)}
<Button as={RouterLink} to="/videa" variant="ghost" justifyContent="flex-start">Videa</Button>
<Button as={RouterLink} to="/hledat" variant="ghost" justifyContent="flex-start">Hledat</Button>
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
{hasGallery !== false && (
<Button as={RouterLink} to="/galerie" variant="ghost" justifyContent="flex-start">{galleryLabel || 'Fotogalerie'}</Button>
)}
{settings?.shop_url && (
<Button as="a" href={settings.shop_url} target="_blank" rel="noreferrer" variant="ghost" justifyContent="flex-start">Fanshop</Button>
)}
@@ -232,6 +254,11 @@ const Navbar = () => {
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
const [hasPlayers, setHasPlayers] = useState<boolean | null>(null);
const [hasArticles, setHasArticles] = useState<boolean | null>(null);
const [hasVideos, setHasVideos] = useState<boolean | null>(null);
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
@@ -381,6 +408,76 @@ const Navbar = () => {
return () => { disposed = true; };
}, []);
// Determine if there are any activities/events available
useEffect(() => {
let disposed = false;
(async () => {
try {
const events = await getEvents();
if (!disposed) setHasActivities(Array.isArray(events) && events.length > 0);
} catch {
if (!disposed) setHasActivities(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any players available
useEffect(() => {
let disposed = false;
(async () => {
try {
const players = await getPlayers();
if (!disposed) setHasPlayers(Array.isArray(players) && players.length > 0);
} catch {
if (!disposed) setHasPlayers(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any articles available
useEffect(() => {
let disposed = false;
(async () => {
try {
const result = await getArticles({ page: 1, page_size: 1, published: true });
if (!disposed) setHasArticles(result.total > 0);
} catch {
if (!disposed) setHasArticles(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there are any videos available
useEffect(() => {
let disposed = false;
(async () => {
try {
const youtube = await getCachedYouTube();
if (!disposed) setHasVideos(youtube && Array.isArray(youtube.videos) && youtube.videos.length > 0);
} catch {
if (!disposed) setHasVideos(false);
}
})();
return () => { disposed = true; };
}, []);
// Determine if there is any gallery content available
useEffect(() => {
let disposed = false;
(async () => {
try {
const manifest = await getZoneramaManifestWithFallbacks();
if (!disposed) setHasGallery(Array.isArray(manifest) && manifest.length > 0);
} catch {
if (!disposed) setHasGallery(false);
}
})();
return () => { disposed = true; };
}, []);
const isPathActive = (to?: string) => {
if (!to) return false;
// Active when current pathname starts with target (handles subroutes)
@@ -459,8 +556,33 @@ const Navbar = () => {
links = links.filter((n) => n.label !== 'Tabulky');
}
// Hide Aktivity when there are no activities
if (hasActivities === false) {
links = links.filter((n) => n.label !== 'Aktivity');
}
// Hide Hráči when there are no players
if (hasPlayers === false) {
links = links.filter((n) => n.label !== 'Hráči');
}
// Hide Články when there are no articles
if (hasArticles === false) {
links = links.filter((n) => n.label !== 'Články');
}
// Hide Videa when there are no videos
if (hasVideos === false) {
links = links.filter((n) => n.label !== 'Videa');
}
// Hide Fotogalerie when there is no gallery content
if (hasGallery === false) {
links = links.filter((n) => n.label === galleryLabel).length === 0 ? links : links.filter((n) => n.label !== galleryLabel);
}
return links;
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, galleryLabel]);
}, [dynamicNavItems, navLoading, settings, categoryItems, hasTables, hasActivities, hasPlayers, hasArticles, hasVideos, hasGallery, galleryLabel]);
return (
<Box position="sticky" top={0} zIndex={1000}>
@@ -501,7 +623,7 @@ const Navbar = () => {
boxShadow={scrolled ? 'sm' : 'none'}
transition="box-shadow 0.2s ease, background-color 0.2s ease, backdrop-filter 0.2s ease"
>
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<MobileMenu isOpen={isOpen} onClose={onClose} isAdmin={isAdmin} menuBg={menuBg} dividerColor={dividerColor} settings={settings} categories={navCategories} galleryHref={galleryHref} galleryLabel={galleryLabel} hasTables={hasTables} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW="7xl">
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
@@ -18,7 +18,6 @@ import {
SimpleGrid,
useToast,
VStack,
useColorModeValue,
ButtonGroup,
IconButton,
Tooltip,
@@ -82,11 +81,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const [cropMaxWidth, setCropMaxWidth] = useState<number>(1500);
const imgRef = useRef<HTMLImageElement | null>(null);
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgColor = useColorModeValue('white', 'gray.800');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const toolbarBg = useColorModeValue('white', 'gray.800');
const toolbarBorder = useColorModeValue('gray.200', 'gray.700');
// Force white mode for better readability in admin
const borderColor = 'gray.200';
const bgColor = 'white';
const hoverBg = 'gray.50';
const toolbarBg = 'white';
const toolbarBorder = 'gray.200';
// Image editing state
const [selectedImageElement, setSelectedImageElement] = useState<HTMLImageElement | null>(null);
@@ -569,6 +569,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
{editorMode === 'rich' ? (
<Box
position="relative"
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
@@ -579,15 +580,27 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
borderBottom: '1px solid',
borderColor: borderColor,
bg: hoverBg,
'& button': {
color: 'gray.700 !important',
},
'& .ql-stroke': {
stroke: 'gray.700 !important',
},
'& .ql-fill': {
fill: 'gray.700 !important',
},
},
'.ql-container': {
fontSize: '16px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
bg: 'white',
},
'.ql-editor': {
minHeight: height,
maxHeight: '70vh',
overflowY: 'auto',
bg: 'white !important',
color: 'gray.800 !important',
'&::-webkit-scrollbar': {
width: '8px',
},
@@ -614,7 +627,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
},
},
'.ql-editor.ql-blank::before': {
color: 'gray.400',
color: 'gray.400 !important',
fontStyle: 'italic',
},
}}
@@ -869,7 +882,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
justifyContent="center"
alignItems="center"
p={4}
bg={useColorModeValue('gray.50', 'gray.900')}
bg="gray.50"
borderRadius="md"
>
<ReactCrop
@@ -0,0 +1,311 @@
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
IconButton,
Button,
Text,
SimpleGrid,
Tooltip,
useColorModeValue,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import {
FiPlus,
FiColumns,
FiGrid,
FiLayout,
FiTrash2,
} from 'react-icons/fi';
import { FaColumns, FaRegNewspaper, FaThLarge } from 'react-icons/fa';
interface Column {
id: string;
width: string;
elements: string[];
}
interface ColumnLayoutManagerProps {
elementName: string;
onLayoutChange: (columns: Column[]) => void;
currentColumns?: Column[];
}
const ColumnLayoutManager: React.FC<ColumnLayoutManagerProps> = ({
elementName,
onLayoutChange,
currentColumns = [],
}) => {
const [columns, setColumns] = useState<Column[]>(currentColumns);
const { isOpen, onOpen, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.50', 'gray.700');
const layoutTemplates = [
{
name: 'Single Column',
icon: FiLayout,
columns: [{ id: '1', width: '100%', elements: [] }],
},
{
name: 'Two Equal',
icon: FaColumns,
columns: [
{ id: '1', width: '50%', elements: [] },
{ id: '2', width: '50%', elements: [] },
],
},
{
name: 'Three Equal',
icon: FiGrid,
columns: [
{ id: '1', width: '33.33%', elements: [] },
{ id: '2', width: '33.33%', elements: [] },
{ id: '3', width: '33.33%', elements: [] },
],
},
{
name: 'Four Equal',
icon: FaThLarge,
columns: [
{ id: '1', width: '25%', elements: [] },
{ id: '2', width: '25%', elements: [] },
{ id: '3', width: '25%', elements: [] },
{ id: '4', width: '25%', elements: [] },
],
},
{
name: 'Left Sidebar',
icon: FiColumns,
columns: [
{ id: '1', width: '33.33%', elements: [] },
{ id: '2', width: '66.67%', elements: [] },
],
},
{
name: 'Right Sidebar',
icon: FiColumns,
columns: [
{ id: '1', width: '66.67%', elements: [] },
{ id: '2', width: '33.33%', elements: [] },
],
},
{
name: 'Featured + Two',
icon: FaRegNewspaper,
columns: [
{ id: '1', width: '50%', elements: [] },
{ id: '2', width: '25%', elements: [] },
{ id: '3', width: '25%', elements: [] },
],
},
{
name: 'Three + One',
icon: FiGrid,
columns: [
{ id: '1', width: '75%', elements: [] },
{ id: '2', width: '25%', elements: [] },
],
},
];
const applyTemplate = (template: typeof layoutTemplates[0]) => {
setColumns(template.columns);
onLayoutChange(template.columns);
onClose();
};
const addColumn = () => {
const newColumn: Column = {
id: `col-${Date.now()}`,
width: `${100 / (columns.length + 1)}%`,
elements: [],
};
// Recalculate existing column widths
const newColumns = [
...columns.map(col => ({
...col,
width: `${100 / (columns.length + 1)}%`,
})),
newColumn,
];
setColumns(newColumns);
onLayoutChange(newColumns);
};
const removeColumn = (columnId: string) => {
const newColumns = columns.filter(col => col.id !== columnId);
// Recalculate remaining column widths
const equalWidth = `${100 / newColumns.length}%`;
const updatedColumns = newColumns.map(col => ({
...col,
width: equalWidth,
}));
setColumns(updatedColumns);
onLayoutChange(updatedColumns);
};
return (
<Box>
<VStack align="stretch" spacing={3}>
<HStack justify="space-between">
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Column Layout
</Text>
<HStack spacing={2}>
<Button
size="xs"
leftIcon={<FiColumns />}
onClick={onOpen}
variant="outline"
>
Templates
</Button>
<Tooltip label="Add Column">
<IconButton
aria-label="Add column"
icon={<FiPlus />}
size="xs"
colorScheme="blue"
onClick={addColumn}
isDisabled={columns.length >= 6}
/>
</Tooltip>
</HStack>
</HStack>
{/* Current Columns Preview */}
{columns.length > 0 && (
<Box
p={3}
borderRadius="md"
border="1px"
borderColor={borderColor}
bg={bgColor}
>
<HStack spacing={2} align="stretch">
{columns.map((column, index) => (
<Box
key={column.id}
flex={column.width}
p={2}
borderRadius="sm"
border="2px dashed"
borderColor="blue.300"
bg={hoverBg}
position="relative"
minHeight="80px"
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
>
<Text fontSize="xs" fontWeight="bold" color="gray.500">
Column {index + 1}
</Text>
<Text fontSize="xs" color="gray.400">
{column.width}
</Text>
{columns.length > 1 && (
<IconButton
aria-label="Remove column"
icon={<FiTrash2 />}
size="xs"
position="absolute"
top={1}
right={1}
variant="ghost"
colorScheme="red"
onClick={() => removeColumn(column.id)}
/>
)}
<Tooltip label="Click + to add element">
<IconButton
aria-label="Add element to column"
icon={<FiPlus />}
size="xs"
position="absolute"
bottom={1}
colorScheme="green"
variant="ghost"
/>
</Tooltip>
</Box>
))}
</HStack>
</Box>
)}
</VStack>
{/* Layout Templates Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Choose Column Layout</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<SimpleGrid columns={2} spacing={4}>
{layoutTemplates.map((template, index) => (
<Box
key={index}
p={4}
borderRadius="lg"
border="2px"
borderColor={borderColor}
cursor="pointer"
transition="all 0.2s"
_hover={{
borderColor: 'blue.400',
transform: 'translateY(-2px)',
boxShadow: 'md',
}}
onClick={() => applyTemplate(template)}
>
<VStack spacing={3}>
<Box
as={template.icon}
fontSize="2xl"
color="blue.500"
/>
<Text fontWeight="bold" fontSize="sm">
{template.name}
</Text>
{/* Visual Preview */}
<HStack spacing={1} width="100%" height="40px">
{template.columns.map((col, i) => (
<Box
key={i}
flex={col.width}
bg="blue.100"
borderRadius="sm"
height="100%"
/>
))}
</HStack>
</VStack>
</Box>
))}
</SimpleGrid>
</ModalBody>
</ModalContent>
</Modal>
</Box>
);
};
export default ColumnLayoutManager;
@@ -0,0 +1,162 @@
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
Link,
Icon,
Divider,
useColorModeValue,
Badge,
} from '@chakra-ui/react';
import {
FiExternalLink,
FiSettings,
FiUsers,
FiFileText,
FiVideo,
FiImage,
FiCalendar,
FiTag,
FiShoppingCart,
FiMail,
} from 'react-icons/fi';
interface AdminLink {
label: string;
url: string;
icon: any;
description?: string;
badge?: string;
}
interface ContextualAdminLinksProps {
elementName: string;
}
const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName }) => {
const bgColor = useColorModeValue('blue.50', 'blue.900');
const borderColor = useColorModeValue('blue.200', 'blue.700');
const getLinksForElement = (element: string): AdminLink[] => {
const links: Record<string, AdminLink[]> = {
hero: [
{ label: 'Manage Articles', url: '/admin/articles', icon: FiFileText, description: 'Edit featured articles' },
{ label: 'Upload Images', url: '/admin/media', icon: FiImage, description: 'Manage hero images' },
],
news: [
{ label: 'Manage Articles', url: '/admin/articles', icon: FiFileText, description: 'Create and edit news' },
{ label: 'Categories', url: '/admin/categories', icon: FiTag, description: 'Organize article categories' },
{ label: 'Article Settings', url: '/admin/settings/articles', icon: FiSettings, description: 'Configure display options' },
],
matches: [
{ label: 'Manage Matches', url: '/admin/matches', icon: FiCalendar, description: 'Schedule and edit matches' },
{ label: 'Match Settings', url: '/admin/settings/matches', icon: FiSettings, description: 'Configure match display' },
],
table: [
{ label: 'Update Table', url: '/admin/table', icon: FiSettings, description: 'Refresh league standings' },
{ label: 'Team Settings', url: '/admin/settings/team', icon: FiSettings },
],
team: [
{ label: 'Manage Players', url: '/admin/team/players', icon: FiUsers, description: 'Add and edit players' },
{ label: 'Team Settings', url: '/admin/settings/team', icon: FiSettings, description: 'Configure team display' },
],
videos: [
{ label: 'Manage Videos', url: '/admin/videos', icon: FiVideo, description: 'Add YouTube videos' },
{ label: 'Video Settings', url: '/admin/settings/videos', icon: FiSettings, description: 'Configure video player' },
],
gallery: [
{ label: 'Gallery Settings', url: '/admin/settings/gallery', icon: FiImage, description: 'Set gallery URL' },
],
merch: [
{ label: 'Fanshop Settings', url: '/admin/settings/fanshop', icon: FiShoppingCart, description: 'Configure merchandise' },
],
newsletter: [
{ label: 'Newsletter Settings', url: '/admin/settings/newsletter', icon: FiMail, description: 'Email configuration' },
{ label: 'Subscribers', url: '/admin/newsletter/subscribers', icon: FiUsers, description: 'View subscribers' },
],
sponsors: [
{ label: 'Manage Sponsors', url: '/admin/sponsors', icon: FiImage, description: 'Add and edit sponsors' },
],
};
return links[element] || [
{ label: 'Admin Dashboard', url: '/admin', icon: FiSettings, description: 'Go to admin panel' },
];
};
const links = getLinksForElement(elementName);
return (
<Box>
<VStack align="stretch" spacing={3}>
<HStack>
<Icon as={FiExternalLink} color="blue.500" />
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Quick Admin Links
</Text>
</HStack>
<Box
p={3}
borderRadius="md"
bg={bgColor}
border="1px"
borderColor={borderColor}
>
<VStack align="stretch" spacing={2} divider={<Divider />}>
{links.map((link, index) => (
<Link
key={index}
href={link.url}
_hover={{ textDecoration: 'none' }}
isExternal
>
<HStack
p={2}
borderRadius="md"
transition="all 0.2s"
_hover={{
bg: useColorModeValue('white', 'gray.800'),
transform: 'translateX(4px)',
}}
justify="space-between"
>
<HStack spacing={3} flex={1}>
<Icon as={link.icon} boxSize={4} color="blue.500" />
<VStack align="start" spacing={0} flex={1}>
<Text fontSize="sm" fontWeight="medium">
{link.label}
</Text>
{link.description && (
<Text fontSize="xs" color="gray.500">
{link.description}
</Text>
)}
</VStack>
</HStack>
{link.badge && (
<Badge colorScheme="green" fontSize="xs">
{link.badge}
</Badge>
)}
<Icon as={FiExternalLink} boxSize={3} color="gray.400" />
</HStack>
</Link>
))}
</VStack>
</Box>
<Text fontSize="xs" color="gray.500" textAlign="center">
💡 These links help you manage content for this section
</Text>
</VStack>
</Box>
);
};
export default ContextualAdminLinks;
@@ -0,0 +1,297 @@
import React, { useState, useEffect } from 'react';
import {
Box,
VStack,
HStack,
Text,
Textarea,
Button,
IconButton,
useToast,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Code,
Alert,
AlertIcon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiCode, FiEye, FiSave, FiRefreshCw } from 'react-icons/fi';
interface CustomCSSEditorProps {
elementName: string;
onCSSChange: (css: string) => void;
currentCSS?: string;
}
const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
elementName,
onCSSChange,
currentCSS = '',
}) => {
const [css, setCSS] = useState(currentCSS);
const [isValid, setIsValid] = useState(true);
const [preview, setPreview] = useState(false);
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
useEffect(() => {
setCSS(currentCSS);
}, [currentCSS]);
const validateCSS = (cssString: string): boolean => {
try {
// Basic CSS validation
if (!cssString.trim()) return true;
// Check for balanced braces
const openBraces = (cssString.match(/{/g) || []).length;
const closeBraces = (cssString.match(/}/g) || []).length;
return openBraces === closeBraces;
} catch {
return false;
}
};
const handleCSSChange = (value: string) => {
setCSS(value);
const valid = validateCSS(value);
setIsValid(valid);
if (valid && preview) {
applyCSS(value);
}
};
const applyCSS = (cssString: string) => {
// Remove existing custom style
const existingStyle = document.getElementById(`custom-css-${elementName}`);
if (existingStyle) {
existingStyle.remove();
}
if (cssString.trim()) {
// Create new style element
const style = document.createElement('style');
style.id = `custom-css-${elementName}`;
style.textContent = `
[data-element="${elementName}"] {
${cssString}
}
`;
document.head.appendChild(style);
}
};
const handleSave = () => {
if (!isValid) {
toast({
title: 'Invalid CSS',
description: 'Please fix CSS errors before saving',
status: 'error',
duration: 3000,
});
return;
}
applyCSS(css);
onCSSChange(css);
toast({
title: 'CSS Applied',
description: 'Custom styles have been applied',
status: 'success',
duration: 2000,
});
};
const handleReset = () => {
setCSS('');
const existingStyle = document.getElementById(`custom-css-${elementName}`);
if (existingStyle) {
existingStyle.remove();
}
onCSSChange('');
};
const cssExamples = [
{
label: 'Background Gradient',
code: `background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;`,
},
{
label: 'Shadow & Hover',
code: `box-shadow: 0 10px 25px rgba(0,0,0,0.1);
transition: transform 0.3s;
&:hover {
transform: translateY(-5px);
}`,
},
{
label: 'Border Radius',
code: `border-radius: 20px;
overflow: hidden;`,
},
{
label: 'Animation',
code: `animation: fadeIn 1s ease-in;
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}`,
},
];
return (
<Box width="100%" height="100%">
<Tabs size="sm" variant="enclosed" colorScheme="purple">
<TabList>
<Tab>
<HStack spacing={2}>
<FiCode />
<Text>CSS Editor</Text>
</HStack>
</Tab>
<Tab>
<HStack spacing={2}>
<FiEye />
<Text>Examples</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
{/* Editor Tab */}
<TabPanel p={0}>
<VStack align="stretch" spacing={3} p={4}>
<HStack justify="space-between">
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Custom CSS for {elementName}
</Text>
<HStack spacing={2}>
<Button
size="xs"
leftIcon={<FiEye />}
variant={preview ? 'solid' : 'outline'}
colorScheme={preview ? 'blue' : 'gray'}
onClick={() => {
setPreview(!preview);
if (!preview && isValid) {
applyCSS(css);
}
}}
>
Preview
</Button>
<Button
size="xs"
leftIcon={<FiRefreshCw />}
variant="ghost"
onClick={handleReset}
>
Reset
</Button>
</HStack>
</HStack>
{!isValid && (
<Alert status="error" borderRadius="md" fontSize="sm">
<AlertIcon />
Invalid CSS syntax. Check for missing braces or semicolons.
</Alert>
)}
<Textarea
value={css}
onChange={(e) => handleCSSChange(e.target.value)}
placeholder={`/* Enter custom CSS properties */
background: #f0f0f0;
padding: 20px;
border-radius: 10px;`}
fontFamily="monospace"
fontSize="sm"
minHeight="300px"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor={isValid ? borderColor : 'red.300'}
_focus={{
borderColor: isValid ? 'purple.400' : 'red.400',
boxShadow: isValid ? '0 0 0 1px var(--chakra-colors-purple-400)' : '0 0 0 1px var(--chakra-colors-red-400)',
}}
/>
<Divider />
<Alert status="info" borderRadius="md" fontSize="xs">
<AlertIcon />
<Box>
<Text fontWeight="bold">Pro tip:</Text>
<Text>Use standard CSS properties. Avoid selectors - styles apply to the element automatically.</Text>
</Box>
</Alert>
<Button
leftIcon={<FiSave />}
colorScheme="purple"
size="sm"
onClick={handleSave}
isDisabled={!isValid}
>
Apply CSS
</Button>
</VStack>
</TabPanel>
{/* Examples Tab */}
<TabPanel>
<VStack align="stretch" spacing={3} p={4}>
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Quick CSS Examples
</Text>
{cssExamples.map((example, index) => (
<Box
key={index}
p={3}
borderRadius="md"
border="1px"
borderColor={borderColor}
cursor="pointer"
transition="all 0.2s"
_hover={{
borderColor: 'purple.400',
transform: 'translateX(4px)',
}}
onClick={() => setCSS(example.code)}
>
<Text fontWeight="bold" fontSize="sm" mb={2}>
{example.label}
</Text>
<Code
fontSize="xs"
display="block"
whiteSpace="pre"
p={2}
borderRadius="sm"
bg={useColorModeValue('gray.100', 'gray.900')}
>
{example.code}
</Code>
</Box>
))}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
);
};
export default CustomCSSEditor;
@@ -0,0 +1,204 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
IconButton,
HStack,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
Button,
VStack,
Input,
useColorModeValue,
Tooltip,
} from '@chakra-ui/react';
import { FiBold, FiItalic, FiUnderline, FiType, FiLink, FiCheck, FiX } from 'react-icons/fi';
interface InlineTextEditorProps {
elementId: string;
onSave: (content: string) => void;
initialContent?: string;
}
const InlineTextEditor: React.FC<InlineTextEditorProps> = ({ elementId, onSave, initialContent = '' }) => {
const [isEditing, setIsEditing] = useState(false);
const [content, setContent] = useState(initialContent);
const [showLinkPopover, setShowLinkPopover] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const editorRef = useRef<HTMLDivElement>(null);
const bgColor = useColorModeValue('white', 'gray.800');
useEffect(() => {
if (isEditing && editorRef.current) {
editorRef.current.focus();
// Select all text
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(editorRef.current);
selection?.removeAllRanges();
selection?.addRange(range);
}
}, [isEditing]);
const handleFormat = (command: string, value?: string) => {
document.execCommand(command, false, value);
};
const handleSave = () => {
if (editorRef.current) {
const newContent = editorRef.current.innerHTML;
setContent(newContent);
onSave(newContent);
setIsEditing(false);
}
};
const handleCancel = () => {
if (editorRef.current) {
editorRef.current.innerHTML = content;
}
setIsEditing(false);
};
const handleInsertLink = () => {
if (linkUrl) {
handleFormat('createLink', linkUrl);
setLinkUrl('');
setShowLinkPopover(false);
}
};
return (
<Box position="relative">
{isEditing && (
<Box
position="absolute"
top="-50px"
left="0"
bg={bgColor}
borderRadius="md"
boxShadow="lg"
p={2}
zIndex={10000}
border="1px solid"
borderColor="blue.400"
>
<HStack spacing={1}>
<Tooltip label="Bold">
<IconButton
aria-label="Bold"
icon={<FiBold />}
size="sm"
variant="ghost"
onClick={() => handleFormat('bold')}
/>
</Tooltip>
<Tooltip label="Italic">
<IconButton
aria-label="Italic"
icon={<FiItalic />}
size="sm"
variant="ghost"
onClick={() => handleFormat('italic')}
/>
</Tooltip>
<Tooltip label="Underline">
<IconButton
aria-label="Underline"
icon={<FiUnderline />}
size="sm"
variant="ghost"
onClick={() => handleFormat('underline')}
/>
</Tooltip>
<Popover isOpen={showLinkPopover} onClose={() => setShowLinkPopover(false)}>
<PopoverTrigger>
<IconButton
aria-label="Link"
icon={<FiLink />}
size="sm"
variant="ghost"
onClick={() => setShowLinkPopover(true)}
/>
</PopoverTrigger>
<PopoverContent width="250px">
<PopoverBody>
<VStack spacing={2}>
<Input
placeholder="https://example.com"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
size="sm"
/>
<HStack width="100%">
<Button size="sm" colorScheme="blue" onClick={handleInsertLink} flex={1}>
Insert
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowLinkPopover(false)}>
Cancel
</Button>
</HStack>
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
<Box width="1px" height="20px" bg="gray.300" mx={1} />
<Tooltip label="Save">
<IconButton
aria-label="Save"
icon={<FiCheck />}
size="sm"
colorScheme="green"
onClick={handleSave}
/>
</Tooltip>
<Tooltip label="Cancel">
<IconButton
aria-label="Cancel"
icon={<FiX />}
size="sm"
colorScheme="red"
variant="ghost"
onClick={handleCancel}
/>
</Tooltip>
</HStack>
</Box>
)}
<Box
ref={editorRef}
contentEditable={isEditing}
suppressContentEditableWarning
dangerouslySetInnerHTML={{ __html: content }}
onClick={() => !isEditing && setIsEditing(true)}
onBlur={(e) => {
// Don't close if clicking on toolbar
if (!e.relatedTarget || !(e.relatedTarget as HTMLElement).closest('[role="group"]')) {
// Auto-save on blur if changed
if (editorRef.current && editorRef.current.innerHTML !== content) {
handleSave();
}
}
}}
cursor={isEditing ? 'text' : 'pointer'}
outline={isEditing ? '2px solid' : 'none'}
outlineColor="blue.400"
outlineOffset="2px"
p={isEditing ? 2 : 0}
borderRadius="md"
transition="all 0.2s"
_hover={{
outline: isEditing ? '2px solid' : '2px dashed',
outlineColor: 'blue.400',
}}
/>
</Box>
);
};
export default InlineTextEditor;
+127 -12
View File
@@ -100,7 +100,7 @@ import {
import { useAuth } from '../../contexts/AuthContext';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import VisualStylePanel from './VisualStylePanel';
import { DEFAULT_HOMEPAGE_ELEMENTS } from '../../data/defaultElements';
import { DEFAULT_HOMEPAGE_ELEMENTS, HOMEPAGE_IMPLEMENTED_ELEMENTS } from '../../data/defaultElements';
interface MyUIbrixStyleEditorProps {
pageType: string;
@@ -130,6 +130,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [visibleElements, setVisibleElements] = useState<Set<string>>(new Set());
const [elementOrder, setElementOrder] = useState<string[]>([]);
const [draggedElement, setDraggedElement] = useState<string | null>(null);
const [dragOverElement, setDragOverElement] = useState<string | null>(null);
const [viewport, setViewport] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true);
@@ -360,7 +361,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
});
};
Object.keys(ELEMENT_VARIANTS).forEach(addOverlay);
// Only add overlays for elements that are actually implemented on this page
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
// Close panel on escape
const handleEscape = (e: KeyboardEvent) => {
@@ -374,7 +381,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
document.querySelectorAll('.elementor-overlay').forEach(el => el.remove());
document.removeEventListener('keydown', handleEscape);
};
}, [isEditing, selectedElement]);
}, [isEditing, selectedElement, pageType]);
// Update selected element overlay styling
useEffect(() => {
@@ -481,8 +488,9 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setElementOrder(newOrder);
setHasChanges(true);
// Trigger reorder event ONLY during editing
// Trigger reorder event and apply visual reordering
if (isEditing) {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
@@ -498,14 +506,92 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setElementOrder(newOrder);
setHasChanges(true);
// Trigger reorder event ONLY during editing
// Trigger reorder event and apply visual reordering
if (isEditing) {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
}
}, [elementOrder, isEditing]);
// Apply visual reordering to DOM elements
const applyVisualReorder = useCallback((order: string[]) => {
const container = document.querySelector('.container');
if (!container) return;
// Get all sections with data-element attributes
const sections = Array.from(container.querySelectorAll('[data-element]')) as HTMLElement[];
// Create a map of element names to their DOM nodes
const elementMap = new Map<string, HTMLElement>();
sections.forEach(section => {
const elementName = section.getAttribute('data-element');
if (elementName) {
elementMap.set(elementName, section);
}
});
// Reorder by appending in the correct order
order.forEach((elementName) => {
const element = elementMap.get(elementName);
if (element && element.parentElement === container) {
container.appendChild(element);
}
});
}, []);
// Drag and drop handlers
const handleDragStart = useCallback((elementName: string) => {
setDraggedElement(elementName);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, elementName: string) => {
e.preventDefault();
setDragOverElement(elementName);
}, []);
const handleDragLeave = useCallback(() => {
setDragOverElement(null);
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetElementName: string) => {
e.preventDefault();
if (!draggedElement || draggedElement === targetElementName) {
setDraggedElement(null);
setDragOverElement(null);
return;
}
const newOrder = [...elementOrder];
const draggedIndex = newOrder.indexOf(draggedElement);
const targetIndex = newOrder.indexOf(targetElementName);
if (draggedIndex === -1 || targetIndex === -1) {
setDraggedElement(null);
setDragOverElement(null);
return;
}
// Remove dragged element and insert at target position
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedElement);
setElementOrder(newOrder);
setHasChanges(true);
setDraggedElement(null);
setDragOverElement(null);
// Apply visual reordering
if (isEditing) {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
}
}, [draggedElement, elementOrder, isEditing, applyVisualReorder]);
const handleSave = async () => {
try {
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
@@ -1125,21 +1211,36 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const isVisible = visibleElements.has(elementName);
const isSelected = selectedElement === elementName;
const isDragging = draggedElement === elementName;
const isDragOver = dragOverElement === elementName;
return (
<Box
key={elementName}
p={3}
borderRadius="lg"
border="2px"
borderColor={isSelected ? secondaryColor : borderColor}
bg={isSelected ? `${secondaryColor}20` : isVisible ? bgColor : 'gray.100'}
cursor="pointer"
opacity={isVisible ? 1 : 0.5}
borderColor={isDragOver ? 'blue.500' : isSelected ? secondaryColor : borderColor}
bg={isDragging ? 'gray.200' : isSelected ? `${secondaryColor}20` : isVisible ? bgColor : 'gray.100'}
cursor={isDragging ? 'grabbing' : 'grab'}
opacity={isDragging ? 0.5 : isVisible ? 1 : 0.5}
transition="all 0.2s"
transform={isDragOver ? 'scale(1.05)' : undefined}
_hover={{
borderColor: secondaryColor,
transform: 'translateX(4px)',
transform: isDragOver ? 'scale(1.05)' : 'translateX(4px)',
}}
draggable
onDragStart={(e) => {
handleDragStart(elementName);
(e.target as HTMLElement).style.cursor = 'grabbing';
}}
onDragEnd={(e) => {
(e.target as HTMLElement).style.cursor = 'grab';
}}
onDragOver={(e) => handleDragOver(e, elementName)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, elementName)}
onClick={() => {
setSelectedElement(elementName);
const el = document.querySelector(`[data-element="${elementName}"]`);
@@ -1155,7 +1256,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}}
>
<Flex align="center" justify="space-between">
<HStack flex={1}>
<HStack flex={1} spacing={2}>
<Icon
as={FaGripVertical}
boxSize={4}
color="gray.400"
cursor="grab"
_active={{ cursor: 'grabbing' }}
/>
<Icon as={element?.icon || FaCube} boxSize={5} color={secondaryColor} />
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="bold" fontSize="sm">
@@ -1339,7 +1447,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Filter by category selection
if (selectedCategory !== 'all' && selectedCategory !== category) return null;
const elements = PREDEFINED_ELEMENTS.filter(e => e.category === category);
// IMPORTANT: Only show elements that are actually implemented on this page
const implementedForThisPage = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : [];
const elements = PREDEFINED_ELEMENTS.filter(e =>
e.category === category &&
(implementedForThisPage.length === 0 || implementedForThisPage.includes(e.name))
);
const availableElements = elements.filter(e => {
if (visibleElements.has(e.name)) return false;
// Filter by search query
@@ -28,8 +28,11 @@ import {
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { FiType, FiLayout, FiBox, FiDroplet, FiGrid, FiSmartphone, FiBarChart2, FiSidebar } from 'react-icons/fi';
import { FiType, FiLayout, FiBox, FiDroplet, FiGrid, FiSmartphone, FiBarChart2, FiSidebar, FiCode, FiColumns, FiExternalLink } from 'react-icons/fi';
import { FaRegNewspaper, FaRegSquare, FaColumns } from 'react-icons/fa';
import CustomCSSEditor from './CustomCSSEditor';
import ColumnLayoutManager from './ColumnLayoutManager';
import ContextualAdminLinks from './ContextualAdminLinks';
import { useClubTheme } from '../../contexts/ClubThemeContext';
interface VisualStylePanelProps {
@@ -85,6 +88,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
alignItems: currentStyles.alignItems || 'stretch',
justifyItems: currentStyles.justifyItems || 'stretch',
// Custom CSS
customCSS: currentStyles.customCSS || '',
...currentStyles,
});
@@ -105,11 +111,12 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
pt="60px"
>
<Tabs size="sm" colorScheme="blue">
<TabList px={2}>
<TabList px={2} flexWrap="wrap">
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
<Tab><FiGrid /> <Text ml={1}>Grid</Text></Tab>
<Tab><FiBox /> <Text ml={1}>Advanced</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Layout</Text></Tab>
<Tab><FiCode /> <Text ml={1}>CSS</Text></Tab>
<Tab><FiExternalLink /> <Text ml={1}>Admin</Text></Tab>
</TabList>
<TabPanels>
@@ -403,7 +410,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Grid Tab */}
{/* Layout Tab (was Grid Tab) */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
@@ -658,51 +665,18 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Advanced Tab */}
{/* Custom CSS Tab */}
<TabPanel p={0}>
<CustomCSSEditor
elementName={elementName}
onCSSChange={(css) => updateStyle('customCSS', css)}
currentCSS={styles.customCSS || ''}
/>
</TabPanel>
{/* Admin Links Tab */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Layout
</Text>
{/* Display */}
<FormControl>
<FormLabel fontSize="xs">Display</FormLabel>
<Select
size="sm"
value={styles.display}
onChange={(e) => updateStyle('display', e.target.value)}
>
<option value="block">Block</option>
<option value="inline-block">Inline Block</option>
<option value="flex">Flex</option>
<option value="grid">Grid</option>
<option value="none">None</option>
</Select>
</FormControl>
{/* Width */}
<FormControl>
<FormLabel fontSize="xs">Width</FormLabel>
<Input
size="sm"
value={styles.width}
onChange={(e) => updateStyle('width', e.target.value)}
placeholder="auto, 100%, 500px"
/>
</FormControl>
{/* Height */}
<FormControl>
<FormLabel fontSize="xs">Height</FormLabel>
<Input
size="sm"
value={styles.height}
onChange={(e) => updateStyle('height', e.target.value)}
placeholder="auto, 100%, 500px"
/>
</FormControl>
</VStack>
<ContextualAdminLinks elementName={elementName} />
</TabPanel>
</TabPanels>
</Tabs>
@@ -6,6 +6,7 @@ import { usePublicSettings } from '../../hooks/usePublicSettings';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../../services/competitionAliases';
import { TeamLogo } from '../common/TeamLogo';
import { sortCategoriesWithOrder } from '../../utils/categorySort';
import { sanitizeClubName } from '../../utils/url';
import '../../styles/logos.css';
const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string; aid?: string; al?: string; s?: string; clubName?: string }> = ({ d, h, hid, hl, a, aid, al, s, clubName }) => (
@@ -13,7 +14,7 @@ const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string
<Text w="140px" fontSize="sm" color="gray.600">{d}</Text>
<HStack flex={1} justify="flex-end" spacing={4}>
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{h}</Text>
<Text noOfLines={1} textAlign="right" flex={1}>{sanitizeClubName(h)}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={hid}
@@ -51,7 +52,7 @@ const Row: React.FC<{ d: string; h: string; hid?: string; hl?: string; a: string
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{a}</Text>
<Text noOfLines={1} flex={1}>{sanitizeClubName(a)}</Text>
</HStack>
</HStack>
</HStack>
+4 -4
View File
@@ -17,7 +17,7 @@ import {
Divider,
} from '@chakra-ui/react';
import { useCountdown } from '../../hooks/useCountdown';
import { assetUrl } from '../../utils/url';
import { assetUrl, sanitizeClubName } from '../../utils/url';
export type FacrMatchLike = {
id?: string | number;
@@ -93,7 +93,7 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
<ModalOverlay />
<ModalContent>
<ModalHeader>
{match?.home || 'Domácí'} vs {match?.away || 'Hosté'}
{sanitizeClubName(match?.home) || 'Domácí'} vs {sanitizeClubName(match?.away) || 'Hosté'}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
@@ -113,7 +113,7 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.home_logo_url) || '/logo192.png'} alt={match.home || 'Domácí'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.home || 'Domácí'}</Text>
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.home) || 'Domácí'}</Text>
</VStack>
<VStack spacing={1} minW="120px">
{hasScore ? (
@@ -149,7 +149,7 @@ export const MatchModal: React.FC<MatchModalProps> = ({ isOpen, match, onClose,
tabIndex={onTeamClick ? 0 : undefined}
>
<Image src={assetUrl(match.away_logo_url) || '/logo192.png'} alt={match.away || 'Hosté'} boxSize="56px" objectFit="contain" />
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{match.away || 'Hosté'}</Text>
<Text fontWeight="semibold" noOfLines={1} textAlign="center">{sanitizeClubName(match.away) || 'Hosté'}</Text>
</VStack>
</HStack>
@@ -17,7 +17,7 @@ const MatchRow: React.FC<{
<Text w="140px" fontSize="sm" color="gray.600">{date}</Text>
<HStack flex={1} justify="flex-end">
<HStack minW="40%" justify="flex-end" spacing={2}>
<Text noOfLines={1} textAlign="right" flex={1}>{home.name}</Text>
<Text noOfLines={1} textAlign="right" flex={1} fontSize="sm" lineHeight="1.2em" height="1.2em" overflow="hidden">{home.name}</Text>
<Box className="logo-container" w="28px" h="28px">
<TeamLogo
teamId={home.id}
@@ -58,7 +58,7 @@ const MatchRow: React.FC<{
boxSize="28px"
/>
</Box>
<Text noOfLines={1} flex={1}>{away.name}</Text>
<Text noOfLines={1} flex={1} fontSize="sm" lineHeight="1.2em" height="1.2em" overflow="hidden">{away.name}</Text>
</HStack>
</HStack>
</HStack>
@@ -180,7 +180,6 @@ const VideosSection: React.FC<Props> = ({ videos }) => {
display="flex"
alignItems="center"
justifyContent="center"
bg="blackAlpha.700"
opacity={0}
transition="opacity 0.3s ease"
pointerEvents="none"
@@ -10,7 +10,7 @@ import { format, parse, isToday, isTomorrow, isAfter } from 'date-fns';
import { cs } from 'date-fns/locale';
import { Match } from '../../types';
import { fetchTeamLogoOverrides } from '@/services/adminMatches';
import { assetUrl } from '@/utils/url';
import { assetUrl, sanitizeClubName } from '@/utils/url';
import { TeamLogo } from '../common/TeamLogo';
import '../../styles/logos.css';
@@ -247,7 +247,7 @@ export const MatchesWidget = () => {
isTruncated
color="gray.800"
>
{match.home}
{sanitizeClubName(match.home)}
</Text>
</HStack>
<Text
@@ -268,7 +268,7 @@ export const MatchesWidget = () => {
textAlign="right"
color="gray.800"
>
{match.away}
{sanitizeClubName(match.away)}
</Text>
<Box flexShrink={0} className="match-widget-logo">
<TeamLogo
@@ -202,10 +202,6 @@ export const ClubThemeProvider: React.FC<{ children: React.ReactNode }>= ({ chil
} catch (error) {
console.warn('ClubTheme: Error updating theme:', error);
// Don't reset to default theme on error - keep current theme
// Only set default if this is the first load and we have no theme yet
if (mounted && theme === defaultTheme) {
setTheme(defaultTheme);
}
}
})();
return () => { mounted = false; };
+34 -11
View File
@@ -3,15 +3,22 @@
import { PageElementConfig } from '../services/pageElements';
// Elements that are actually implemented on HomePage
// Only these should be available in the editor
export const HOMEPAGE_IMPLEMENTED_ELEMENTS = [
'hero', // Hero section with news cards (grid/scroller/swiper variants)
'news', // Featured news articles
'matches', // Upcoming/recent matches
'table', // League standings table
'team', // Players scroller
'videos', // Videos section
'merch', // Merchandise/fanshop
'newsletter',// Newsletter subscription
'sponsors', // Sponsors/partners
'banner', // Advertisement banners (various placements)
];
export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
{
page_type: 'homepage',
element_name: 'header',
variant: 'unified',
visible: true,
display_order: 0,
settings: {},
},
{
page_type: 'homepage',
element_name: 'hero',
@@ -70,12 +77,28 @@ export const DEFAULT_HOMEPAGE_ELEMENTS: PageElementConfig[] = [
},
{
page_type: 'homepage',
element_name: 'activities',
variant: 'list',
visible: false,
element_name: 'merch',
variant: 'grid',
visible: true,
display_order: 7,
settings: {},
},
{
page_type: 'homepage',
element_name: 'table',
variant: 'split_news',
visible: true,
display_order: 8,
settings: {},
},
{
page_type: 'homepage',
element_name: 'banner',
variant: 'top',
visible: false,
display_order: 10,
settings: {},
},
{
page_type: 'homepage',
element_name: 'newsletter',
+39 -7
View File
@@ -40,11 +40,34 @@ export const useAllPageElementConfigs = (pageType: string) => {
const [configs, setConfigs] = useState<Record<string, string>>({});
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
const [styles, setStyles] = useState<Record<string, Record<string, any>>>({});
const [elementOrder, setElementOrder] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
// Helper function to apply DOM order
const applyDOMOrder = (order: string[]) => {
const container = document.querySelector('.container');
if (!container) return;
const sections = Array.from(container.querySelectorAll('[data-element]')) as HTMLElement[];
const elementMap = new Map<string, HTMLElement>();
sections.forEach(section => {
const elementName = section.getAttribute('data-element');
if (elementName) {
elementMap.set(elementName, section);
}
});
order.forEach((elementName) => {
const element = elementMap.get(elementName);
if (element && element.parentElement === container) {
container.appendChild(element);
}
});
};
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
@@ -52,13 +75,25 @@ export const useAllPageElementConfigs = (pageType: string) => {
const configMap: Record<string, string> = {};
const visMap: Record<string, boolean> = {};
data.forEach(config => {
// Sort by display_order to get correct element order
const sorted = [...data].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const order = sorted.map(config => config.element_name);
sorted.forEach(config => {
configMap[config.element_name] = config.variant;
visMap[config.element_name] = config.visible !== false;
});
setConfigs(configMap);
setVisibility(visMap);
setElementOrder(order);
// Apply initial order to DOM if elements exist
if (order.length > 0) {
requestAnimationFrame(() => {
applyDOMOrder(order);
});
}
}
} catch (error) {
console.error('Failed to load page element configs:', error);
@@ -93,11 +128,8 @@ export const useAllPageElementConfigs = (pageType: string) => {
// Listen for reorder events
const handleMyUIbrixReorder = ((event: CustomEvent) => {
const { order } = event.detail;
// Trigger re-render with new order
// The actual reordering happens in the parent component
window.dispatchEvent(new CustomEvent('myuibrix-order-changed', {
detail: { order }
}));
setElementOrder(order);
applyDOMOrder(order);
}) as EventListener;
// Listen for style changes from VisualStylePanel
@@ -153,5 +185,5 @@ export const useAllPageElementConfigs = (pageType: string) => {
return styles[elementName];
};
return { configs, visibility, styles, getVariant, isVisible, getStyles, loading };
return { configs, visibility, styles, elementOrder, getVariant, isVisible, getStyles, loading };
};
+167 -33
View File
@@ -822,26 +822,66 @@ const CalendarPage: React.FC = () => {
borderColor={listMatchBorder}
_hover={{ bg: listMatchHoverBg, borderColor: 'brand.primary', cursor: 'pointer', transform: 'translateY(-2px)', boxShadow: 'md' }}
transition="all 0.2s"
gap={3}
>
<Flex direction="column" minW="180px">
<Text fontWeight="semibold" color={listDateText}>{m.date}</Text>
{m.venue && <Text color={listVenueText} fontSize="sm">{m.venue}</Text>}
<Flex direction="column" minW="100px">
<Text fontWeight="semibold" color={listDateText} fontSize="sm">{m.date}</Text>
<Text color={listTimeText} fontSize="sm">{m.time || '—'}</Text>
{m.venue && <Text color={listVenueText} fontSize="xs" mt={1}>{m.venue}</Text>}
</Flex>
<Flex direction="column" align="center" gap={1} flex="1" justify="center">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="md">za {countdown}</Badge>
) : (
<Flex align="center" gap={2} justify="center">
{m.home_logo_url && (
<Image src={m.home_logo_url} alt={m.home} boxSize="20px" borderRadius="full" objectFit="cover" />
)}
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'}>{isPast && m.score ? m.score : 'vs'}</Badge>
{m.away_logo_url && (
<Image src={m.away_logo_url} alt={m.away} boxSize="20px" borderRadius="full" objectFit="cover" />
)}
</Flex>
)}
<Text fontSize="sm" color={listTimeText}>{m.time || '—'}</Text>
<Flex align="center" gap={3} flex="1">
{/* Home Team */}
<Flex align="center" gap={2} flex="1" justify="flex-end">
<Text fontSize="sm" fontWeight="medium" textAlign="right" color={listDateText}>
{m.home}
</Text>
{m.home_logo_url && (
<Image
src={m.home_logo_url}
alt={m.home}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
</Flex>
{/* Score or Countdown */}
<Flex direction="column" align="center" gap={1} minW="80px">
{!isPast && countdown ? (
<Badge colorScheme="orange" fontSize="sm" px={2}>za {countdown}</Badge>
) : (
<Badge colorScheme={isPast && m.score ? (getSentiment(m)?.color || 'gray') : 'gray'} fontSize="md" px={3} py={1}>
{isPast && m.score ? m.score : 'vs'}
</Badge>
)}
{sentiment && (
<Text fontSize="xs" color={`${sentiment.color}.600`} fontWeight="semibold">
{sentiment.label}
</Text>
)}
</Flex>
{/* Away Team */}
<Flex align="center" gap={2} flex="1" justify="flex-start">
{m.away_logo_url && (
<Image
src={m.away_logo_url}
alt={m.away}
boxSize="32px"
borderRadius="full"
objectFit="cover"
border="2px solid"
borderColor="gray.200"
/>
)}
<Text fontSize="sm" fontWeight="medium" textAlign="left" color={listDateText}>
{m.away}
</Text>
</Flex>
</Flex>
</Flex>
{href && (
@@ -905,10 +945,12 @@ const CalendarPage: React.FC = () => {
</Flex>
);
}
if (selected.comp?.name || selected.match.__compName) {
const compName = selected.comp?.name || selected.match.__compName;
// Don't show "Všechny soutěže" badge - only show specific competition names
if (compName && compName !== 'Všechny soutěže') {
return (
<Flex justify="center">
<Badge colorScheme="purple">{selected.comp?.name || selected.match.__compName}</Badge>
<Badge colorScheme="purple">{compName}</Badge>
</Flex>
);
}
@@ -971,20 +1013,112 @@ const CalendarPage: React.FC = () => {
<Text fontSize="md" color="gray.700">
{selected.match.time || '—'}
</Text>
{(() => {
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
const hasScore = Boolean(selected.match.score);
if (!hasScore && !isPast && modalCountdown.countdownString) {
return (
<Badge colorScheme="orange" mt={2} fontSize="sm" px={2} py={1}>
Začíná za {modalCountdown.countdownString}
</Badge>
);
}
return null;
})()}
</Box>
{/* Enhanced Countdown Display for Upcoming Matches */}
{(() => {
const dt = new Date(`${selected.match.date}T${(selected.match.time || '00:00')}:00`);
const isPast = Date.now() >= dt.getTime();
const hasScore = Boolean(selected.match.score);
if (!hasScore && !isPast && modalCountdown.isActive && modalCountdown.timeRemaining > 0) {
const days = Math.floor(modalCountdown.timeRemaining / (24 * 60 * 60 * 1000));
const hours = Math.floor((modalCountdown.timeRemaining % (24 * 60 * 60 * 1000)) / (60 * 60 * 1000));
const minutes = Math.floor((modalCountdown.timeRemaining % (60 * 60 * 1000)) / (60 * 1000));
const seconds = Math.floor((modalCountdown.timeRemaining % (60 * 1000)) / 1000);
return (
<Box
mt={4}
p={4}
bg="orange.50"
borderRadius="lg"
borderWidth="2px"
borderColor="orange.200"
>
<Text fontSize="sm" fontWeight="semibold" color="orange.800" mb={3} textAlign="center">
Zápas začíná za
</Text>
<Grid
templateColumns={days > 0 ? "repeat(4, 1fr)" : "repeat(3, 1fr)"}
gap={3}
>
{days > 0 && (
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{days}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{days === 1 ? 'den' : days < 5 ? 'dny' : 'dní'}
</Text>
</Box>
)}
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(hours).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{hours === 1 ? 'hodina' : hours < 5 ? 'hodiny' : 'hodin'}
</Text>
</Box>
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(minutes).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{minutes === 1 ? 'minuta' : minutes < 5 ? 'minuty' : 'minut'}
</Text>
</Box>
<Box textAlign="center">
<Box
bg="white"
borderRadius="md"
p={3}
borderWidth="1px"
borderColor="orange.300"
boxShadow="sm"
>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{String(seconds).padStart(2, '0')}
</Text>
</Box>
<Text fontSize="xs" color="gray.600" mt={1} fontWeight="medium">
{seconds === 1 ? 'sekunda' : seconds < 5 ? 'sekundy' : 'sekund'}
</Text>
</Box>
</Grid>
</Box>
);
}
return null;
})()}
<Box h="1px" bg="gray.200" />
<Heading as="h3" size="sm">Odběr notifikací pro fanoušky</Heading>
<Text fontSize="sm" color="gray.600">Zadejte svůj email a budeme vás informovat o novinkách a zápasech.</Text>
+43 -83
View File
@@ -4,7 +4,7 @@ import { FiArrowRight, FiCalendar, FiUsers, FiAward, FiChevronLeft, FiChevronRig
import '../styles/theme.css';
import './styles/UnifiedHome.css';
import { getPublicSettings } from '../services/settings';
import { assetUrl } from '../utils/url';
import { assetUrl, sanitizeClubName } from '../utils/url';
import { getPlayers as apiGetPlayers, Player as ApiPlayer } from '../services/players';
import { getSponsors as apiGetSponsors, Sponsor as ApiSponsor } from '../services/sponsors';
import BlogCardsScroller from '../components/home/BlogCardsScroller';
@@ -17,6 +17,7 @@ import NewsletterSubscribe from '../components/newsletter/NewsletterSubscribe';
import MyUIbrixStyleEditor from '../components/editor/MyUIbrixEditor';
import ClubModal from '../components/home/ClubModal';
import MatchModal from '../components/home/MatchModal';
import { useAllPageElementConfigs } from '../hooks/usePageElementConfig';
// Types for real API-driven data
type NewsItem = {
@@ -101,6 +102,9 @@ const HomePage: React.FC = () => {
const [aliasMap, setAliasMap] = useState<Record<string, { alias: string; original_name?: string }>>({});
const [settings, setSettings] = useState<any>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
// MyUIbrix element configuration hook for live preview
const { getVariant, isVisible, loading: configLoading } = useAllPageElementConfigs('homepage');
useEffect(() => {
let cancelled = false;
@@ -578,52 +582,8 @@ const HomePage: React.FC = () => {
return () => { disposed = true; };
}, [clubLogo]);
// Listen to MyUIbrix events for live preview
useEffect(() => {
const handleMyUIbrixChange = (e: CustomEvent) => {
const { elementName, variant, visible, previewMode } = e.detail;
if (!previewMode) return; // Only respond to preview mode changes
// For now, log the change - full implementation would update element visibility/variant
console.log(`MyUIbrix: ${elementName} -> ${variant} (visible: ${visible})`);
// You can implement logic here to dynamically show/hide or restyle elements
// For example:
// - Toggle display on data-element sections based on visibility
// - Apply variant-specific classes
};
const handleMyUIbrixStyleChange = (e: CustomEvent) => {
const { elementName, styles, previewMode } = e.detail;
if (!previewMode) return;
// Apply custom styles to elements
const elements = document.querySelectorAll(`[data-element="${elementName}"]`);
elements.forEach((el: any) => {
Object.keys(styles).forEach((key) => {
el.style[key] = styles[key];
});
});
};
const handleMyUIbrixReorder = (e: CustomEvent) => {
const { order, previewMode } = e.detail;
if (!previewMode) return;
// Reorder elements based on the order array
console.log('MyUIbrix: Reorder elements', order);
};
window.addEventListener('myuibrix-change' as any, handleMyUIbrixChange);
window.addEventListener('myuibrix-style-change' as any, handleMyUIbrixStyleChange);
window.addEventListener('myuibrix-reorder' as any, handleMyUIbrixReorder);
return () => {
window.removeEventListener('myuibrix-change' as any, handleMyUIbrixChange);
window.removeEventListener('myuibrix-style-change' as any, handleMyUIbrixStyleChange);
window.removeEventListener('myuibrix-reorder' as any, handleMyUIbrixReorder);
};
}, []);
// MyUIbrix events are handled by useAllPageElementConfigs hook
// It automatically updates getVariant() and isVisible() when changes occur in edit mode
// Countdown to next match (uses selected competition upcoming if available)
useEffect(() => {
@@ -707,9 +667,9 @@ const HomePage: React.FC = () => {
<div className="list">
{(facrCompetitions[matchesTab]?.matches || []).slice(0,4).map((m:any, idx:number) => (
<a key={m.id || idx} className="row" href={m.facr_link || m.report_url || '#'} target="_blank" rel="noopener noreferrer">
<div className="team"><img src={assetUrl(m.home_logo_url)} alt={m.home} /><span>{m.home}</span></div>
<div className="team"><img src={assetUrl(m.home_logo_url)} alt={m.home} /><span>{sanitizeClubName(m.home)}</span></div>
<div className="meta"><span>{new Date(`${m.date}T${(m.time||'00:00')}:00`).toLocaleDateString()}</span><span></span><span>{m.time || ''}</span></div>
<div className="team"><img src={assetUrl(m.away_logo_url)} alt={m.away} /><span>{m.away}</span></div>
<div className="team"><img src={assetUrl(m.away_logo_url)} alt={m.away} /><span>{sanitizeClubName(m.away)}</span></div>
</a>
))}
</div>
@@ -834,12 +794,12 @@ const HomePage: React.FC = () => {
<div className="row teams">
<div className="team">
<img src={assetUrl(m.home_logo_url)} alt={m.home} />
<span>{m.home}</span>
<span>{sanitizeClubName(m.home)}</span>
</div>
<span className="vs">vs</span>
<div className="team">
<img src={assetUrl(m.away_logo_url)} alt={m.away} />
<span>{m.away}</span>
<span>{sanitizeClubName(m.away)}</span>
</div>
</div>
</a>
@@ -1404,8 +1364,8 @@ const HomePage: React.FC = () => {
</div>
</div>
{/* Hero section: variant controlled by settings.hero_style */}
{heroStyle === 'grid' && (
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
<section data-element="hero" className="hero-grid">
{news[0] ? (
<a href={`/news/${news[0].slug || news[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
@@ -1435,10 +1395,11 @@ const HomePage: React.FC = () => {
</a>
))}
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, news.length - 1))) }).map((_, idx) => (
<div key={`placeholder-${idx}`} className="hero-card small" style={{ background: 'var(--bg-soft)', pointerEvents: 'none' }}>
<div className="overlay" style={{ background: 'linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.3) 40%, rgba(0,0,0,0.6) 100%)' }}>
<div style={{ opacity: 0.6, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>Aktuality</div>
<h3 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)', opacity: 0.6 }}>Připravujeme...</h3>
<div key={`placeholder-${idx}`} className="hero-card small" style={{ pointerEvents: '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>
</div>
))}
@@ -1446,7 +1407,7 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Banner: homepage_middle */}
{(banners || []).some(b => b.placement === 'homepage_middle') && (
{(banners || []).some(b => b.placement === 'homepage_middle') && isVisible('banner', true) && (
<section data-element="banner" className="banner banner-middle" style={{ margin: '24px 0', textAlign: 'center' }}>
{(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 }}>
@@ -1458,7 +1419,7 @@ const HomePage: React.FC = () => {
)}
{/* Featured articles grid (uses Articles.featured flag) */}
{featured.length > 0 && (
{featured.length > 0 && isVisible('news', true) && (
<section data-element="news" className="three-cols" style={{ marginTop: 8 }}>
{featured.map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none', height: 220 }}>
@@ -1488,19 +1449,19 @@ const HomePage: React.FC = () => {
</div>
</section>
)}
{heroStyle === 'scroller' && (
{getVariant('hero', heroStyle) === 'scroller' && isVisible('hero', true) && (
<section data-element="hero">
<BlogCardsScroller />
</section>
)}
{(heroStyle === 'swiper' || heroStyle === 'swiper_full') && (
<section data-element="hero" style={heroStyle === 'swiper_full' ? { marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' } : undefined}>
{(getVariant('hero', heroStyle) === 'swiper' || getVariant('hero', heroStyle) === 'swiper_full') && isVisible('hero', true) && (
<section data-element="hero" style={getVariant('hero', heroStyle) === 'swiper_full' ? { marginLeft: 'calc(50% - 50vw)', marginRight: 'calc(50% - 50vw)' } : undefined}>
<BlogSwiper />
</section>
)}
{/* Next match: categories (competitions) with left/right navigation - synced with matchesTab */}
{facrCompetitions.length > 0 ? (
{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 : [];
@@ -1532,7 +1493,7 @@ const HomePage: React.FC = () => {
</button>
<div className="team">
<img className="logo" src={assetUrl(show?.home_logo_url) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
<div>{show?.home || matches[0]?.homeTeam || clubName}</div>
<div>{sanitizeClubName(show?.home || matches[0]?.homeTeam || clubName)}</div>
</div>
<div className="countdown">
<div style={{ fontSize: '0.8rem', opacity: 0.85, marginBottom: 4 }}>{comp?.name || 'Soutěž'}</div>
@@ -1541,7 +1502,7 @@ const HomePage: React.FC = () => {
</div>
<div className="team">
<img className="logo" src={assetUrl(show?.away_logo_url) || '/images/club-opponent.png'} alt="Hosté" />
<div>{show?.away || matches[0]?.awayTeam || 'Soupeř'}</div>
<div>{sanitizeClubName(show?.away || matches[0]?.awayTeam || 'Soupeř')}</div>
</div>
<button
aria-label="Další soutěž"
@@ -1554,11 +1515,11 @@ const HomePage: React.FC = () => {
</section>
);
})()
) : (
<section className="next-match">
) : isVisible('matches', true) ? (
<section data-element="matches" className="next-match">
<div className="team">
<img className="logo" src={assetUrl(matches[0]?.homeLogoURL) || assetUrl(clubLogo) || '/images/club-logo.png'} alt="Domácí" />
<div>{matches[0]?.homeTeam || clubName}</div>
<div>{sanitizeClubName(matches[0]?.homeTeam || clubName)}</div>
</div>
<div className="countdown">
{countdown || '—'}
@@ -1571,10 +1532,10 @@ const HomePage: React.FC = () => {
</div>
<div className="team">
<img className="logo" src={assetUrl(matches[0]?.awayLogoURL) || '/images/club-opponent.png'} alt="Hosté" />
<div>{matches[0]?.awayTeam || 'Soupeř'}</div>
<div>{sanitizeClubName(matches[0]?.awayTeam || 'Soupeř')}</div>
</div>
</section>
)}
) : null}
{/* Matches slider with scores by competition */}
{facrCompetitions.length > 0 && (
@@ -1625,7 +1586,7 @@ const HomePage: React.FC = () => {
<div className="teams">
<div className="team">
<img src={assetUrl(m.home_logo_url)} alt={m.home} />
<div className="name">{m.home}</div>
<div className="name">{sanitizeClubName(m.home)}</div>
</div>
<div className="score">
{m.score ? (
@@ -1640,7 +1601,7 @@ const HomePage: React.FC = () => {
</div>
<div className="team">
<img src={assetUrl(m.away_logo_url)} alt={m.away} />
<div className="name">{m.away}</div>
<div className="name">{sanitizeClubName(m.away)}</div>
</div>
</div>
</div>
@@ -1658,6 +1619,7 @@ const HomePage: React.FC = () => {
{/* Competition tables moved into right column below */}
{/* Standings: tabs per competition (only FACR), clicking row opens ClubModal */}
{isVisible('table', true) && (
<section data-element="table" className="standings" style={{ marginTop: 32 }}>
<div>
<div className="section-head" style={{ marginTop: 0 }}>
@@ -1690,15 +1652,6 @@ const HomePage: React.FC = () => {
<h3>Tabulky</h3>
<a href="/tabulky" className="see-all" style={{ fontSize: '0.85rem' }}>Zobrazit vše <FiArrowRight size={14} /></a>
</div>
<div className="tabs" style={{ marginBottom: 12 }}>
{standings.length > 0 ? standings.map((s:any, i: number) => (
<button key={`${s.name}-${i}`} className={i===matchesTab ? 'active' : ''} onClick={() => setMatchesTab(i)}>
<span>{s.name || s.competition || 'Soutěž'}</span>
</button>
)) : ['Liga'].map((t: string, i: number) => (
<button key={`${t}-${i}`} className={i===matchesTab ? 'active' : ''} disabled>{t}</button>
))}
</div>
{standings.length > 0 ? (
<div className="standings">
{(standings[matchesTab]?.table || standings[matchesTab]?.rows || []).slice(0,8).map((row: any, idx: number) => {
@@ -1738,9 +1691,10 @@ const HomePage: React.FC = () => {
</div>
</div>
</section>
)}
{/* Players scroller (optional) */}
{players.length > 0 && (
{players.length > 0 && isVisible('team', false) && (
<section data-element="team" className="players-scroller" style={{ marginTop: 32 }}>
<div className="section-head">
<h3>Hráči</h3>
@@ -1775,13 +1729,15 @@ const HomePage: React.FC = () => {
)}
{/* Videos */}
{isVisible('videos', false) && (
<section data-element="videos" style={{ marginTop: 32, marginBottom: 32 }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<VideosSection />
</div>
</section>
)}
{true && (
{isVisible('merch', true) && (
<section data-element="merch" style={{ marginTop: 24, marginBottom: 24 }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '0 12px' }}>
<MerchSection />
@@ -1790,11 +1746,13 @@ const HomePage: React.FC = () => {
)}
{/* Newsletter subscription CTA */}
{isVisible('newsletter', false) && (
<section data-element="newsletter" className="newsletter-cta" style={{ marginTop: 24, marginBottom: 24 }}>
<div className="card" style={{ maxWidth: 960, margin: '0 auto' }}>
<NewsletterSubscribe />
</div>
</section>
)}
{/* Banner: homepage_top */}
{(banners || []).some(b => b.placement === 'homepage_top') && (
@@ -1821,6 +1779,7 @@ const HomePage: React.FC = () => {
)}
{/* Sponsors: grid or slider (controlled by settings); dark theme supported; full-bleed */}
{isVisible('sponsors', true) && (
<section
data-element="sponsors"
className={`sponsors ${sponsorsTheme === 'dark' ? 'dark' : ''}`}
@@ -1874,6 +1833,7 @@ const HomePage: React.FC = () => {
</div>
)}
</section>
)}
</div>
<ClubModal
isOpen={isModalOpen}
+9 -43
View File
@@ -4,7 +4,7 @@ import MainLayout from '../components/layout/MainLayout';
import { getPublicSettings } from '../services/settings';
import { getCompetitionAliasesPublic, CompetitionAlias } from '../services/competitionAliases';
import { sortCategoriesWithOrder } from '../utils/categorySort';
import { assetUrl } from '../utils/url';
import { assetUrl, sanitizeClubName } from '../utils/url';
import MatchModal from '../components/home/MatchModal';
import { useCountdown, useMultipleCountdowns } from '../hooks/useCountdown';
import '../styles/theme.css';
@@ -54,11 +54,13 @@ const MatchesPage: React.FC = () => {
}
};
// Helper function to truncate long club names
// Helper function to sanitize and truncate long club names
const truncateClubName = (name: string, maxLength: number = 35) => {
if (!name) return name;
if (name.length <= maxLength) return name;
return name.substring(0, maxLength).trim() + '…';
// First sanitize the club name
const sanitized = sanitizeClubName(name);
if (sanitized.length <= maxLength) return sanitized;
return sanitized.substring(0, maxLength).trim() + '…';
};
// Format date to Czech format
@@ -457,13 +459,7 @@ const MatchesPage: React.FC = () => {
}}
>
<div style={{ fontSize: '0.85rem', color: textSecondary, marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 600 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
<span>
{formatCzechDate(m.date, m.time || '00:00')}
</span>
<span style={{ background: 'var(--chakra-colors-brand-primary, #3b82f6)', color: 'white', padding: '4px 10px', borderRadius: 8, fontSize: '0.8rem', fontWeight: 700 }}>{m.time}</span>
@@ -529,11 +525,7 @@ const MatchesPage: React.FC = () => {
</div>
</div>
{m.venue && (
<div style={{ fontSize: '0.85rem', color: textSecondary, marginTop: 12, textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
<div style={{ fontSize: '0.85rem', color: textSecondary, marginTop: 12, textAlign: 'center' }}>
{m.venue}
</div>
)}
@@ -565,38 +557,12 @@ const MatchesPage: React.FC = () => {
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
boxShadow: `0 2px 8px ${color.shadow}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 6
boxShadow: `0 2px 8px ${color.shadow}`
}}>
{sentiment.label === 'Výhra' && '🏆'}
{sentiment.label === 'Remíza' && '⚖️'}
{sentiment.label === 'Prohra' && '😔'}
{sentiment.label}
</div>
);
}
if (hasScore && isPast) {
return (
<div style={{
fontSize: '0.75rem',
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
marginTop: 12,
padding: '6px 12px',
borderRadius: 8,
textAlign: 'center',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)'
}}>
Skončeno
</div>
);
}
if (!hasScore && isPast) {
return (
<div style={{
+98 -72
View File
@@ -17,6 +17,7 @@ import { FONT_PAIRINGS, loadGoogleFont, getFontStyleColor } from '../config/font
import MapLinkImporter from '../components/admin/MapLinkImporter';
import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
import { fetchLogoFromLogoAPI } from '../utils/sportLogosAPI';
const SetupPage: React.FC = () => {
const [loading, setLoading] = useState(true);
@@ -41,6 +42,8 @@ const SetupPage: React.FC = () => {
const resolveLogoUrl = (u?: string | null) => {
if (!u) return undefined;
// If it's a logoapi URL, use it directly (no proxy needed)
if (u.includes('logoapi.sportcreative.eu')) return u;
// If it's a backend-relative path or dist asset, use assetUrl helper
if (u.startsWith('/uploads') || u.startsWith('/dist') || u.startsWith('/api/')) return assetUrl(u);
// If it's an absolute remote URL, route through backend proxy to avoid CORS/hotlinking issues
@@ -170,13 +173,28 @@ const SetupPage: React.FC = () => {
}
}, [selectedFont]);
const handleSelectClub = (item: SearchResult) => {
setClubId(item.club_id || '');
const handleSelectClub = async (item: SearchResult) => {
const clubIdValue = item.club_id || '';
setClubId(clubIdValue);
setClubType(item.club_type || 'football');
setClubName(item.name || '');
setClubLogoUrl(item.logo_url || '');
setClubUrl(item.url || '');
setClubQuery(item.name || '');
// Try to fetch logo from logoapi first, fallback to FACR logo
let logoUrl = '';
if (clubIdValue) {
const logoApiUrl = await fetchLogoFromLogoAPI(clubIdValue, item.name);
if (logoApiUrl) {
logoUrl = logoApiUrl;
}
}
// Fallback to FACR logo if logoapi doesn't have it
if (!logoUrl && item.logo_url) {
logoUrl = item.logo_url;
}
setClubLogoUrl(logoUrl);
// Auto-fill sender display name from club name if empty
if (!smtpFromName && item.name) {
setSmtpFromName(item.name);
@@ -188,8 +206,8 @@ const SetupPage: React.FC = () => {
}
} catch {}
// Try to extract colors
if (item.logo_url) {
extractPalette(item.logo_url, 5)
if (logoUrl) {
extractPalette(logoUrl, 5)
.then((colors) => {
if (!colors || colors.length === 0) return;
const presets = generateThemeCandidates(colors);
@@ -304,8 +322,8 @@ const SetupPage: React.FC = () => {
const fd = new FormData();
fd.append('file', f);
fd.append('preserve_quality', 'true');
// Upload should go to the API root (usually /api/v1/upload). Use configured API_URL
const uploadUrl = `${(API_URL || '').replace(/\/$/, '')}/upload`;
// Upload should go to the API root (usually /api/v1/upload). Use configured API_URL
const uploadUrl = `${(API_URL || '').replace(/\/$/, '')}/upload`;
const res = await fetch(uploadUrl, { method: 'POST', body: fd });
if (!res.ok) throw new Error('Upload failed');
const data = await res.json();
@@ -315,6 +333,25 @@ const SetupPage: React.FC = () => {
url = parsed.pathname + parsed.search + parsed.hash;
} catch {}
setClubLogoUrl(url);
// Also upload to logoapi if we have a club ID
if (clubId) {
try {
const logoFd = new FormData();
logoFd.append('logo', f);
const logoApiRes = await fetch(`https://logoapi.sportcreative.eu/logos/${clubId}`, {
method: 'POST',
body: logoFd,
});
if (logoApiRes.ok) {
toast({ title: 'Logo nahráno', description: 'Logo bylo nahráno na logoapi i lokálně', status: 'success', duration: 3000 });
}
} catch (logoApiErr) {
console.warn('Failed to upload to logoapi:', logoApiErr);
// Don't fail the whole upload if logoapi fails
}
}
// Try to extract colors from uploaded logo
try { const colors = await extractPalette(url, 5); const presets = generateThemeCandidates(colors); setThemePresets(presets); if (presets[0]) { setPrimaryColor(presets[0].primary); setSecondaryColor(presets[0].secondary); setAccentColor(presets[0].accent); setBackgroundColor(presets[0].background); setTextColor(presets[0].text); setSelectedPreset(0); } } catch {}
} catch (e) {
@@ -376,17 +413,28 @@ const SetupPage: React.FC = () => {
setSelectedPreset(idx);
};
// Redirect if setup not required
useEffect(() => {
if (!loading && !requiresSetup) {
navigate('/login', { replace: true });
}
}, [loading, requiresSetup, navigate]);
if (loading) return <Box p={8}>Načítání</Box>;
if (!requiresSetup) {
navigate('/login', { replace: true });
return null;
}
// Get selected font pairing for live preview
const selectedFontPairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
const fontHeading = selectedFontPairing?.cssHeading || 'inherit';
const fontBody = selectedFontPairing?.cssBody || 'inherit';
return (
<Box minH="100vh" bg="gray.50" display="flex" alignItems="center" justifyContent="center" px={8} py={8}>
<Box as="form" onSubmit={handleSubmit} w="100%" maxW="3xl" p={8} bg={bg} borderRadius="xl" boxShadow="lg" borderWidth="1px" borderColor={borderCol}>
<Box minH="100vh" bg="gray.50" display="flex" alignItems="center" justifyContent="center" px={8} py={8} fontFamily={fontBody}>
<Box as="form" onSubmit={handleSubmit} w="100%" maxW="3xl" p={8} bg={bg} borderRadius="xl" boxShadow="lg" borderWidth="1px" borderColor={borderCol} fontFamily={fontBody}>
<VStack spacing={3} mb={6} align="stretch">
<Heading size="xl">🚀 Vítejte v nastavení vašeho webu!</Heading>
<Heading size="xl" fontFamily={fontHeading}>🚀 Vítejte v nastavení vašeho webu!</Heading>
<Text fontSize="md" color="gray.600">
Nastavte základní informace o vašem klubu. Můžete vše vyplnit nyní, nebo některé údaje doplnit později v administraci.
</Text>
@@ -401,7 +449,7 @@ const SetupPage: React.FC = () => {
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<Box>
<Heading as="h3" size="md" mb={4}>🔐 Administrátorský účet</Heading>
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>🔐 Administrátorský účet</Heading>
<VStack align="stretch" spacing={4}>
<FormControl isRequired>
<FormLabel>Email administrátora</FormLabel>
@@ -439,7 +487,7 @@ const SetupPage: React.FC = () => {
</Box>
<Box>
<Heading as="h3" size="md" mb={4}> Informace o klubu</Heading>
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}> Informace o klubu</Heading>
<VStack align="stretch" spacing={4}>
<FormControl>
<FormLabel>Hledat klub (FAČR)</FormLabel>
@@ -452,7 +500,7 @@ const SetupPage: React.FC = () => {
{clubQuery && searchResults?.length > 0 && (
<Box mt={2} borderWidth="1px" borderRadius="md" maxH="240px" overflowY="auto">
<List spacing={0}>
{searchResults.slice(0, 8).map((r) => (
{searchResults.filter((r) => r.name && r.name.trim() !== '').slice(0, 8).map((r) => (
<ListItem
key={`${r.club_type}-${r.club_id}`}
px={3} py={2} _hover={{ bg: 'gray.50', cursor: 'pointer' }}
@@ -522,33 +570,7 @@ const SetupPage: React.FC = () => {
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>📱 Sociální sítě a fotogalerie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Zadejte odkazy na profily klubu a volitelně na fotogalerii. Lze později upravit v administraci.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={6} mb={2}>
<FormControl>
<FormLabel>Facebook URL</FormLabel>
<Input placeholder="https://www.facebook.com/vas.klub" value={facebookUrl} onChange={(e) => setFacebookUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Instagram URL</FormLabel>
<Input placeholder="https://www.instagram.com/vas.klub" value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>YouTube URL</FormLabel>
<Input placeholder="https://www.youtube.com/@vas_klub" value={youtubeUrl} onChange={(e) => setYoutubeUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>URL fotogalerie</FormLabel>
<Input placeholder="https://photos.example.com/club" value={galleryUrl} onChange={(e) => setGalleryUrl(e.target.value)} />
<FormHelperText>Můžete použít libovolný web (SmugMug, Flickr, Google Photos, Zonerama...).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Popisek odkazu fotogalerie</FormLabel>
<Input placeholder="Fotogalerie" value={galleryLabel} onChange={(e) => setGalleryLabel(e.target.value)} />
</FormControl>
</SimpleGrid>
<Heading as="h3" size="md" mb={2}>🎨 Barvy a vzhled webu</Heading>
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>🎨 Barvy a vzhled webu</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Automaticky z loga (lze upravit). Vyberte jednu z předloh nebo barvy ručně dolaďte.</Text>
{/* Preset selector */}
@@ -571,17 +593,7 @@ const SetupPage: React.FC = () => {
<Button mt={3} variant="ghost" onClick={regenerateFromLogo}>Znovu z loga</Button>
</Box>
)}
<SimpleGrid columns={[1, 1, 3]} spacing={6}>
<FormControl>
<FormLabel>Styl webu</FormLabel>
<Select value={frontpageStyle} onChange={(e) => setFrontpageStyle((e.target.value as any) || 'unified')}>
<option value="unified">Aktuální (Unified)</option>
<option value="magazine">Nový (Magazine)</option>
<option value="pro">Pro (Hero fullscreen)</option>
<option value="edge">Edge (Fullwidth minimal)</option>
</Select>
<FormHelperText>Zvolte výchozí vzhled. Lze později změnit v administraci.</FormHelperText>
</FormControl>
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<FormControl>
<FormLabel>Primární
<Tooltip label="Hlavní barva značky (tlačítka, odkazy, zvýraznění)." hasArrow><InfoOutlineIcon ml={2} /></Tooltip>
@@ -632,8 +644,36 @@ const SetupPage: React.FC = () => {
<Divider my={6} />
<Heading as="h3" size="md" mb={2}> Písmo a typografie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Vyberte vzhled písma pro váš web. Můžete kdykoliv změnit v administraci.</Text>
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📱 Sociální sítě a fotogalerie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Zadejte odkazy na profily klubu a volitelně na fotogalerii. Lze později upravit v administraci.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={6} mb={2}>
<FormControl>
<FormLabel>Facebook URL</FormLabel>
<Input placeholder="https://www.facebook.com/vas.klub" value={facebookUrl} onChange={(e) => setFacebookUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>Instagram URL</FormLabel>
<Input placeholder="https://www.instagram.com/vas.klub" value={instagramUrl} onChange={(e) => setInstagramUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>YouTube URL</FormLabel>
<Input placeholder="https://www.youtube.com/@vas_klub" value={youtubeUrl} onChange={(e) => setYoutubeUrl(e.target.value)} />
</FormControl>
<FormControl>
<FormLabel>URL fotogalerie</FormLabel>
<Input placeholder="https://photos.example.com/club" value={galleryUrl} onChange={(e) => setGalleryUrl(e.target.value)} />
<FormHelperText>Můžete použít libovolný web (SmugMug, Flickr, Google Photos, Zonerama...).</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Popisek odkazu fotogalerie</FormLabel>
<Input placeholder="Fotogalerie" value={galleryLabel} onChange={(e) => setGalleryLabel(e.target.value)} />
</FormControl>
</SimpleGrid>
<Divider my={6} />
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}> Písmo a typografie</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Vyberte vzhled písma pro váš web. Náhled se aplikuje okamžitě na celou stránku.</Text>
<Box mb={4}>
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
{FONT_PAIRINGS.map((font) => (
@@ -662,8 +702,8 @@ const SetupPage: React.FC = () => {
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>📍 GPS poloha a mapa</Heading>
<Text fontSize="sm" mb={4} color="gray.600">Nastavte polohu vašeho stadionu. Můžete vložit odkaz z mapy, nebo zadat souřadnice ručně.</Text>
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📍 GPS poloha a mapa</Heading>
<Text fontSize="sm" mb={4} color="gray.600">Nastavte polohu vašeho stadionu. Můžete vložit odkaz z mapy, nebo zadat souřadnice ručně. Vyberte také styl mapy.</Text>
<Box mb={4}>
<MapLinkImporter
@@ -695,21 +735,7 @@ const SetupPage: React.FC = () => {
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>🎨 Styl mapy</Heading>
<Text fontSize="sm" mb={4} color="gray.600">Vyberte vzhled mapy, který nejlépe pasuje k barvám vašeho klubu.</Text>
<Box mb={4}>
<MapStyleSelector
value={mapStyle}
onChange={setMapStyle}
clubPrimaryColor={primaryColor}
clubSecondaryColor={accentColor}
showPreview={true}
/>
</Box>
<Divider my={6} />
<Heading as="h3" size="md" mb={2}>📧 Kontaktní údaje</Heading>
<Heading as="h3" size="md" mb={2} fontFamily={fontHeading}>📧 Kontaktní údaje</Heading>
<Text fontSize="sm" mb={3} color="gray.600">Tyto údaje se automaticky vyplní při importu z mapy. Můžete je upravit nebo doplnit ručně.</Text>
<SimpleGrid columns={[1, 1, 2]} spacing={4} mb={4}>
<FormControl>
@@ -742,7 +768,7 @@ const SetupPage: React.FC = () => {
<Divider my={6} />
<Heading as="h3" size="md" mb={4}>🔒 Zabezpečení a SMTP</Heading>
<Heading as="h3" size="md" mb={4} fontFamily={fontHeading}>🔒 Zabezpečení a SMTP</Heading>
<SimpleGrid columns={[1, 1, 2]} spacing={6}>
<FormControl>
<FormLabel>JWT tajemství</FormLabel>
@@ -220,6 +220,8 @@ const AnalyticsAdminPage: React.FC = () => {
} | null>(null);
const [countryDetails, setCountryDetails] = useState<any>(null);
const [loadingCountryDetails, setLoadingCountryDetails] = useState(false);
const [umamiConfig, setUmamiConfig] = useState<any>(null);
const [showDiagnostics, setShowDiagnostics] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const bgColor = useColorModeValue('white', 'gray.800');
@@ -310,8 +312,18 @@ const AnalyticsAdminPage: React.FC = () => {
useEffect(() => {
fetchAnalytics(timeRange);
fetchUmamiConfig();
}, [timeRange]);
const fetchUmamiConfig = async () => {
try {
const response = await api.get('/umami/config');
setUmamiConfig(response.data);
} catch (error) {
console.error('Failed to fetch Umami config:', error);
}
};
const handleCountryClick = async (countryCode: string, countryName: string, value: number) => {
setSelectedCountry({ code: countryCode, name: countryName, value });
setLoadingCountryDetails(true);
@@ -544,6 +556,117 @@ const AnalyticsAdminPage: React.FC = () => {
</Card>
</SimpleGrid>
{/* Diagnostics Panel */}
{(!hasData || showDiagnostics) && (
<Card bg="blue.50" borderColor="blue.300" borderWidth={2}>
<CardBody>
<HStack spacing={3} align="start">
<Icon as={FiActivity} color="blue.500" boxSize={6} mt={1} />
<VStack align="start" spacing={3} flex={1}>
<HStack justify="space-between" w="full">
<Text fontWeight="bold" color="blue.800" fontSize="lg">Diagnostika analytiky</Text>
<Button
size="xs"
variant="ghost"
onClick={() => setShowDiagnostics(!showDiagnostics)}
>
{showDiagnostics ? 'Skrýt' : 'Zobrazit detaily'}
</Button>
</HStack>
{/* Umami Connection Status */}
<Box w="full">
<HStack spacing={2} mb={2}>
<Badge colorScheme={umamiConfig?.enabled ? 'green' : 'red'}>
{umamiConfig?.enabled ? 'Připojeno' : 'Nepřipojeno'}
</Badge>
<Text fontSize="sm" fontWeight="semibold" color="blue.800">
Stav Umami
</Text>
</HStack>
{umamiConfig && (
<VStack align="start" spacing={1} pl={4}>
<Text fontSize="xs" color="blue.700">
<strong>Aktivováno:</strong> {umamiConfig.enabled ? 'Ano' : 'Ne'}
</Text>
{umamiConfig.website_id && (
<Text fontSize="xs" color="blue.700">
<strong>Website ID:</strong> {umamiConfig.website_id}
</Text>
)}
{umamiConfig.reason && (
<Text fontSize="xs" color="red.600">
<strong>Důvod:</strong> {umamiConfig.reason}
</Text>
)}
</VStack>
)}
</Box>
<Divider borderColor="blue.200" />
{/* Why No Data */}
{!hasData && (
<>
<Text fontSize="sm" color="blue.800" fontWeight="semibold">
Proč nejsou k dispozici žádná data?
</Text>
<VStack align="start" spacing={1} pl={4}>
<Text fontSize="xs" color="blue.700">
Umami tracking ještě nezaznamenal žádné návštěvy
</Text>
<Text fontSize="xs" color="blue.700">
Tracking script se načítá pouze na veřejných stránkách (ne na /admin)
</Text>
<Text fontSize="xs" color="blue.700">
Data se aktualizují v reálném čase po návštěvě veřejných stránek
</Text>
</VStack>
<Divider borderColor="blue.200" />
<Text fontSize="sm" color="blue.800" fontWeight="semibold">
Jak vygenerovat testovací data:
</Text>
<VStack align="start" spacing={1} pl={4}>
<Text fontSize="xs" color="blue.700">
1. Otevřete hlavní stránku webu v novém okně inkognito
</Text>
<Text fontSize="xs" color="blue.700">
2. Procházejte několik veřejných stránek (Blog, O klubu, Kontakt...)
</Text>
<Text fontSize="xs" color="blue.700">
3. Počkejte 1-2 minuty a obnovte tuto stránku analytiky
</Text>
</VStack>
<HStack spacing={2} mt={2}>
<Button
size="sm"
colorScheme="blue"
leftIcon={<Icon as={FiGlobe} />}
onClick={() => window.open('/', '_blank')}
>
Otevřít hlavní stránku
</Button>
<Button
size="sm"
colorScheme="blue"
variant="outline"
leftIcon={<Icon as={FiZap} />}
onClick={() => window.location.reload()}
>
Obnovit analytiku
</Button>
</HStack>
</>
)}
</VStack>
</HStack>
</CardBody>
</Card>
)}
{/* Error Message */}
{errorMessage && (
<Card bg="orange.50" borderColor="orange.300" borderWidth={2}>
+514
View File
@@ -0,0 +1,514 @@
/**
* DevDocsPage - Admin Documentation Viewer
*
* REQUIRED DEPENDENCIES:
* npm install react-markdown react-syntax-highlighter
* npm install --save-dev @types/react-syntax-highlighter
*
* This component requires these packages to render markdown with syntax highlighting.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
HStack,
Button,
Input,
InputGroup,
InputLeftElement,
Select,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Badge,
Icon,
useColorModeValue,
Divider,
Code,
Alert,
AlertIcon,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Spinner,
useToast,
} from '@chakra-ui/react';
import {
FiSearch,
FiBook,
FiCode,
FiFileText,
FiLayers,
FiTool,
FiHome,
FiDownload,
FiRefreshCw,
} from 'react-icons/fi';
import { Link as RouterLink } from 'react-router-dom';
// @ts-ignore - Install with: npm install react-markdown
import ReactMarkdown from 'react-markdown';
// @ts-ignore - Install with: npm install react-syntax-highlighter
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
// @ts-ignore
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
interface DocFile {
name: string;
path: string;
category: string;
description: string;
icon: any;
tags: string[];
}
const DevDocsPage: React.FC = () => {
const [selectedDoc, setSelectedDoc] = useState<string>('');
const [docContent, setDocContent] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [loading, setLoading] = useState(false);
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const sidebarBg = useColorModeValue('gray.50', 'gray.900');
// Documentation files registry
const docFiles: DocFile[] = [
{
name: 'MyUIbrix Elementor Features',
path: '/DOCS/MYUIBRIX_ELEMENTOR_FEATURES.md',
category: 'Features',
description: 'Complete guide to Elementor-style page builder features',
icon: FiLayers,
tags: ['myuibrix', 'elementor', 'editor', 'features'],
},
{
name: 'MyUIbrix Enhancement Summary',
path: '/DOCS/MYUIBRIX_ENHANCEMENT_SUMMARY.md',
category: 'Features',
description: 'Implementation summary of Elementor enhancements',
icon: FiTool,
tags: ['myuibrix', 'enhancement', 'summary'],
},
{
name: 'MyUIbrix Quick Start',
path: '/DOCS/MYUIBRIX_QUICK_START.md',
category: 'Guides',
description: 'Quick reference guide for MyUIbrix editor',
icon: FiBook,
tags: ['myuibrix', 'quick-start', 'guide'],
},
{
name: 'MyUIbrix Fixes',
path: '/DOCS/MYUIBRIX_FIXES.md',
category: 'Technical',
description: 'Technical fixes and improvements documentation',
icon: FiTool,
tags: ['myuibrix', 'fixes', 'technical'],
},
{
name: 'Integration Guide',
path: '/DOCS/INTEGRATION_GUIDE.md',
category: 'Development',
description: 'How to integrate MyUIbrix components',
icon: FiCode,
tags: ['integration', 'development', 'components'],
},
{
name: 'CSS Classes Reference',
path: '/DOCS/CSS_CLASSES_REFERENCE.md',
category: 'Reference',
description: 'Complete CSS classes and selectors reference',
icon: FiFileText,
tags: ['css', 'styling', 'classes', 'reference'],
},
{
name: 'Admin Functionality Report',
path: '/DOCS/ADMIN_FUNCTIONALITY_REPORT.md',
category: 'Admin',
description: 'Complete admin panel functionality documentation',
icon: FiTool,
tags: ['admin', 'functionality', 'report'],
},
{
name: 'Setup Improvements',
path: '/DOCS/SETUP_IMPROVEMENTS.md',
category: 'Setup',
description: 'Initial setup and configuration guide',
icon: FiBook,
tags: ['setup', 'configuration', 'improvements'],
},
{
name: 'Docker Enhancements',
path: '/DOCS/DOCKER_ENHANCEMENTS_SUMMARY.md',
category: 'DevOps',
description: 'Docker setup and deployment guide',
icon: FiCode,
tags: ['docker', 'deployment', 'devops'],
},
];
const categories = ['all', 'Features', 'Guides', 'Technical', 'Development', 'Reference', 'Admin', 'Setup', 'DevOps'];
// Filter documents
const filteredDocs = docFiles.filter(doc => {
const matchesSearch = searchQuery === '' ||
doc.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
doc.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
doc.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesCategory = selectedCategory === 'all' || doc.category === selectedCategory;
return matchesSearch && matchesCategory;
});
// Load document content
const loadDocument = async (docPath: string) => {
setLoading(true);
setSelectedDoc(docPath);
try {
// In production, fetch from backend API
const response = await fetch(docPath);
if (!response.ok) throw new Error('Failed to load document');
const content = await response.text();
setDocContent(content);
} catch (error) {
console.error('Error loading document:', error);
// Fallback: show error message
setDocContent(`# Document Not Found\n\nThe requested documentation file could not be loaded.\n\n**Path**: ${docPath}\n\nPlease ensure the documentation files are properly deployed.`);
toast({
title: 'Error loading document',
description: 'The documentation file could not be loaded',
status: 'error',
duration: 3000,
});
} finally {
setLoading(false);
}
};
// Load first document on mount
useEffect(() => {
if (docFiles.length > 0) {
loadDocument(docFiles[0].path);
}
}, []);
// Custom markdown components
const markdownComponents = {
code({ node, inline, className, children, ...props }: any) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<Code {...props}>{children}</Code>
);
},
h1: ({ children }: any) => (
<Heading as="h1" size="2xl" mb={6} mt={8}>
{children}
</Heading>
),
h2: ({ children }: any) => (
<Heading as="h2" size="xl" mb={4} mt={6}>
{children}
</Heading>
),
h3: ({ children }: any) => (
<Heading as="h3" size="lg" mb={3} mt={5}>
{children}
</Heading>
),
p: ({ children }: any) => (
<Text mb={4} lineHeight="tall">
{children}
</Text>
),
ul: ({ children }: any) => (
<VStack as="ul" align="stretch" spacing={2} mb={4} pl={6}>
{children}
</VStack>
),
li: ({ children }: any) => (
<Text as="li" mb={1}>
{children}
</Text>
),
};
// Download document
const downloadDocument = () => {
const blob = new Blob([docContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = selectedDoc.split('/').pop() || 'document.md';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: 'Document downloaded',
status: 'success',
duration: 2000,
});
};
return (
<Box minH="100vh" bg={useColorModeValue('gray.50', 'gray.900')}>
{/* Breadcrumb */}
<Box bg={bgColor} borderBottom="1px" borderColor={borderColor} py={4}>
<Container maxW="container.xl">
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={RouterLink} to="/admin">
<HStack spacing={2}>
<FiHome />
<Text>Admin</Text>
</HStack>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink>
<HStack spacing={2}>
<FiBook />
<Text>Developer Documentation</Text>
</HStack>
</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</Container>
</Box>
{/* Header */}
<Box bg={bgColor} borderBottom="1px" borderColor={borderColor} py={6}>
<Container maxW="container.xl">
<VStack align="stretch" spacing={4}>
<HStack justify="space-between" align="start">
<VStack align="start" spacing={2}>
<Heading size="lg">📚 Developer Documentation</Heading>
<Text color="gray.600">
Complete technical documentation for MyUIbrix and admin features
</Text>
</VStack>
<HStack spacing={2}>
<Button
leftIcon={<FiDownload />}
size="sm"
variant="outline"
onClick={downloadDocument}
isDisabled={!selectedDoc}
>
Download
</Button>
<Button
leftIcon={<FiRefreshCw />}
size="sm"
variant="outline"
onClick={() => selectedDoc && loadDocument(selectedDoc)}
isLoading={loading}
>
Refresh
</Button>
</HStack>
</HStack>
{/* Search and Filter */}
<HStack spacing={4}>
<InputGroup maxW="400px">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray.300" />
</InputLeftElement>
<Input
placeholder="Search documentation..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg={bgColor}
/>
</InputGroup>
<Select
maxW="200px"
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
bg={bgColor}
>
{categories.map(cat => (
<option key={cat} value={cat}>
{cat === 'all' ? 'All Categories' : cat}
</option>
))}
</Select>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{filteredDocs.length} docs
</Badge>
</HStack>
</VStack>
</Container>
</Box>
{/* Main Content */}
<Container maxW="container.xl" py={8}>
<HStack align="start" spacing={6}>
{/* Sidebar */}
<VStack
width="350px"
bg={sidebarBg}
borderRadius="lg"
p={4}
align="stretch"
spacing={3}
maxH="calc(100vh - 300px)"
overflowY="auto"
position="sticky"
top="20px"
>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Documentation Files
</Text>
{filteredDocs.length === 0 ? (
<Alert status="info" borderRadius="md">
<AlertIcon />
No documents found
</Alert>
) : (
filteredDocs.map((doc) => (
<Box
key={doc.path}
p={4}
bg={selectedDoc === doc.path ? 'blue.50' : bgColor}
borderRadius="md"
cursor="pointer"
transition="all 0.2s"
borderWidth="2px"
borderColor={selectedDoc === doc.path ? 'blue.400' : 'transparent'}
_hover={{
transform: 'translateX(4px)',
borderColor: 'blue.300',
}}
onClick={() => loadDocument(doc.path)}
>
<HStack spacing={3} mb={2}>
<Icon as={doc.icon} boxSize={5} color="blue.500" />
<VStack align="start" spacing={0} flex={1}>
<Text fontWeight="bold" fontSize="sm">
{doc.name}
</Text>
<Badge colorScheme="purple" fontSize="xs">
{doc.category}
</Badge>
</VStack>
</HStack>
<Text fontSize="xs" color="gray.600">
{doc.description}
</Text>
<HStack spacing={1} mt={2} flexWrap="wrap">
{doc.tags.slice(0, 3).map(tag => (
<Badge key={tag} size="sm" variant="outline" fontSize="xs">
{tag}
</Badge>
))}
</HStack>
</Box>
))
)}
</VStack>
{/* Content Area */}
<Box
flex={1}
bg={bgColor}
borderRadius="lg"
p={8}
boxShadow="sm"
minH="600px"
>
{loading ? (
<VStack spacing={4} py={12}>
<Spinner size="xl" color="blue.500" />
<Text color="gray.500">Loading documentation...</Text>
</VStack>
) : docContent ? (
<Box
sx={{
'& pre': {
borderRadius: 'md',
marginBottom: '1rem',
},
'& table': {
width: '100%',
marginBottom: '1rem',
borderCollapse: 'collapse',
},
'& th': {
background: useColorModeValue('gray.100', 'gray.700'),
padding: '12px',
textAlign: 'left',
borderBottom: '2px solid',
borderColor: borderColor,
},
'& td': {
padding: '12px',
borderBottom: '1px solid',
borderColor: borderColor,
},
'& hr': {
margin: '2rem 0',
borderColor: borderColor,
},
'& blockquote': {
borderLeft: '4px solid',
borderColor: 'blue.400',
paddingLeft: '1rem',
marginLeft: 0,
fontStyle: 'italic',
color: 'gray.600',
},
'& img': {
maxWidth: '100%',
borderRadius: 'md',
boxShadow: 'md',
marginBottom: '1rem',
},
}}
>
<ReactMarkdown components={markdownComponents}>
{docContent}
</ReactMarkdown>
</Box>
) : (
<VStack spacing={4} py={12}>
<Icon as={FiBook} boxSize={16} color="gray.300" />
<Text color="gray.500">Select a document to view</Text>
</VStack>
)}
</Box>
</HStack>
</Container>
</Box>
);
};
export default DevDocsPage;
+10 -17
View File
@@ -25,7 +25,8 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
import AdminLayout from '../../components/layout/AdminLayout';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
interface Album {
id: string;
@@ -114,20 +115,8 @@ const GalleryAdminPage: React.FC = () => {
setRefreshing(true);
try {
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const token = localStorage.getItem('token');
const response = await fetch(`${apiUrl}/admin/gallery/refresh`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Chyba při obnově galerie');
}
// Use the api service which automatically includes authentication
await api.post('/admin/gallery/refresh');
toast({
title: 'Galerie obnovena',
@@ -140,13 +129,17 @@ const GalleryAdminPage: React.FC = () => {
// Reload albums after refresh
await fetchAlbums();
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii';
toast({
title: 'Chyba',
description: err.message || 'Nepodařilo se obnovit galerii',
title: 'Chyba při obnově galerie',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
console.error('Gallery refresh error:', err);
} finally {
setRefreshing(false);
}
@@ -562,9 +562,9 @@ const MatchesAdminPage = () => {
return (
<AdminLayout requireAdmin={false}>
<Box>
<Box bg={headerBg} color={headerText} borderRadius="xl" p={6} mb={6} boxShadow="lg">
<Box mb={6}>
<Heading size="lg" mb={2}>Správa zápasů</Heading>
<Text opacity={0.9}>
<Text color={useColorModeValue('gray.600', 'gray.400')}>
Správa a úprava zápasů. Můžete upravovat informace o zápasech, včetně názvů týmů, termínů, log a dalších detailů.
</Text>
</Box>
@@ -719,7 +719,7 @@ const NavigationAdminPage = () => {
<Box flex="1">
<HStack spacing={4}>
<Text fontSize="sm">
<strong>Načteno:</strong> {navItems.length} webových, {adminNavItems.length} admin, {socialLinks.length} sociálních
<strong>Načteno:</strong> {navItems.length} webových, {adminNavItems.length} admin
</Text>
</HStack>
</Box>
@@ -731,8 +731,7 @@ const NavigationAdminPage = () => {
<Text fontWeight="bold">Oddělená správa navigace</Text>
<Text fontSize="sm" mt={1}>
<strong>Webová navigace:</strong> Menu na veřejném webu<br/>
<strong>Admin panel:</strong> Postranní menu v administraci<br/>
<strong>Sociální sítě:</strong> Odkazy na sociální média
<strong>Admin panel:</strong> Postranní menu v administraci
</Text>
</Box>
</Alert>
@@ -741,7 +740,6 @@ const NavigationAdminPage = () => {
<TabList>
<Tab>Webová navigace</Tab>
<Tab>Admin panel</Tab>
<Tab>Sociální sítě</Tab>
</TabList>
<TabPanels>
@@ -874,100 +872,6 @@ const NavigationAdminPage = () => {
</VStack>
</VStack>
</TabPanel>
{/* Social Links Tab */}
<TabPanel>
<VStack spacing={4} align="stretch">
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={() => openSocialModal()}>
Přidat sociální síť
</Button>
{socialLinks.length === 0 ? (
<Alert status="warning">
<AlertIcon />
<Box>
<Text fontWeight="bold">Žádné sociální sítě</Text>
<Text fontSize="sm" mt={1}>
Nebyly nalezeny žádné odkazy na sociální sítě. Klikněte na "Přidat sociální síť" pro vytvoření odkazu.
</Text>
</Box>
</Alert>
) : (
<Box borderWidth="1px" borderRadius="lg" overflow="hidden">
<Table variant="simple">
<Thead>
<Tr>
<Th width="100px">Pořadí</Th>
<Th>Ikona</Th>
<Th>Platforma</Th>
<Th>URL</Th>
<Th>Viditelné</Th>
<Th>Akce</Th>
</Tr>
</Thead>
<Tbody>
{socialLinks.map((link, index) => {
const IconComponent = getSocialIcon(link.platform);
return (
<Tr key={link.id} bg={link.visible ? 'transparent' : 'gray.50'}>
<Td>
<HStack spacing={1}>
<IconButton
aria-label="Nahoru"
icon={<ChevronUpIcon />}
size="sm"
isDisabled={index === 0}
onClick={() => moveSocialLink(index, 'up')}
/>
<IconButton
aria-label="Dolů"
icon={<ChevronDownIcon />}
size="sm"
isDisabled={index === socialLinks.length - 1}
onClick={() => moveSocialLink(index, 'down')}
/>
</HStack>
</Td>
<Td>
<IconComponent size={24} />
</Td>
<Td fontWeight="bold">{link.platform}</Td>
<Td>
<Text fontSize="sm" color="gray.600" isTruncated maxW="300px">
{link.url}
</Text>
</Td>
<Td>
<Badge colorScheme={link.visible ? 'green' : 'gray'}>
{link.visible ? 'Ano' : 'Ne'}
</Badge>
</Td>
<Td>
<HStack spacing={2}>
<IconButton
aria-label="Upravit"
icon={<EditIcon />}
size="sm"
onClick={() => openSocialModal(link)}
/>
<IconButton
aria-label="Smazat"
icon={<DeleteIcon />}
size="sm"
colorScheme="red"
onClick={() => deleteSocial(link.id!)}
/>
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
)}
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
@@ -1184,61 +1088,6 @@ const NavigationAdminPage = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Social Link Modal */}
<Modal isOpen={isSocialModalOpen} onClose={onSocialModalClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{editingSocial?.id ? 'Upravit odkaz' : 'Nový odkaz'}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Platforma</FormLabel>
<Select
value={editingSocial?.platform || 'facebook'}
onChange={(e) =>
setEditingSocial({ ...editingSocial!, platform: e.target.value })
}
>
{SOCIAL_PLATFORMS.map((platform) => (
<option key={platform.value} value={platform.value}>
{platform.label}
</option>
))}
</Select>
</FormControl>
<FormControl isRequired>
<FormLabel>URL</FormLabel>
<Input
value={editingSocial?.url || ''}
onChange={(e) => setEditingSocial({ ...editingSocial!, url: e.target.value })}
placeholder="https://www.facebook.com/..."
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel mb="0">Viditelné</FormLabel>
<Switch
isChecked={editingSocial?.visible ?? true}
onChange={(e) =>
setEditingSocial({ ...editingSocial!, visible: e.target.checked })
}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onSocialModalClose}>
Zrušit
</Button>
<Button colorScheme="blue" onClick={saveSocialLink}>
Uložit
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</Container>
</AdminLayout>
);
+1 -1
View File
@@ -1040,7 +1040,7 @@ html {
/* Standings section (Další aktuality + Tabulky) - default two-column layout */
section.standings {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
+32
View File
@@ -26,3 +26,35 @@ export function assetUrl(pathOrUrl?: string | null): string | undefined {
export function isUploadPath(pathOrUrl?: string | null): boolean {
return !!pathOrUrl && /^\/uploads\//.test(pathOrUrl);
}
/**
* Sanitizes club names by removing common suffixes and abbreviating long formal names
* Examples:
* - "Tělovýchovná jednota Valašské Meziříčí, spolek" -> "TJ Valašské Meziříčí"
* - "MFK Slavoj Bruntál, z. s." -> "MFK Slavoj Bruntál"
*/
export function sanitizeClubName(name?: string | null): string {
if (!name) return '';
let sanitized = name.trim();
// Replace full form "Tělovýchovná jednota" with "TJ"
sanitized = sanitized.replace(/^Tělovýchovná jednota\s+/i, 'TJ ');
// Replace full form "Sportovní klub" with "SK"
sanitized = sanitized.replace(/^Sportovní klub\s+/i, 'SK ');
// Replace full form "Fotbalový klub" with "FK"
sanitized = sanitized.replace(/^Fotbalový klub\s+/i, 'FK ');
// Replace full form "Fotbal klub" with "FK"
sanitized = sanitized.replace(/^Fotbal klub\s+/i, 'FK ');
// Replace full form "Sokol" abbreviation
sanitized = sanitized.replace(/^Sokol\s+/i, 'SK ');
// Remove common legal suffixes at the end
sanitized = sanitized.replace(/,\s*(spolek|z\.\s*s\.|o\.\s*s\.|z\s*s|o\s*s|spolková\s+organizace|spolek\s+registrovaný|z\.s\.|o\.s\.)$/i, '');
return sanitized.trim();
}