This commit is contained in:
Tomas Dvorak
2025-10-23 22:26:50 +02:00
parent 63700eedb2
commit 70ea0c3c91
75 changed files with 3337 additions and 1160 deletions
+26 -4
View File
@@ -33,6 +33,7 @@ import {
InputGroup,
InputLeftElement,
Input,
useToast,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, HamburgerIcon, EditIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { FaFacebook, FaInstagram, FaYoutube, FaPhotoVideo, FaExternalLinkAlt, FaShoppingBag, FaCamera, FaSearch } from 'react-icons/fa';
@@ -49,6 +50,7 @@ import { getPlayers } from '../services/public';
import { getArticles } from '../services/articles';
import { getCachedYouTube } from '../services/youtube';
import { getZoneramaManifestWithFallbacks } from '../services/zonerama';
import { getMyNewsletterToken } from '../services/public/newsletter';
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
@@ -236,7 +238,7 @@ const MobileMenu = ({ isOpen, onClose, isAdmin, menuBg, dividerColor, settings,
</Drawer>
);
const Navbar = () => {
const Navbar: React.FC<{ fullWidth?: boolean }> = ({ fullWidth = false }) => {
const { colorMode, toggleColorMode } = useColorMode();
const { isAuthenticated, logout, user } = useAuth();
const { isOpen, onOpen, onClose } = useDisclosure();
@@ -246,12 +248,14 @@ const Navbar = () => {
const theme = useClubTheme();
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const menuBg = useColorModeValue('white', '#0f1115');
const dividerColor = useColorModeValue('gray.600', 'gray.300');
const hoverBg = useColorModeValue('blackAlpha.100', 'whiteAlpha.200');
const activeBg = useColorModeValue('blackAlpha.50', 'whiteAlpha.100');
const activeTextColor = useColorModeValue('brand.primary', 'brand.accent');
const navTextColor = useColorModeValue('gray.700', 'gray.200');
const topBarBg = useColorModeValue('gray.50', 'blackAlpha.500');
const [scrolled, setScrolled] = useState(false);
const [hasTables, setHasTables] = useState<boolean | null>(null);
const [hasActivities, setHasActivities] = useState<boolean | null>(null);
@@ -261,6 +265,7 @@ const Navbar = () => {
const [hasGallery, setHasGallery] = useState<boolean | null>(null);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const containerMaxW = fullWidth ? 'full' as const : '7xl' as const;
// Search modal state
const [query, setQuery] = useState('');
@@ -279,6 +284,21 @@ const Navbar = () => {
return () => window.removeEventListener('scroll', onScroll as any);
}, []);
// Open newsletter preferences for logged-in user (fetch token and redirect)
const openMyNewsletterPrefs = async () => {
try {
const { token } = await getMyNewsletterToken();
navigate(`/newsletter/preferences?token=${encodeURIComponent(token)}`);
} catch (err: any) {
toast({
title: 'Chyba',
description: 'Nelze načíst odkaz na emailové preference. Zkuste to prosím znovu.',
status: 'error',
duration: 4000,
});
}
};
// Also set document title to club name ASAP (SEO component will refine further)
useEffect(() => {
const name = settings?.club_name || theme.name;
@@ -607,8 +627,8 @@ const Navbar = () => {
<Box position="sticky" top={0} zIndex={1000}>
{/* Top bar with socials and quick external links */}
{(settings?.facebook_url || settings?.instagram_url || settings?.youtube_url || settings?.shop_url) && (
<Box bg={useColorModeValue('gray.50', 'blackAlpha.500')} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW="7xl">
<Box bg={topBarBg} borderBottomWidth="1px" borderColor="border.subtle" py={1}>
<Container maxW={containerMaxW}>
<Flex align="center" justify="space-between" gap={2}>
<HStack spacing={2}>
{settings?.shop_url && (
@@ -643,7 +663,7 @@ const Navbar = () => {
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} hasActivities={hasActivities} hasPlayers={hasPlayers} hasArticles={hasArticles} hasVideos={hasVideos} hasGallery={hasGallery} dynamicNavItems={dynamicNavItems} navLoading={navLoading} />
<Container maxW="7xl">
<Container maxW={containerMaxW}>
<Flex h={16} alignItems="center" justifyContent="space-between">
<HStack spacing={4} alignItems="center">
{/* Club Logo only */}
@@ -768,6 +788,8 @@ const Navbar = () => {
</MenuButton>
<MenuList>
<MenuItem as={RouterLink} to="/admin/nastaveni">Můj účet</MenuItem>
<MenuItem onClick={openMyNewsletterPrefs}>Emailové preference</MenuItem>
<MenuItem as={RouterLink} to="/profil/nastaveni">Nastavení stránky</MenuItem>
{isAdmin && <MenuItem as={RouterLink} to="/admin">Administrace</MenuItem>}
<MenuItem onClick={logout}>Odhlásit se</MenuItem>
</MenuList>
@@ -443,6 +443,7 @@ const PollLinker: React.FC<PollLinkerProps> = ({ articleId, eventId, onPollsChan
>
<option value="single">Jedna odpověď</option>
<option value="multiple">Více odpovědí</option>
<option value="rating">Hodnocení</option>
</Select>
</FormControl>
@@ -36,6 +36,7 @@ import {
} from 'lucide-react';
import { Image as ChakraImage } from '@chakra-ui/react';
import { cropAndUpload, quickEditImage } from '../../services/imageProcessing';
import { assetUrl } from '../../utils/url';
interface ImageFilters {
brightness: number;
@@ -245,28 +246,36 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return;
}
// Calculate crop data in pixels
// Calculate crop data in natural image pixels (backend expects absolute pixels)
const img = imgRef.current;
const percToPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
const displayW = img.width;
const displayH = img.height;
const naturalW = img.naturalWidth || displayW;
const naturalH = img.naturalHeight || displayH;
const scaleX = naturalW / Math.max(1, displayW);
const scaleY = naturalH / Math.max(1, displayH);
const toDisplayPx = (val: number, size: number) => (crop.unit === '%' ? (val / 100) * size : val);
let cropData = undefined;
if (crop.width && crop.height && crop.width > 0 && crop.height > 0) {
const cropPx = {
x: Math.round(Math.max(0, percToPx(crop.x || 0, img.width))),
y: Math.round(Math.max(0, percToPx(crop.y || 0, img.height))),
width: Math.round(Math.min(img.width, percToPx(crop.width || img.width, img.width))),
height: Math.round(Math.min(img.height, percToPx(crop.height || img.height, img.height))),
};
// Adjust crop to fit image bounds
if (cropPx.x + cropPx.width > img.width) {
cropPx.width = img.width - cropPx.x;
}
if (cropPx.y + cropPx.height > img.height) {
cropPx.height = img.height - cropPx.y;
}
cropData = cropPx;
// Convert selection from displayed coordinates to natural pixel coordinates
const dispX = Math.max(0, toDisplayPx(crop.x || 0, displayW));
const dispY = Math.max(0, toDisplayPx(crop.y || 0, displayH));
const dispW = Math.min(displayW, toDisplayPx(crop.width || displayW, displayW));
const dispH = Math.min(displayH, toDisplayPx(crop.height || displayH, displayH));
let natX = Math.round(dispX * scaleX);
let natY = Math.round(dispY * scaleY);
let natW = Math.round(dispW * scaleX);
let natH = Math.round(dispH * scaleY);
// Clamp within natural bounds
if (natX + natW > naturalW) natW = naturalW - natX;
if (natY + natH > naturalH) natH = naturalH - natY;
natW = Math.max(1, natW);
natH = Math.max(1, natH);
cropData = { x: natX, y: natY, width: natW, height: natH };
}
toast({ title: 'Zpracování obrázku...', status: 'info', duration: 2000 });
@@ -290,16 +299,25 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const range = quill.getSelection();
const index = range ? range.index : quill.getLength();
// Insert the image
quill.insertEmbed(index, 'image', res.url, 'api');
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
const absoluteUrl = assetUrl(res.url) || res.url;
const img = new Image();
img.onload = () => {
try {
quill.insertEmbed(index, 'image', absoluteUrl, 'api');
// Move cursor after the image
quill.setSelection(index + 1, 0, 'api');
// Force content change to trigger re-render
onChangeRef.current(quill.root.innerHTML);
toast({ title: 'Obrázek vložen', status: 'success', duration: 2000 });
} catch (e) {
console.error('Insert after preload error:', e);
toast({ title: 'Chyba při vkládání obrázku', description: String(e), status: 'error' });
}
};
img.onerror = () => {
toast({ title: 'Obrázek nelze načíst', description: absoluteUrl, status: 'error' });
};
img.src = absoluteUrl;
} catch (embedError) {
console.error('Error inserting image:', embedError);
toast({ title: 'Chyba při vkládání obrázku', description: String(embedError), status: 'error' });
@@ -716,6 +734,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
// Delete selected image on Delete key
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
// Do not act on Delete/Backspace if user is typing in an input, textarea, or contentEditable
if (tag === 'INPUT' || tag === 'TEXTAREA' || (target && (target as any).isContentEditable)) {
return;
}
if (selectedImage && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
selectedImage.remove();
@@ -754,6 +778,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
editor.root.addEventListener('scroll', handleScroll);
editor.root.addEventListener('dragstart', handleDragStart);
document.addEventListener('keydown', handleKeyDown);
// Also reposition on window resize and any document scroll (capture phase)
window.addEventListener('resize', handleScroll);
document.addEventListener('scroll', handleScroll, true);
return () => {
editor.root.removeEventListener('click', handleImageClick);
@@ -761,10 +788,12 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
editor.root.removeEventListener('scroll', handleScroll);
editor.root.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleScroll);
document.removeEventListener('scroll', handleScroll, true);
removeResizeHandle();
deselectImage();
};
}, [readOnly, toast]);
}, [readOnly, toast, isMounted]);
// Apply filters to selected image
const applyFiltersToImage = useCallback((img: HTMLImageElement, filters: ImageFilters) => {
@@ -850,7 +879,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
});
// Replace image src
selectedImageElement.src = res.url;
const absoluteUrl = assetUrl(res.url) || res.url;
selectedImageElement.src = absoluteUrl;
// Reset filters to default since they're now baked into the image
setImageFilters({
@@ -873,6 +903,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Filtry aplikovány', status: 'success', duration: 2000 });
@@ -904,6 +936,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
const editor = quillRef.current?.getEditor();
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: `Obrázek zarovnán ${alignment === 'left' ? 'vlevo' : alignment === 'center' ? 'na střed' : 'vpravo'}`, status: 'success', duration: 1500 });
}
@@ -924,6 +958,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
setManualWidth(finalWidth.toString());
if (editor) {
onChangeRef.current(editor.root.innerHTML);
// Force overlay reposition
try { editor.root.dispatchEvent(new Event('scroll')); } catch {}
}
toast({ title: 'Šířka nastavena', description: `${finalWidth}px`, status: 'success', duration: 1500 });
} else {
@@ -1218,9 +1254,9 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
maxH="80vh"
overflowY="auto"
pointerEvents="auto"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
onMouseUp={(e) => { e.preventDefault(); e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); }}
onMouseDown={(e) => { e.stopPropagation(); }}
onMouseUp={(e) => { e.stopPropagation(); }}
css={{
'&::-webkit-scrollbar': {
width: '6px',
@@ -1302,7 +1338,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
type="number"
value={manualWidth}
onChange={(e) => setManualWidth(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && applyManualWidth()}
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); applyManualWidth(); } }}
placeholder="Šířka v px"
min={50}
/>
+35 -307
View File
@@ -1,28 +1,15 @@
import React, { useState } from 'react';
import React from 'react';
import {
Box,
Button,
HStack,
Icon,
Link as ChakraLink,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
ModalFooter,
Text,
useDisclosure,
Image,
VStack,
Badge,
useColorModeValue,
AspectRatio,
} from '@chakra-ui/react';
import {
FiDownload,
FiEye,
FiExternalLink,
FiFile,
FiFileText,
FiImage,
@@ -44,10 +31,7 @@ const FilePreview: React.FC<FilePreviewProps> = ({
name,
mimeType = '',
size,
showInline = false,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [imageError, setImageError] = useState(false);
const fullUrl = assetUrl(url) || url;
const fileName = name || url.split('/').pop() || 'file';
@@ -56,7 +40,6 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const borderColor = useColorModeValue('gray.200', 'gray.700');
const cardBg = useColorModeValue('white', 'gray.800');
const mutedText = useColorModeValue('gray.600', 'gray.300');
const linkColor = useColorModeValue('blue.600', 'blue.300');
// Determine file type and icon
const getFileInfo = () => {
@@ -89,297 +72,42 @@ const FilePreview: React.FC<FilePreviewProps> = ({
const sizeMB = sizeKB && sizeKB > 1024 ? (sizeKB / 1024).toFixed(1) : undefined;
const sizeStr = sizeMB ? `${sizeMB} MB` : sizeKB ? `${sizeKB} kB` : '';
// Render preview content based on file type
const renderPreviewContent = () => {
if (fileInfo.type === 'image') {
if (imageError) {
return (
<VStack spacing={4} py={10}>
<Icon as={FiImage} boxSize={12} color="gray.400" />
<Text color={mutedText}>Obrázek se nepodařilo načíst</Text>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
>
Stáhnout soubor
</Button>
</VStack>
);
}
return (
<Image
src={fullUrl}
alt={fileName}
maxW="100%"
maxH="70vh"
objectFit="contain"
onError={() => setImageError(true)}
/>
);
}
if (fileInfo.type === 'pdf') {
// Try multiple PDF viewing methods due to CSP restrictions
return (
<VStack spacing={4} w="100%" minH="70vh">
{/* Primary: Try direct iframe embed */}
<Box w="100%" h="70vh" borderWidth="1px" borderRadius="md" overflow="hidden">
<iframe
src={`${fullUrl}#view=FitH&toolbar=1`}
title={fileName}
style={{ border: 'none', width: '100%', height: '100%' }}
onError={(e) => {
console.error('PDF iframe load error:', e);
}}
/>
</Box>
{/* Fallback options */}
<VStack spacing={2} w="100%">
<Text fontSize="sm" color={mutedText} textAlign="center">
Pokud se PDF nezobrazuje, použijte jedno z tlačítek níže:
</Text>
<HStack spacing={3} flexWrap="wrap" justify="center">
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiEye />}
colorScheme="blue"
size="sm"
>
Otevřít v novém okně
</Button>
<Button
as={ChakraLink}
href={`https://mozilla.github.io/pdf.js/web/viewer.html?file=${encodeURIComponent(fullUrl)}`}
isExternal
leftIcon={<FiEye />}
colorScheme="purple"
size="sm"
>
Zobrazit pomocí PDF.js
</Button>
<Button
as={ChakraLink}
href={`https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`}
isExternal
leftIcon={<FiEye />}
colorScheme="green"
size="sm"
>
Zobrazit přes Google
</Button>
<Button
as={ChakraLink}
href={fullUrl}
download
leftIcon={<FiDownload />}
colorScheme="gray"
size="sm"
>
Stáhnout PDF
</Button>
</HStack>
</VStack>
</VStack>
);
}
if (fileInfo.type === 'video') {
return (
<AspectRatio ratio={16 / 9} w="100%">
<video controls style={{ width: '100%', height: '100%' }}>
<source src={fullUrl} type={mime} />
Váš prohlížeč nepodporuje přehrávání videa.
</video>
</AspectRatio>
);
}
if (fileInfo.type === 'audio') {
return (
<VStack spacing={4} py={10}>
<Icon as={FiMusic} boxSize={12} color={fileInfo.color} />
<audio controls style={{ width: '100%', maxWidth: '500px' }}>
<source src={fullUrl} type={mime} />
Váš prohlížeč nepodporuje přehrávání zvuku.
</audio>
</VStack>
);
}
// For Office documents, show info and download option
return (
<VStack spacing={4} py={10}>
<Icon as={fileInfo.icon} boxSize={16} color={fileInfo.color} />
<VStack spacing={2}>
<Text fontSize="lg" fontWeight="medium">{fileName}</Text>
{sizeStr && <Badge colorScheme="gray">{sizeStr}</Badge>}
<Text color={mutedText} fontSize="sm" textAlign="center">
{fileInfo.type === 'presentation' && 'PowerPoint prezentace'}
{fileInfo.type === 'document' && 'Word dokument'}
{fileInfo.type === 'spreadsheet' && 'Excel tabulka'}
</Text>
</VStack>
<HStack spacing={3}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
>
Stáhnout
</Button>
<Button
as={ChakraLink}
href={`https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(fullUrl)}`}
isExternal
leftIcon={<FiEye />}
variant="outline"
>
Zobrazit online
</Button>
</HStack>
<Text fontSize="xs" color={mutedText}>
Pro zobrazení .pptx, .docx, .xlsx můžete použít "Zobrazit online"
</Text>
</VStack>
);
};
// Inline preview for images
if (showInline && fileInfo.type === 'image') {
return (
<Box
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={cardBg}
>
<Image
src={fullUrl}
alt={fileName}
w="100%"
maxH="400px"
objectFit="cover"
cursor="pointer"
onClick={onOpen}
_hover={{ opacity: 0.9 }}
onError={() => setImageError(true)}
/>
{!imageError && (
<HStack justify="space-between" p={3} borderTopWidth="1px">
<Text fontSize="sm" color={mutedText} isTruncated maxW="60%">
{fileName}
</Text>
<HStack spacing={2}>
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen}>
Náhled
</Button>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
variant="ghost"
leftIcon={<FiDownload />}
>
Stáhnout
</Button>
</HStack>
</HStack>
)}
</Box>
);
}
// Compact button view
// Simplified preview: only provide an "Open in new window" action
return (
<>
<HStack
justify="space-between"
p={3}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<ChakraLink
href={fullUrl}
isExternal
color={linkColor}
fontWeight="medium"
isTruncated
maxW="100%"
_hover={{ textDecoration: 'underline' }}
>
{fileName}
</ChakraLink>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
<HStack spacing={2} flexShrink={0}>
{fileInfo.canPreview && (
<Button size="sm" leftIcon={<FiEye />} onClick={onOpen} variant="outline">
Náhled
</Button>
)}
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiDownload />}
colorScheme="blue"
<HStack
justify="space-between"
p={3}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={cardBg}
>
<HStack flex={1} minW={0}>
<Icon as={fileInfo.icon} color={fileInfo.color} flexShrink={0} />
<VStack align="start" spacing={0} flex={1} minW={0}>
<Text
fontWeight="medium"
isTruncated
maxW="100%"
>
Stáhnout
</Button>
</HStack>
{fileName}
</Text>
{sizeStr && <Text fontSize="xs" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
{/* Preview Modal */}
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
<ModalOverlay bg="blackAlpha.800" />
<ModalContent maxW="90vw" maxH="90vh">
<ModalHeader>
<HStack justify="space-between">
<VStack align="start" spacing={0}>
<Text>{fileName}</Text>
{sizeStr && <Text fontSize="sm" fontWeight="normal" color={mutedText}>{sizeStr}</Text>}
</VStack>
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6} overflow="auto">
{renderPreviewContent()}
</ModalBody>
<ModalFooter>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
leftIcon={<FiDownload />}
colorScheme="blue"
mr={3}
>
Stáhnout
</Button>
<Button variant="ghost" onClick={onClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
<HStack spacing={2} flexShrink={0}>
<Button
as={ChakraLink}
href={fullUrl}
isExternal
size="sm"
leftIcon={<FiExternalLink />}
colorScheme="blue"
>
Otevřít v novém okně
</Button>
</HStack>
</HStack>
);
};
@@ -83,7 +83,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
};
return links[element] || [
{ label: 'Admin Dashboard', url: '/admin', icon: FiSettings, description: 'Go to admin panel' },
{ label: 'Administrace', url: '/admin', icon: FiSettings, description: 'Přejít do administrace' },
];
};
@@ -95,7 +95,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
<HStack>
<Icon as={FiExternalLink} color="blue.500" />
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Quick Admin Links
Rychlé odkazy administrace
</Text>
</HStack>
@@ -152,7 +152,7 @@ const ContextualAdminLinks: React.FC<ContextualAdminLinksProps> = ({ elementName
</Box>
<Text fontSize="xs" color="gray.500" textAlign="center">
💡 These links help you manage content for this section
💡 Tyto odkazy vám pomohou spravovat obsah této sekce
</Text>
</VStack>
</Box>
@@ -18,23 +18,32 @@ import {
AlertIcon,
useColorModeValue,
Divider,
Spinner,
} from '@chakra-ui/react';
import { FiCode, FiEye, FiSave, FiRefreshCw } from 'react-icons/fi';
import { FiCode, FiEye, FiSave, FiRefreshCw, FiZap } from 'react-icons/fi';
import { generateCSSAI, AIGenerateCSSReq } from '../../services/ai';
import { ELEMENT_TSX_CONTEXT } from './elementContext';
interface CustomCSSEditorProps {
elementName: string;
onCSSChange: (css: string) => void;
currentCSS?: string;
currentStyles?: Record<string, any>;
theme?: Record<string, string>;
}
const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
elementName,
onCSSChange,
currentCSS = '',
currentStyles = {},
theme = {},
}) => {
const [css, setCSS] = useState(currentCSS);
const [isValid, setIsValid] = useState(true);
const [preview, setPreview] = useState(false);
const [aiPrompt, setAIPrompt] = useState('Zvýrazni tento blok: moderní vzhled, zaoblené rohy, stín, lepší hover efekt; respektuj klubové barvy a responzivitu.');
const [aiLoading, setAILoading] = useState(false);
const toast = useToast();
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -58,6 +67,84 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
}
};
// Collect rich AI context about the current element and page layout
const collectAIContext = (elName: string) => {
try {
const rootSelector = `[data-element="${elName}"]`;
const el = document.querySelector(rootSelector) as HTMLElement | null;
const container = (document.querySelector('.myuibrix-viewport-wrapper') as HTMLElement) || (document.querySelector('.container') as HTMLElement) || null;
const cssVars = (() => {
const style = getComputedStyle(document.documentElement);
const keys = ['--primary','--primary-light','--secondary','--text','--bg','--bg-soft','--club-primary','--club-text-on-primary'];
const out: Record<string,string> = {};
keys.forEach(k => { const v = style.getPropertyValue(k); if (v) out[k] = v.trim(); });
return out;
})();
const elComputed = el ? getComputedStyle(el) : null;
const computed = elComputed ? {
display: elComputed.display,
gridTemplateColumns: elComputed.gridTemplateColumns,
gridTemplateRows: elComputed.gridTemplateRows,
gap: elComputed.gap,
justifyItems: (elComputed as any).justifyItems,
alignItems: (elComputed as any).alignItems,
color: elComputed.color,
backgroundColor: elComputed.backgroundColor,
padding: `${elComputed.paddingTop} ${elComputed.paddingRight} ${elComputed.paddingBottom} ${elComputed.paddingLeft}`,
margin: `${elComputed.marginTop} ${elComputed.marginRight} ${elComputed.marginBottom} ${elComputed.marginLeft}`,
fontFamily: elComputed.fontFamily,
fontSize: elComputed.fontSize,
} : {};
const rect = el ? el.getBoundingClientRect() : null;
const neighborInfo = (() => {
try {
const nodes = Array.from((container || document).querySelectorAll('[data-element]')) as HTMLElement[];
const names = nodes.map(n => n.getAttribute('data-element')) as (string|null)[];
const index = names.findIndex(n => n === elName);
const prev = index > 0 ? names[index-1] : null;
const next = index >= 0 && index < names.length - 1 ? names[index+1] : null;
return { index, total: names.length, previous: prev, next };
} catch { return {}; }
})();
const containerComputed = container ? getComputedStyle(container) : null;
const containerInfo = containerComputed ? {
display: containerComputed.display,
gridTemplateColumns: containerComputed.gridTemplateColumns,
gridAutoFlow: containerComputed.gridAutoFlow,
gap: containerComputed.gap,
} : {};
// HTML snapshot (truncate to keep payload small)
const rootHtml = el ? el.outerHTML.slice(0, 6000) : '';
const tsxCtx = ELEMENT_TSX_CONTEXT[elName] || {};
return {
page_path: typeof window !== 'undefined' ? window.location.pathname : '',
element: {
name: elName,
variant: el?.getAttribute('data-variant') || null,
classList: el ? Array.from(el.classList) : [],
attributes: el ? Array.from(el.attributes).map(a => ({ name: a.name, value: a.value })) : [],
rect: rect ? { width: rect.width, height: rect.height } : undefined,
computed,
root_html_snapshot: rootHtml,
},
container: containerInfo,
neighbors: neighborInfo,
css_variables: cssVars,
tsx_snippet: tsxCtx.tsx || undefined,
known_selectors: tsxCtx.selectors || undefined,
design_notes: tsxCtx.notes || undefined,
};
} catch {
return {};
}
};
const handleCSSChange = (value: string) => {
setCSS(value);
const valid = validateCSS(value);
@@ -79,11 +166,29 @@ const CustomCSSEditor: React.FC<CustomCSSEditorProps> = ({
// Create new style element
const style = document.createElement('style');
style.id = `custom-css-${elementName}`;
style.textContent = `
[data-element="${elementName}"] {
${cssString}
}
`;
// If the CSS contains braces or at-rules, assume full CSS block already scoped
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(cssString);
if (hasBlocks) {
style.textContent = cssString;
} else {
// Treat as declarations, scope under element
// Ensure each declaration is marked important to override theme CSS
const importantDecls = cssString
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => {
// Avoid double !important
return /!important\s*$/.test(s) ? s : `${s} !important`;
})
.join(';\n ');
style.textContent = `
[data-element="${elementName}"] {
${importantDecls};
}
`;
}
document.head.appendChild(style);
}
};
@@ -166,6 +271,12 @@ overflow: hidden;`,
<Text>Examples</Text>
</HStack>
</Tab>
<Tab>
<HStack spacing={2}>
<FiZap />
<Text>AI (beta)</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
@@ -288,6 +399,88 @@ border-radius: 10px;`}
))}
</VStack>
</TabPanel>
{/* AI Tab */}
<TabPanel>
<VStack align="stretch" spacing={3} p={4}>
<Text fontSize="xs" fontWeight="bold" color="gray.500" textTransform="uppercase">
Vygenerovat CSS pomocí AI
</Text>
<Textarea
value={aiPrompt}
onChange={(e) => setAIPrompt(e.target.value)}
placeholder="Popište, jak má daný blok vypadat (česky). Např.: Tmavé pozadí, světlý text, zaoblené rohy, 2-sloupcový layout na desktopu, jeden sloupec na mobilu."
fontSize="sm"
minHeight="120px"
bg={useColorModeValue('gray.50', 'gray.900')}
borderColor={borderColor}
/>
<HStack spacing={2} flexWrap="wrap">
{[
'Tmavé pozadí a světlý text',
'Skleněný efekt (glassmorphism)',
'Zaoblené rohy a měkký stín',
'Dvou-sloupcový grid, mobil 1 sloupec',
'Akcent klubových barev',
].map((t, idx) => (
<Button key={idx} size="xs" variant="outline" onClick={() => setAIPrompt(p => `${p} ${t}.`)}>
{t}
</Button>
))}
</HStack>
<HStack>
<Button
leftIcon={aiLoading ? <Spinner size="xs" /> : <FiZap />}
colorScheme="purple"
size="sm"
isLoading={aiLoading}
onClick={async () => {
try {
setAILoading(true);
const payload: AIGenerateCSSReq = {
prompt: aiPrompt,
element_name: elementName,
root_selector: `[data-element="${elementName}"]`,
current_css: css,
current_styles: currentStyles || {},
theme: (theme as any) || {},
breakpoints: [640, 960, 1200],
context: collectAIContext(elementName),
};
const res = await generateCSSAI(payload);
const next = (res?.css || '').trim();
if (!next) {
toast({ title: 'AI nevrátila CSS', status: 'warning', duration: 2500 });
return;
}
setCSS(next);
setIsValid(true);
setPreview(true);
applyCSS(next);
// Persist into parent editor state so it survives panel close
onCSSChange(next);
toast({ title: 'CSS vygenerováno', status: 'success', duration: 1500 });
} catch (e: any) {
toast({ title: 'Chyba při generování CSS', description: e?.message || 'Zkuste to znovu', status: 'error', duration: 3000 });
} finally {
setAILoading(false);
}
}}
>
Vygenerovat CSS
</Button>
<Button size="sm" variant="ghost" onClick={() => setAIPrompt('')}>Vymazat zadání</Button>
</HStack>
<Alert status="info" borderRadius="md" fontSize="xs">
<AlertIcon />
AI výstup je automaticky scope-nutý pod `[data-element="název"]`. Po vygenerování se CSS předvyplní do editoru a lze ho dál upravovat.
</Alert>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
+609 -116
View File
@@ -150,11 +150,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const [viewport] = useState<'desktop'>('desktop');
const [elementStyles, setElementStyles] = useState<Record<string, any>>({});
const [showStylePanel, setShowStylePanel] = useState(true);
const [stylePanelRight, setStylePanelRight] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showHelpHint, setShowHelpHint] = useState(true);
const [baseline, setBaseline] = useState<{ variants: Record<string, string>; visible: Set<string>; order: string[]; css: Record<string, string> }>({ variants: {}, visible: new Set<string>(), order: [], css: {} });
const overlayRef = useRef<HTMLDivElement>(null);
const isReorderingRef = useRef(false);
const [allowCrossContainer, setAllowCrossContainer] = useState(false);
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
const [containerGridCols, setContainerGridCols] = useState<number>(0);
const elementOrderRef = useRef<string[]>([]);
useEffect(() => { elementOrderRef.current = elementOrder; }, [elementOrder]);
// Draggable panel states
const [panelPositions, setPanelPositions] = useState({
@@ -322,6 +329,83 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Toggle body class for edit mode so other parts can detect reliably
useEffect(() => {
try {
if (isEditing) {
document.body.classList.add('myuibrix-edit-mode');
} else {
document.body.classList.remove('myuibrix-edit-mode');
}
} catch {}
return () => {
try {
document.body.classList.remove('myuibrix-edit-mode');
} catch {}
};
}, [isEditing]);
// Editor-only CSS: hide MyClub watermark and stabilize footer appearance in edit mode
useEffect(() => {
let styleEl: HTMLStyleElement | null = null;
try {
if (isEditing) {
styleEl = document.createElement('style');
styleEl.id = 'myuibrix-footer-editor-fixes';
styleEl.textContent = `
body.myuibrix-edit-mode [data-watermark="myclub"] { display: none !important; }
body.myuibrix-edit-mode [data-element="footer"] { position: relative; z-index: 0; }
`;
document.head.appendChild(styleEl);
}
} catch {}
return () => {
try { const n = document.getElementById('myuibrix-footer-editor-fixes'); if (n) n.remove(); } catch {}
};
}, [isEditing]);
// Toggle cross-container reorder mode class for global checks
useEffect(() => {
try {
if (allowCrossContainer) {
document.body.classList.add('myuibrix-cross-container-reorder');
} else {
document.body.classList.remove('myuibrix-cross-container-reorder');
}
} catch {}
return () => {
try { document.body.classList.remove('myuibrix-cross-container-reorder'); } catch {}
};
}, [allowCrossContainer]);
// Auto-open Layers panel on the left by default when entering edit mode
useEffect(() => {
if (isEditing) {
setShowLayersPanel(true);
}
}, [isEditing]);
// Detect grid columns on main container for grid insertion UI
useEffect(() => {
try {
const el = safeDOM.querySelector('.container') as HTMLElement | null;
if (!el) { setContainerGridCols(0); return; }
const cs = window.getComputedStyle(el);
if (cs.display !== 'grid') { setContainerGridCols(0); return; }
const gtc = cs.gridTemplateColumns || '';
let cols = 0;
const m = gtc.match(/repeat\((\d+)/);
if (m) cols = parseInt(m[1], 10);
if (!cols) {
// Fallback: naive split
cols = gtc.split(' ').filter(Boolean).length || 2;
}
setContainerGridCols(cols);
} catch {
setContainerGridCols(0);
}
}, [isEditing, elementStyles]);
// Auto-dismiss help hint after 5 seconds
useEffect(() => {
if (isEditing && showHelpHint) {
@@ -342,6 +426,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
...cfg,
variant: normalizeVariant(cfg.element_name, cfg.variant)
}));
// Load saved custom CSS from settings
const cssByElement: Record<string, string> = {};
sanitizedConfigs.forEach(cfg => {
const css = (cfg.settings && (cfg.settings as any).customCSS) || '';
if (css) cssByElement[cfg.element_name] = String(css);
});
setConfigs(sanitizedConfigs);
const changes: Record<string, string> = {};
const visible = new Set<string>();
@@ -358,10 +448,45 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setLocalChanges(changes);
setVisibleElements(visible);
setElementOrder(order);
// Prime style state with saved custom CSS
if (Object.keys(cssByElement).length > 0) {
setElementStyles(prev => {
const next = { ...prev } as Record<string, any>;
Object.entries(cssByElement).forEach(([name, css]) => {
next[name] = { ...(next[name] || {}), customCSS: css };
});
return next;
});
// Inject saved CSS for preview (admin only)
Object.entries(cssByElement).forEach(([name, css]) => {
try {
const styleId = `custom-css-${name}`;
const existing = document.getElementById(styleId);
if (existing) existing.remove();
const style = document.createElement('style');
style.id = styleId;
const hasBlocks = /\{[^}]*\}|@media|@keyframes/.test(css);
if (hasBlocks) {
style.textContent = css;
} else {
const importantDecls = css
.split(';')
.map(s => s.trim())
.filter(Boolean)
.map(s => (/!important\s*$/.test(s) ? s : `${s} !important`))
.join(';\n ');
style.textContent = `\n [data-element="${name}"] {\n ${importantDecls};\n }\n `;
}
document.head.appendChild(style);
} catch {}
});
}
setBaseline({ variants: { ...changes }, visible: new Set<string>(visible), order: [...order], css: cssByElement });
// If using defaults and no data exists, mark as has changes so user can save
// If using defaults and no data exists, treat everything as unsaved
if (data.length === 0) {
setHasChanges(true);
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
}
} catch (error) {
console.error('Failed to load page element configs:', error);
@@ -385,12 +510,48 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
setVisibleElements(visible);
setElementOrder(order);
setHasChanges(true);
// Treat fallback like unsaved defaults so counter encourages save
setBaseline({ variants: {}, visible: new Set<string>(), order: [], css: {} });
}
};
loadConfigs();
}, [pageType, normalizeVariant]);
// Compute unsaved changes count by diffing against baseline
const unsavedCount = useMemo(() => {
try {
const saved = baseline || { variants: {}, visible: new Set<string>(), order: [], css: {} } as any;
const names = new Set<string>([
...Object.keys(localChanges || {}),
...Object.keys(saved.variants || {}),
...elementOrder,
...saved.order,
]);
const changedElements = new Set<string>();
names.forEach((name) => {
const curVar = normalizeVariant(name, localChanges[name]);
const savVar = normalizeVariant(name, saved.variants[name]);
if ((curVar || '') !== (savVar || '')) changedElements.add(name);
const curVis = visibleElements.has(name);
const savVis = saved.visible.has(name);
if (curVis !== savVis) changedElements.add(name);
const curCSS = String((elementStyles[name]?.customCSS || '')).trim();
const savCSS = String((saved.css?.[name] || '')).trim();
if (curCSS !== savCSS) changedElements.add(name);
});
const orderEqual = (elementOrder.length === saved.order.length) && elementOrder.every((n, i) => n === saved.order[i]);
return changedElements.size + (orderEqual ? 0 : 1);
} catch {
return 0;
}
}, [localChanges, visibleElements, elementOrder, baseline, normalizeVariant]);
// Keep hasChanges in sync with computed counter
useEffect(() => {
setHasChanges(unsavedCount > 0);
}, [unsavedCount]);
// Keyboard shortcuts
useEffect(() => {
if (!isEditing) return;
@@ -433,6 +594,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, [isEditing, selectedElement, showElementPicker, showLayersPanel, hasChanges]);
// Add element highlighting and click handlers when editing
// Also re-run when order/visibility changes so overlays are added for newly shown elements
useEffect(() => {
if (!isEditing) {
// Clean up overlays when exiting edit mode
@@ -466,6 +628,8 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
transition: all 0.2s;
z-index: 9998;
cursor: move;
user-select: none;
-webkit-user-select: none;
`;
const badge = document.createElement('div');
@@ -564,10 +728,64 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
deleteBtn.onmouseover = () => deleteBtn.style.transform = 'scale(1.1)';
deleteBtn.onmouseout = () => deleteBtn.style.transform = 'scale(1)';
// Add before button
const addBeforeBtn = document.createElement('button');
addBeforeBtn.innerHTML = '';
addBeforeBtn.title = 'Přidat před';
addBeforeBtn.style.cssText = editBtn.style.cssText;
addBeforeBtn.onmouseover = () => addBeforeBtn.style.transform = 'scale(1.1)';
addBeforeBtn.onmouseout = () => addBeforeBtn.style.transform = 'scale(1)';
// Add after button
const addAfterBtn = document.createElement('button');
addAfterBtn.innerHTML = '';
addAfterBtn.title = 'Přidat za';
addAfterBtn.style.cssText = editBtn.style.cssText;
addAfterBtn.onmouseover = () => addAfterBtn.style.transform = 'scale(1.1)';
addAfterBtn.onmouseout = () => addAfterBtn.style.transform = 'scale(1)';
// Grid column quick-insert buttons (add into specific grid column)
let colWrap: HTMLDivElement | null = null;
if (containerGridCols > 1) {
colWrap = document.createElement('div');
colWrap.className = 'elementor-col-picker';
colWrap.style.cssText = `
display: flex;
gap: 4px;
`;
for (let c = 0; c < containerGridCols; c++) {
const colBtn = document.createElement('button');
colBtn.innerHTML = '';
colBtn.title = `Přidat do sloupce ${c + 1}`;
colBtn.style.cssText = editBtn.style.cssText;
colBtn.onmouseover = () => (colBtn.style.transform = 'scale(1.1)');
colBtn.onmouseout = () => (colBtn.style.transform = 'scale(1)');
colBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const cols = Math.max(1, containerGridCols || 1);
const L = elementOrderRef.current.length;
let countInCol = 0;
for (let i = 0; i < L; i++) {
if (i % cols === c) countInCol++;
}
const targetIndex = c + countInCol * cols;
setPendingInsertIndex(Math.min(targetIndex, elementOrderRef.current.length));
setShowElementPicker(true);
} catch {}
});
safeDOM.appendChild(colWrap!, colBtn);
}
}
// Use safeDOM to build overlay structure
safeDOM.appendChild(actionsBar, editBtn);
safeDOM.appendChild(actionsBar, moveUpBtn);
safeDOM.appendChild(actionsBar, moveDownBtn);
safeDOM.appendChild(actionsBar, addBeforeBtn);
safeDOM.appendChild(actionsBar, addAfterBtn);
if (containerGridCols > 1 && colWrap) {
safeDOM.appendChild(actionsBar, colWrap);
}
safeDOM.appendChild(actionsBar, deleteBtn);
safeDOM.appendChild(overlay, badge);
@@ -652,10 +870,35 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
});
// Add before/after handlers
addBeforeBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const idx = elementOrderRef.current.indexOf(elementName);
if (idx >= 0) {
setPendingInsertIndex(idx);
setShowElementPicker(true);
}
} catch {}
});
addAfterBtn.addEventListener('click', (e) => {
e.stopPropagation();
try {
const idx = elementOrderRef.current.indexOf(elementName);
if (idx >= 0) {
setPendingInsertIndex(idx + 1);
setShowElementPicker(true);
}
} catch {}
});
// per-column insert handled by elementor-col-picker buttons above
// Make overlay draggable
overlay.draggable = true;
overlay.addEventListener('dragstart', (e) => {
e.stopPropagation();
try { (e as DragEvent).dataTransfer?.setData('text/plain', elementName); } catch {}
setDraggedElement(elementName);
overlay.style.opacity = '0.5';
});
@@ -669,6 +912,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
overlay.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
try { (e as DragEvent).dataTransfer!.dropEffect = 'move'; } catch {}
if (draggedElement && draggedElement !== elementName) {
overlay.style.border = `3px solid ${secondaryColor}`;
setDragOverElement(elementName);
@@ -688,13 +932,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
e.stopPropagation();
if (draggedElement && draggedElement !== elementName) {
// Reorder elements
const newOrder = [...elementOrder];
const draggedIndex = newOrder.indexOf(draggedElement);
const newOrder = [...elementOrderRef.current];
const draggedIndex = newOrder.indexOf(draggedElement as string);
const targetIndex = newOrder.indexOf(elementName);
if (draggedIndex !== -1 && targetIndex !== -1) {
newOrder.splice(draggedIndex, 1);
newOrder.splice(targetIndex, 0, draggedElement);
newOrder.splice(targetIndex, 0, draggedElement as string);
setElementOrder(newOrder);
setHasChanges(true);
applyVisualReorder(newOrder);
@@ -706,13 +950,23 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
});
};
// 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);
}
});
// Add overlays for all present [data-element] nodes in DOM (dynamic)
try {
const nodes = Array.from(safeDOM.querySelectorAll('[data-element]')) as HTMLElement[];
const names = Array.from(new Set(nodes
.map(n => n.getAttribute('data-element'))
.filter((v): v is string => !!v && v !== 'container')
));
names.forEach(name => addOverlay(name));
} catch {
// fallback to implemented list if needed
const implementedElements = pageType === 'homepage' ? HOMEPAGE_IMPLEMENTED_ELEMENTS : Object.keys(ELEMENT_VARIANTS);
implementedElements.forEach((elementName) => {
if (ELEMENT_VARIANTS[elementName]) {
addOverlay(elementName);
}
});
}
// Close panel on escape
const handleEscape = (e: KeyboardEvent) => {
@@ -753,7 +1007,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
clearTimeout(debounceTimerRef.current);
}
};
}, [isEditing, selectedElement, pageType]);
}, [isEditing, selectedElement, pageType, elementOrder, visibleElements]);
// Update selected element overlay styling
useEffect(() => {
@@ -848,6 +1102,71 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
applyChange();
}, [localChanges, visibleElements, isEditing, toast, getAvailableVariants, normalizeVariant]);
// Apply visual reordering with optional cross-container moves
const applyVisualReorder = useCallback((order: string[]) => {
if (isReorderingRef.current) return;
isReorderingRef.current = true;
requestAnimationFrame(() => {
try {
const cross = allowCrossContainer || (typeof document !== 'undefined' && document.body?.classList?.contains('myuibrix-cross-container-reorder'));
if (cross) {
// Prefer the in-page content wrapper as the canonical parent
const primary = (safeDOM.querySelector('.container') as HTMLElement) ||
(safeDOM.querySelector('.myuibrix-viewport-wrapper') as HTMLElement) ||
(safeDOM.querySelector('main') as HTMLElement) || null;
if (primary) {
// Move any element not under the primary into it
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el && el.parentElement !== primary) {
safeDOM.appendChild(primary, el);
}
});
// Append in requested order
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el) {
try { el.style.order = ''; } catch {}
safeDOM.appendChild(primary, el);
}
});
}
} else {
// Reorder only within each element's existing parent
const parentMap = new Map<HTMLElement, HTMLElement[]>();
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (!el || !el.parentElement) return;
const parent = el.parentElement as HTMLElement;
if (!parentMap.has(parent)) parentMap.set(parent, []);
parentMap.get(parent)!.push(el);
});
parentMap.forEach((els, parent) => {
els.forEach((el) => { try { el.style.order = ''; } catch {} });
order.forEach((name) => {
const el = safeDOM.querySelector(`[data-element="${name}"]`) as HTMLElement | null;
if (el && el.parentElement === parent) {
safeDOM.appendChild(parent, el);
}
});
});
}
// Notify listeners (HomePage hook) about the new order
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
setTimeout(() => { isReorderingRef.current = false; }, 50);
} catch (error) {
console.error('Error during DOM reordering:', error);
isReorderingRef.current = false;
}
});
}, [allowCrossContainer]);
// Debounce style changes to prevent lag
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
@@ -873,20 +1192,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, 100); // 100ms debounce
}, [isEditing]);
const handleAddElement = useCallback((elementName: string) => {
// Helper: compute insert index for a given grid column (append at end of that column)
const computeInsertIndexForColumn = useCallback((col: number) => {
const cols = Math.max(1, containerGridCols || 1);
const L = elementOrderRef.current.length;
let countInCol = 0;
for (let i = 0; i < L; i++) {
if (i % cols === col) countInCol++;
}
return col + countInCol * cols;
}, [containerGridCols]);
// Add element from picker and make it visible + ordered in preview
const handleAddElement = useCallback((elementName: string, insertAt?: number) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
if (!element) return;
const newVisible = new Set(visibleElements);
newVisible.add(elementName);
setVisibleElements(newVisible);
const existingVariant = localChanges[elementName];
const defaultVariant = normalizeVariant(elementName, element.defaultVariant);
const defaultVariant = normalizeVariant(elementName, element?.defaultVariant || 'default');
const variantToUse = normalizeVariant(elementName, existingVariant || defaultVariant);
if (!localChanges[elementName]) {
setLocalChanges(prev => ({ ...prev, [elementName]: variantToUse }));
}
// Mark as visible in editor state
setVisibleElements(prev => {
const next = new Set(prev);
next.add(elementName);
return next;
});
// Ensure config entry exists and is visible
setConfigs(prev => {
const index = prev.findIndex(cfg => cfg.element_name === elementName);
if (index !== -1) {
@@ -894,27 +1227,104 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
updated[index] = { ...updated[index], variant: variantToUse, visible: true };
return updated;
}
return [...prev, {
page_type: pageType,
element_name: elementName,
variant: variantToUse,
visible: true,
display_order: prev.length,
}];
return [
...prev,
{
page_type: pageType,
element_name: elementName,
variant: variantToUse,
visible: true,
display_order: prev.length,
}
];
});
// Close picker UI
setHasChanges(true);
setShowElementPicker(false);
setSearchQuery('');
setSelectedCategory('all');
// Live preview ONLY during editing
// Add into ordering at desired position and apply reordering
setElementOrder(prev => {
const targetIndex = (typeof insertAt === 'number') ? insertAt : (pendingInsertIndex != null ? pendingInsertIndex : undefined);
if (prev.includes(elementName)) {
try {
toast({
title: 'Duplicitní element',
description: 'Tento element již na stránce existuje. Přesouvám existující na zvolené místo.',
status: 'warning',
duration: 2500,
isClosable: true,
});
} catch {}
const existingIdx = prev.indexOf(elementName);
if (typeof targetIndex === 'number' && targetIndex !== existingIdx) {
const reordered = [...prev];
reordered.splice(existingIdx, 1);
reordered.splice(Math.min(targetIndex, reordered.length), 0, elementName);
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(reordered);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: reordered, previewMode: true } }));
});
}
return reordered;
}
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(prev);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', { detail: { order: prev, previewMode: true } }));
});
}
return prev;
}
const newOrder = [...prev];
if (typeof targetIndex === 'number') {
newOrder.splice(Math.min(targetIndex, newOrder.length), 0, elementName);
} else {
newOrder.push(elementName);
}
if (isEditing) {
requestAnimationFrame(() => {
applyVisualReorder(newOrder);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order: newOrder, previewMode: true }
}));
});
}
// Ensure element is visible in DOM for editing
setTimeout(() => {
try {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = '';
}
} catch {}
}, 0);
return newOrder;
});
// Notify preview consumers to render element
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: variantToUse, visible: true, previewMode: true }
}));
}
}, [visibleElements, localChanges, isEditing, normalizeVariant, pageType]);
setPendingInsertIndex(null);
// Auto-select and open style panel for the newly added element
try {
setSelectedElement(elementName);
setTimeout(() => {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`) as HTMLElement | null;
if (el) {
const rect = el.getBoundingClientRect();
setElementPosition({ top: rect.top, left: rect.left, width: rect.width, height: rect.height });
setShowStylePanel(true);
}
}, 0);
} catch {}
}, [localChanges, isEditing, normalizeVariant, pageType, applyVisualReorder, pendingInsertIndex]);
const handleRemoveElement = useCallback((elementName: string) => {
// Update state - React will handle DOM removal
@@ -940,50 +1350,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}, 0);
}, [visibleElements, localChanges, isEditing]);
// Apply visual reordering using CSS order property instead of DOM manipulation
const applyVisualReorder = useCallback((order: string[]) => {
// Prevent concurrent reordering operations
if (isReorderingRef.current) {
return;
}
isReorderingRef.current = true;
// Use CSS order property to avoid DOM manipulation conflicts with React
requestAnimationFrame(() => {
try {
order.forEach((elementName, index) => {
const element = safeDOM.querySelector(`[data-element="${elementName}"]`) as HTMLElement;
if (element) {
// Use CSS order instead of moving DOM nodes
element.style.order = String(index);
}
});
// Ensure parent container uses flexbox
const viewport = safeDOM.querySelector('.myuibrix-viewport-wrapper');
const container = viewport || safeDOM.querySelector('.container') || safeDOM.querySelector('main');
if (container) {
(container as HTMLElement).style.display = 'flex';
(container as HTMLElement).style.flexDirection = 'column';
}
console.log('Visual reorder applied via CSS order');
// Dispatch reorder event for HomePage to update
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
setTimeout(() => {
isReorderingRef.current = false;
}, 50);
} catch (error) {
console.error('Error during visual reordering:', error);
isReorderingRef.current = false;
}
});
}, []);
const handleMoveUp = useCallback((elementName: string) => {
const currentIndex = elementOrder.indexOf(elementName);
@@ -1087,6 +1454,57 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
}, [draggedElement, elementOrder, isEditing, applyVisualReorder]);
// Start with a blank layout: hide all elements and clear order
const handleStartBlank = useCallback(() => {
try {
if (!confirm('Začít s prázdným rozložením? Všechny sekce (kromě hlavičky a patičky) budou skryté.')) {
return;
}
} catch {}
const previouslyVisible = Array.from(visibleElements);
const keep = new Set(['header', 'footer']);
const toHide = previouslyVisible.filter(n => !keep.has(n));
const newVisible = new Set<string>();
previouslyVisible.forEach(n => { if (keep.has(n)) newVisible.add(n); });
setVisibleElements(newVisible);
setElementOrder(prev => prev.filter(n => keep.has(n)));
setHasChanges(true);
if (isEditing) {
toHide.forEach((elementName) => {
try {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: false, previewMode: true }
}));
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = 'none';
}
} catch {}
});
requestAnimationFrame(() => {
const order = Array.from(newVisible).filter(n => keep.has(n));
applyVisualReorder(order);
window.dispatchEvent(new CustomEvent('myuibrix-reorder', {
detail: { order, previewMode: true }
}));
});
}
try {
toast({
title: 'Prázdné rozložení',
description: 'Všechny sekce byly skryty. Můžete začít přidávat prvky.',
status: 'info',
duration: 2500,
isClosable: true,
});
} catch {}
setShowElementPicker(true);
}, [visibleElements, isEditing, localChanges, applyVisualReorder, toast]);
const handleSave = async () => {
try {
const configsToSave: PageElementConfig[] = elementOrder.map((elementName, index) => ({
@@ -1095,6 +1513,10 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
variant: localChanges[elementName] || 'default',
visible: visibleElements.has(elementName),
display_order: index,
settings: {
...(configs.find(c => c.element_name === elementName)?.settings || {}),
customCSS: (elementStyles[elementName]?.customCSS || ''),
},
}));
await batchUpdatePageElementConfigs(configsToSave);
@@ -1107,6 +1529,13 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
isClosable: true,
});
// Update baseline to current state so counter resets immediately
setBaseline({
variants: { ...localChanges },
visible: new Set<string>(visibleElements),
order: [...elementOrder],
css: Object.fromEntries(Object.entries(elementStyles).map(([k, v]) => [k, String((v as any)?.customCSS || '')])),
});
setHasChanges(false);
// Reload the page to apply changes in production view
@@ -1438,7 +1867,27 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Right: Actions */}
<HStack spacing={2}>
{hasChanges && (
<Button
leftIcon={<FaPaintBrush />}
size="sm"
variant={showStylePanel ? 'solid' : 'outline'}
colorScheme={showStylePanel ? 'blue' : 'whiteAlpha'}
onClick={() => setShowStylePanel(!showStylePanel)}
borderRadius="xl"
>
Vizuální styly
</Button>
<Tooltip label={stylePanelRight ? 'Ukotvit vlevo' : 'Ukotvit vpravo'}>
<IconButton
aria-label="Dock panel"
icon={stylePanelRight ? <FaAngleLeft /> : <FaAngleRight />}
size="sm"
variant="ghost"
colorScheme="whiteAlpha"
onClick={() => setStylePanelRight(!stylePanelRight)}
/>
</Tooltip>
{unsavedCount > 0 && (
<Badge
bg="yellow.400"
color="gray.900"
@@ -1457,7 +1906,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
animation: 'bounce 1s infinite'
}}
>
{Object.keys(localChanges).length} neuložených změn
{unsavedCount} neuložených změn
</Badge>
)}
<Button
@@ -1492,18 +1941,17 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
</HStack>
</Flex>
</Box>
{/* Left Visual Style Panel */}
{showStylePanel && selectedElement && (
{/* Visual Style Panel (anchored, non-movable) */}
{showStylePanel && (
<Box
className="myuibrix-panel"
position="fixed"
left={`${panelPositions.visualStylePanel.x}px`}
top={`${panelPositions.visualStylePanel.y}px`}
width={`${panelPositions.visualStylePanel.width}px`}
height={`${panelPositions.visualStylePanel.height}px`}
left={stylePanelRight ? undefined : 4}
right={stylePanelRight ? 4 : undefined}
top={64}
bottom={4}
width="380px"
zIndex={9998}
onMouseDown={(e) => handlePanelMouseDown('visualStylePanel', e)}
cursor={draggingPanel === 'visualStylePanel' ? 'grabbing' : 'default'}
overflow="hidden"
display="flex"
flexDirection="column"
@@ -1526,14 +1974,14 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
bgGradient={`linear(135deg, ${primaryColor}, ${primaryColor}dd)`}
color="white"
p={3}
cursor="move"
cursor="default"
display="flex"
alignItems="center"
justifyContent="space-between"
flexShrink={0}
borderTopRadius="2xl"
borderBottom="1px solid rgba(255,255,255,0.2)"
boxShadow="0 2px 8px rgba(0,0,0,0.1)"
borderBottom="1px solid rgba(255,255,255,0.2)"
>
<HStack>
<Icon as={FaPaintBrush} />
@@ -1549,29 +1997,19 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
/>
</Box>
<Box flex="1" overflow="auto">
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
{selectedElement ? (
<VisualStylePanel
elementName={selectedElement}
onStyleChange={(styles) => handleStyleChange(selectedElement, styles)}
currentStyles={elementStyles[selectedElement]}
/>
) : (
<Box p={4} color="gray.600" fontSize="sm">
<Text fontWeight="bold" mb={2}>Vyberte sekci</Text>
<Text>Vyberte sekci na stránce pro úpravu vizuálních stylů. Klikněte na zvýrazněný překryv sekce nebo vyberte ze seznamu vrstev.</Text>
</Box>
)}
</Box>
{/* Resize handle */}
<Box
position="absolute"
bottom={0}
right={0}
width="24px"
height="24px"
cursor="nwse-resize"
bgGradient="linear(135deg, transparent, rgba(0,0,0,0.15))"
opacity={0.4}
_hover={{ opacity: 1 }}
onMouseDown={(e) => handleResizeStart('visualStylePanel', e)}
sx={{
clipPath: 'polygon(100% 0, 100% 100%, 0 100%)'
}}
transition="opacity 0.2s"
/>
</Box>
)}
</>
@@ -1579,6 +2017,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
{/* Floating Control Panel - Minimalist */}
<Box
className="myuibrix-toolbar"
position="fixed"
left={4}
bottom={4}
@@ -2016,6 +2455,34 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
</Box>
)}
{/* Grid insertion pickers */}
{containerGridCols > 1 && (
<Box p={4} borderBottom="1px" borderColor="gray.100" bg="white">
<VStack align="stretch" spacing={2}>
<Text fontSize="sm" fontWeight="bold" color="gray.600">Vložit do sloupce</Text>
<HStack spacing={2} flexWrap="wrap">
{Array.from({ length: containerGridCols }).map((_, col) => (
<Button
key={col}
size="sm"
variant={pendingInsertIndex != null && (pendingInsertIndex % containerGridCols) === col ? 'solid' : 'outline'}
colorScheme={pendingInsertIndex != null && (pendingInsertIndex % containerGridCols) === col ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
const idx = computeInsertIndexForColumn(col);
setPendingInsertIndex(idx);
toast({ title: 'Pozice zvolena', description: `Sloupec ${col + 1}`, status: 'info', duration: 1500 });
}}
>
Sloupec {col + 1}
</Button>
))}
<Button size="sm" variant="ghost" onClick={() => setPendingInsertIndex(null)}>Zrušit pozici</Button>
</HStack>
</VStack>
</Box>
)}
{/* Layers Panel - Visual element list with drag-drop */}
{isEditing && showLayersPanel && (
<Box
@@ -2023,10 +2490,11 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
position="fixed"
left={panelPositions.layersPanel.x === 0 ? undefined : `${panelPositions.layersPanel.x}px`}
right={panelPositions.layersPanel.x === 0 ? 4 : undefined}
top={panelPositions.layersPanel.y === 0 ? "50%" : `${panelPositions.layersPanel.y}px`}
transform={panelPositions.layersPanel.y === 0 ? "translateY(-50%)" : undefined}
top={panelPositions.layersPanel.y === 0 ? 4 : `${panelPositions.layersPanel.y}px`}
bottom={panelPositions.layersPanel.y === 0 ? 4 : undefined}
transform={undefined}
width={`${panelPositions.layersPanel.width}px`}
height={`${panelPositions.layersPanel.height}px`}
height={panelPositions.layersPanel.y === 0 ? 'auto' : `${panelPositions.layersPanel.height}px`}
bg="rgba(255, 255, 255, 0.97)"
backdropFilter="blur(16px) saturate(180%)"
borderRadius="2xl"
@@ -2038,10 +2506,12 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
onMouseDown={(e) => handlePanelMouseDown('layersPanel', e)}
cursor={draggingPanel === 'layersPanel' ? 'grabbing' : 'default'}
fontFamily="var(--chakra-fonts-body)"
display="flex"
flexDirection="column"
sx={{
'@keyframes slideInRight': {
from: { opacity: 0, transform: panelPositions.layersPanel.y === 0 ? 'translate(40px, -50%)' : 'translateX(40px)' },
to: { opacity: 1, transform: panelPositions.layersPanel.y === 0 ? 'translateY(-50%)' : 'translateX(0)' }
from: { opacity: 0, transform: 'translateX(40px)' },
to: { opacity: 1, transform: 'translateX(0)' }
},
animation: 'slideInRight 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
}}
@@ -2073,8 +2543,18 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
/>
</Flex>
{/* Blank layout action */}
<Box p={2} borderBottom="1px" borderColor="whiteAlpha.400" bg="whiteAlpha.200">
<HStack justify="space-between">
<Text fontSize="xs" opacity={0.85}>Začít s prázdným rozložením</Text>
<Button size="xs" variant="outline" onClick={handleStartBlank}>
Začít s prázdným rozložením
</Button>
</HStack>
</Box>
{/* Layers List */}
<VStack align="stretch" p={3} spacing={2} maxH="calc(80vh - 60px)" overflowY="auto">
<VStack align="stretch" p={3} spacing={2} flex={1} overflowY="auto">
{elementOrder.map((elementName, index) => {
const element = PREDEFINED_ELEMENTS.find(e => e.name === elementName);
const isVisible = visibleElements.has(elementName);
@@ -2192,6 +2672,20 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
newVisible.add(elementName);
setVisibleElements(newVisible);
setHasChanges(true);
// Live preview: show element again
if (isEditing) {
window.dispatchEvent(new CustomEvent('myuibrix-change', {
detail: { elementName, variant: localChanges[elementName], visible: true, previewMode: true }
}));
}
// Restore element display and re-apply current visual order
setTimeout(() => {
const el = safeDOM.querySelector(`[data-element="${elementName}"]`);
if (el) {
(el as HTMLElement).style.display = '';
}
applyVisualReorder(elementOrder);
}, 0);
}
}}
/>
@@ -2404,8 +2898,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
);
const availableElements = elements.filter(e => {
if (visibleElements.has(e.name)) return false;
// Filter by search query
// Filter by search query only; allow duplicates (we'll warn and move existing)
if (searchQuery) {
const query = searchQuery.toLowerCase();
return e.label.toLowerCase().includes(query) ||
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import {
Box,
VStack,
@@ -34,6 +34,7 @@ import CustomCSSEditor from './CustomCSSEditor';
import ColumnLayoutManager from './ColumnLayoutManager';
import ContextualAdminLinks from './ContextualAdminLinks';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { FONT_PAIRINGS, loadGoogleFont } from '../../config/fonts';
interface VisualStylePanelProps {
elementName: string;
@@ -94,12 +95,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
...currentStyles,
});
// Sync local styles state when switching element or when parent provides updated styles
useEffect(() => {
setStyles({
// Typography
fontFamily: currentStyles.fontFamily || 'Inter',
fontSize: currentStyles.fontSize || 16,
fontWeight: currentStyles.fontWeight || 400,
lineHeight: currentStyles.lineHeight || 1.5,
letterSpacing: currentStyles.letterSpacing || 0,
textTransform: currentStyles.textTransform || 'none',
// Colors
color: currentStyles.color || '#000000',
backgroundColor: currentStyles.backgroundColor || '#ffffff',
// Spacing
paddingTop: currentStyles.paddingTop || 0,
paddingRight: currentStyles.paddingRight || 0,
paddingBottom: currentStyles.paddingBottom || 0,
paddingLeft: currentStyles.paddingLeft || 0,
marginTop: currentStyles.marginTop || 0,
marginRight: currentStyles.marginRight || 0,
marginBottom: currentStyles.marginBottom || 0,
marginLeft: currentStyles.marginLeft || 0,
// Layout
width: currentStyles.width || 'auto',
height: currentStyles.height || 'auto',
display: currentStyles.display || 'block',
// Grid Layout
gridTemplateColumns: currentStyles.gridTemplateColumns || 'repeat(3, 1fr)',
gridTemplateRows: currentStyles.gridTemplateRows || 'auto',
gridColumnGap: currentStyles.gridColumnGap || 16,
gridRowGap: currentStyles.gridRowGap || 16,
gridAutoFlow: currentStyles.gridAutoFlow || 'row',
alignItems: currentStyles.alignItems || 'stretch',
justifyItems: currentStyles.justifyItems || 'stretch',
// Custom CSS
customCSS: currentStyles.customCSS || '',
...currentStyles,
});
}, [elementName, currentStyles]);
const updateStyle = (key: string, value: any) => {
const newStyles = { ...styles, [key]: value };
setStyles(newStyles);
onStyleChange(newStyles);
};
// Font pairing and curated font options
const [pairingId, setPairingId] = useState<string>(FONT_PAIRINGS[0]?.id || '');
const selectedPairing = useMemo(() => FONT_PAIRINGS.find(p => p.id === pairingId) || FONT_PAIRINGS[0], [pairingId]);
const curatedFonts = useMemo(() => {
const map = new Map<string, { name: string; googleFontsUrl: string }>();
FONT_PAIRINGS.forEach(p => {
map.set(p.heading, { name: p.heading, googleFontsUrl: p.googleFontsUrl });
map.set(p.body, { name: p.body, googleFontsUrl: p.googleFontsUrl });
});
return Array.from(map.values());
}, []);
return (
<Box
width="280px"
@@ -112,24 +171,70 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<Tabs size="sm" colorScheme="blue">
<TabList px={2} flexWrap="wrap">
<Tab><FiType /> <Text ml={1}>Content</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Style</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Layout</Text></Tab>
<Tab><FiType /> <Text ml={1}>Obsah</Text></Tab>
<Tab><FiLayout /> <Text ml={1}>Styl</Text></Tab>
<Tab><FiColumns /> <Text ml={1}>Rozvržení</Text></Tab>
<Tab><FiCode /> <Text ml={1}>CSS</Text></Tab>
<Tab><FiExternalLink /> <Text ml={1}>Admin</Text></Tab>
</TabList>
<TabPanels>
{/* Content Tab */}
{/* Záložka: Obsah */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Typography
Typografie
</Text>
{/* Font Family */}
{/* Font pairing from Setup (curated) */}
<FormControl>
<FormLabel fontSize="xs">Font Family</FormLabel>
<FormLabel fontSize="xs">Párování fontů (Setup)</FormLabel>
<HStack>
<Select
size="sm"
value={pairingId}
onChange={(e) => {
const id = e.target.value;
setPairingId(id);
const p = FONT_PAIRINGS.find(pp => pp.id === id);
if (p) {
loadGoogleFont(p.googleFontsUrl);
}
}}
>
{FONT_PAIRINGS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</Select>
</HStack>
<HStack spacing={2} mt={2}>
<Button size="xs" variant="outline" onClick={() => { if (selectedPairing) { loadGoogleFont(selectedPairing.googleFontsUrl); updateStyle('fontFamily', selectedPairing.cssHeading); } }}>Použít nadpisový</Button>
<Button size="xs" variant="outline" onClick={() => { if (selectedPairing) { loadGoogleFont(selectedPairing.googleFontsUrl); updateStyle('fontFamily', selectedPairing.cssBody); } }}>Použít textový</Button>
</HStack>
</FormControl>
{/* Curated font list (unique from pairing set) */}
<FormControl>
<FormLabel fontSize="xs">Dostupné fonty (Setup)</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
onChange={(e) => {
const val = e.target.value;
updateStyle('fontFamily', val);
const found = FONT_PAIRINGS.find(p => p.cssHeading === val || p.cssBody === val);
if (found) loadGoogleFont(found.googleFontsUrl);
}}
>
{curatedFonts.map(f => (
<option key={f.name} value={f.name}>{f.name}</option>
))}
</Select>
</FormControl>
{/* Rodina písma */}
<FormControl>
<FormLabel fontSize="xs">Písmo</FormLabel>
<Select
size="sm"
value={styles.fontFamily}
@@ -146,9 +251,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</Select>
</FormControl>
{/* Font Size */}
{/* Velikost písma */}
<FormControl>
<FormLabel fontSize="xs">Size (px)</FormLabel>
<FormLabel fontSize="xs">Velikost (px)</FormLabel>
<HStack>
<NumberInput
size="sm"
@@ -167,9 +272,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Font Weight */}
{/* Tloušťka písma */}
<FormControl>
<FormLabel fontSize="xs">Weight</FormLabel>
<FormLabel fontSize="xs">Tloušťka</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.fontWeight}
@@ -188,9 +293,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Line Height */}
{/* Řádkování */}
<FormControl>
<FormLabel fontSize="xs">Line Height</FormLabel>
<FormLabel fontSize="xs">Řádkování</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.lineHeight}
@@ -209,9 +314,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Letter Spacing */}
{/* Mezery mezi písmeny */}
<FormControl>
<FormLabel fontSize="xs">Letter Spacing (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi písmeny (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.letterSpacing}
@@ -230,33 +335,33 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Text Transform */}
{/* Transformace textu */}
<FormControl>
<FormLabel fontSize="xs">Transform</FormLabel>
<FormLabel fontSize="xs">Transformace</FormLabel>
<Select
size="sm"
value={styles.textTransform}
onChange={(e) => updateStyle('textTransform', e.target.value)}
>
<option value="none">None</option>
<option value="uppercase">UPPERCASE</option>
<option value="lowercase">lowercase</option>
<option value="capitalize">Capitalize</option>
<option value="none">Žádné</option>
<option value="uppercase">VELKÁ PÍSMENA</option>
<option value="lowercase">malá písmena</option>
<option value="capitalize">První písmena velká</option>
</Select>
</FormControl>
</VStack>
</TabPanel>
{/* Style Tab */}
{/* Záložka: Styl */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Colors
Barvy
</Text>
{/* Text Color */}
{/* Barva textu */}
<FormControl>
<FormLabel fontSize="xs">Text Color</FormLabel>
<FormLabel fontSize="xs">Barva textu</FormLabel>
<HStack>
<Input
type="color"
@@ -275,9 +380,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Background Color */}
{/* Barva pozadí */}
<FormControl>
<FormLabel fontSize="xs">Background Color</FormLabel>
<FormLabel fontSize="xs">Barva pozadí</FormLabel>
<HStack>
<Input
type="color"
@@ -299,15 +404,15 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider my={2} />
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Spacing
Odsazení a okraje
</Text>
{/* Padding */}
{/* Vnitřní odsazení (padding) */}
<FormControl>
<FormLabel fontSize="xs">Padding (px)</FormLabel>
<FormLabel fontSize="xs">Vnitřní odsazení (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<Text fontSize="xs" minW="20px">N</Text>
<NumberInput
size="xs"
value={styles.paddingTop}
@@ -319,7 +424,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<Text fontSize="xs" minW="20px">P</Text>
<NumberInput
size="xs"
value={styles.paddingRight}
@@ -331,7 +436,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<Text fontSize="xs" minW="20px">D</Text>
<NumberInput
size="xs"
value={styles.paddingBottom}
@@ -357,12 +462,12 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</FormControl>
{/* Margin */}
{/* Vnější okraj (margin) */}
<FormControl>
<FormLabel fontSize="xs">Margin (px)</FormLabel>
<FormLabel fontSize="xs">Vnější okraj (px)</FormLabel>
<VStack spacing={2}>
<HStack width="100%">
<Text fontSize="xs" minW="20px">T</Text>
<Text fontSize="xs" minW="20px">N</Text>
<NumberInput
size="xs"
value={styles.marginTop}
@@ -373,7 +478,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">R</Text>
<Text fontSize="xs" minW="20px">P</Text>
<NumberInput
size="xs"
value={styles.marginRight}
@@ -384,7 +489,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</NumberInput>
</HStack>
<HStack width="100%">
<Text fontSize="xs" minW="20px">B</Text>
<Text fontSize="xs" minW="20px">D</Text>
<NumberInput
size="xs"
value={styles.marginBottom}
@@ -410,16 +515,16 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Layout Tab (was Grid Tab) */}
{/* Záložka: Rozvržení (mřížka) */}
<TabPanel>
<VStack align="stretch" spacing={4}>
<Text fontWeight="bold" fontSize="sm" textTransform="uppercase" color="gray.500">
Grid Layout
Mřížkové rozložení
</Text>
{/* Enable Grid */}
{/* Povolit mřížkové rozložení */}
<FormControl display="flex" alignItems="center">
<FormLabel fontSize="xs" mb={0} flex={1}>Enable Grid Layout</FormLabel>
<FormLabel fontSize="xs" mb={0} flex={1}>Povolit mřížkové rozložení</FormLabel>
<Switch
size="sm"
isChecked={styles.display === 'grid'}
@@ -437,9 +542,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<>
<Divider />
{/* Quick Templates */}
{/* Rychlé šablony */}
<FormControl>
<FormLabel fontSize="xs" fontWeight="bold">Quick Templates</FormLabel>
<FormLabel fontSize="xs" fontWeight="bold">Rychlé šablony</FormLabel>
<VStack spacing={2}>
<Button
size="xs"
@@ -450,7 +555,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiSmartphone />
<Text>Single Column</Text>
<Text>Jeden sloupec</Text>
</HStack>
</Button>
<Button
@@ -462,7 +567,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaColumns />
<Text>Two Equal (50% / 50%)</Text>
<Text>Dva stejné (50 % / 50 %)</Text>
</HStack>
</Button>
<Button
@@ -474,7 +579,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiBarChart2 />
<Text>Left Larger (66% / 33%)</Text>
<Text>Vlevo větší (66 % / 33 %)</Text>
</HStack>
</Button>
<Button
@@ -486,7 +591,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiBarChart2 style={{ transform: 'scaleX(-1)' }} />
<Text>Right Larger (33% / 66%)</Text>
<Text>Vpravo větší (33 % / 66 %)</Text>
</HStack>
</Button>
<Button
@@ -498,7 +603,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiGrid />
<Text>Three Equal (33% / 33% / 33%)</Text>
<Text>Tři stejné (33 % / 33 % / 33 %)</Text>
</HStack>
</Button>
<Button
@@ -510,7 +615,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaRegNewspaper />
<Text>Featured + Two (50% / 25% / 25%)</Text>
<Text>Zvýrazněný + dva (50 % / 25 % / 25 %)</Text>
</HStack>
</Button>
<Button
@@ -522,7 +627,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FaRegSquare />
<Text>Four Equal (25% each)</Text>
<Text>Čtyři stejné (25 % každá)</Text>
</HStack>
</Button>
<Button
@@ -534,7 +639,7 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
>
<HStack spacing={2}>
<FiSidebar />
<Text>Main + Sidebar (75% / 25%)</Text>
<Text>Hlavní + postranní (75 % / 25 %)</Text>
</HStack>
</Button>
</VStack>
@@ -542,38 +647,38 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider />
{/* Custom Columns */}
{/* Vlastní sloupce */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Columns</FormLabel>
<FormLabel fontSize="xs">Sloupce mřížky</FormLabel>
<Input
size="sm"
value={styles.gridTemplateColumns}
onChange={(e) => updateStyle('gridTemplateColumns', e.target.value)}
placeholder="e.g. 1fr 2fr or 300px 1fr"
placeholder="např. 1fr 2fr nebo 300px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
<Text fontSize="10px" color="gray.500" mt={1}>
Examples: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
Příklady: 1fr 1fr | 2fr 1fr | 200px 1fr | repeat(3, 1fr)
</Text>
</FormControl>
{/* Grid Template Rows */}
{/* Řádky mřížky */}
<FormControl>
<FormLabel fontSize="xs">Grid Template Rows</FormLabel>
<FormLabel fontSize="xs">Řádky mřížky</FormLabel>
<Input
size="sm"
value={styles.gridTemplateRows}
onChange={(e) => updateStyle('gridTemplateRows', e.target.value)}
placeholder="auto or 200px 1fr"
placeholder="auto nebo 200px 1fr"
fontFamily="monospace"
fontSize="xs"
/>
</FormControl>
{/* Column Gap */}
{/* Mezera mezi sloupci */}
<FormControl>
<FormLabel fontSize="xs">Column Gap (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi sloupci (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridColumnGap}
@@ -592,9 +697,9 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</HStack>
</FormControl>
{/* Row Gap */}
{/* Mezera mezi řádky */}
<FormControl>
<FormLabel fontSize="xs">Row Gap (px)</FormLabel>
<FormLabel fontSize="xs">Mezera mezi řádky (px)</FormLabel>
<HStack spacing={2}>
<Slider
value={styles.gridRowGap}
@@ -615,49 +720,49 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
<Divider />
{/* Grid Auto Flow */}
{/* Automatické rozmístění */}
<FormControl>
<FormLabel fontSize="xs">Auto Flow</FormLabel>
<FormLabel fontSize="xs">Automatické rozmístění</FormLabel>
<Select
size="sm"
value={styles.gridAutoFlow}
onChange={(e) => updateStyle('gridAutoFlow', e.target.value)}
>
<option value="row">Row (horizontal)</option>
<option value="column">Column (vertical)</option>
<option value="row dense">Row Dense</option>
<option value="column dense">Column Dense</option>
<option value="row">Řádek (vodorovně)</option>
<option value="column">Sloupec (svisle)</option>
<option value="row dense">Řádek (zahuštěný)</option>
<option value="column dense">Sloupec (zahuštěný)</option>
</Select>
</FormControl>
{/* Align Items */}
{/* Zarovnání (vertikálně) */}
<FormControl>
<FormLabel fontSize="xs">Align Items (vertical)</FormLabel>
<FormLabel fontSize="xs">Zarovnání prvků (vertikálně)</FormLabel>
<Select
size="sm"
value={styles.alignItems}
onChange={(e) => updateStyle('alignItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="baseline">Baseline</option>
<option value="stretch">Roztáhnout</option>
<option value="start">Začátek</option>
<option value="center">Střed</option>
<option value="end">Konec</option>
<option value="baseline">Základní řádek</option>
</Select>
</FormControl>
{/* Justify Items */}
{/* Zarovnání (horizontálně) */}
<FormControl>
<FormLabel fontSize="xs">Justify Items (horizontal)</FormLabel>
<FormLabel fontSize="xs">Zarovnání prvků (horizontálně)</FormLabel>
<Select
size="sm"
value={styles.justifyItems}
onChange={(e) => updateStyle('justifyItems', e.target.value)}
>
<option value="stretch">Stretch</option>
<option value="start">Start</option>
<option value="center">Center</option>
<option value="end">End</option>
<option value="stretch">Roztáhnout</option>
<option value="start">Začátek</option>
<option value="center">Střed</option>
<option value="end">Konec</option>
</Select>
</FormControl>
</>
@@ -665,16 +770,18 @@ const VisualStylePanel: React.FC<VisualStylePanelProps> = ({
</VStack>
</TabPanel>
{/* Custom CSS Tab */}
{/* Záložka: Vlastní CSS */}
<TabPanel p={0}>
<CustomCSSEditor
elementName={elementName}
onCSSChange={(css) => updateStyle('customCSS', css)}
currentCSS={styles.customCSS || ''}
currentStyles={styles}
theme={{ primary: clubTheme.primary, secondary: clubTheme.secondary, accent: (clubTheme as any).accent }}
/>
</TabPanel>
{/* Admin Links Tab */}
{/* Záložka: Admin odkazy */}
<TabPanel>
<ContextualAdminLinks elementName={elementName} />
</TabPanel>
@@ -0,0 +1,111 @@
// Static TSX context snippets for AI CSS generation. Keep concise and representative.
// These snippets help the AI understand structure and common selectors per section.
export const ELEMENT_TSX_CONTEXT: Record<string, { tsx: string; selectors?: string[]; notes?: string }> = {
hero: {
tsx: `
<section data-element="hero" className="hero-grid">
{/* variant: grid | scroller | swiper | swiper_full */}
<a className="hero-card big">
<div className="bg" />
<div className="meta">
<div className="tag">Aktuality</div>
<h2 className="title">Nadpis</h2>
</div>
</a>
<a className="hero-card" />
<a className="hero-card" />
</section>
`.trim(),
selectors: ['.hero-grid', '.hero-card', '.bg', '.meta', '.tag', '.title'],
notes: 'Full-bleed variants may use negative margins and viewport width tricks.'
},
matches: {
tsx: `
<section data-element="matches" className="next-match">
<button className="nav prev" />
<div className="team">
<img className="logo" />
<div>Domácí</div>
</div>
<div className="countdown">Začátek zápasu</div>
<div className="team">
<img className="logo" />
<div>Hosté</div>
</div>
<button className="nav next" />
</section>
`.trim(),
selectors: ['.next-match', '.team', '.logo', '.countdown', '.nav.prev', '.nav.next'],
notes: 'Center content, strong contrast on countdown. Keep buttons accessible.'
},
'matches-slider': {
tsx: `
<section data-element="matches-slider" className="matches-slider">
<div className="section-head">
<h3>Zápasy</h3>
<a className="see-all" />
</div>
<div className="matches-grid">
<div className="matches-track">
<div className="match-card">
<div className="match-meta" />
<div className="teams">
<div className="team"><img /><div className="name" /></div>
<div className="score"><span className="home" /><span className="sep" /><span className="away" /></div>
<div className="team"><img /><div className="name" /></div>
</div>
</div>
</div>
<div className="matches-tabs"><button className="active" /></div>
</div>
</section>
`.trim(),
selectors: ['.matches-slider', '.section-head', '.see-all', '.matches-grid', '.matches-track', '.match-card', '.match-meta', '.teams', '.team', '.score', '.matches-tabs'],
notes: 'Horizontal scrolling track with cards; consider responsive card widths and gaps.'
},
news: {
tsx: `
<section data-element="news" className="news-list">
<div className="section-head"><h3>Další aktuality</h3></div>
<div className="blog-list">
<a className="card">
<div className="thumb" />
<div><h4>Title</h4><div className="excerpt" /></div>
</a>
</div>
<div><a className="btn">Zobrazit všechny aktuality</a></div>
</section>
`.trim(),
selectors: ['.news-list', '.section-head', '.blog-list', '.card', '.thumb', '.btn'],
},
table: {
tsx: `
<section data-element="table" className="standings">
<div className="table-card">
<div className="section-head">
<h3>Tabulky</h3>
<a className="see-all" />
</div>
<div className="standings-table-wrapper">
<table className="standings-table-compact">
<thead><tr><th>#</th><th>Tým</th><th>Z</th><th>V</th><th>R</th><th>P</th><th className="hide-mobile">Skóre</th><th>Body</th></tr></thead>
<tbody><tr><td>#1</td><td><img />Tým</td><td>…</td></tr></tbody>
</table>
</div>
</div>
</section>
`.trim(),
selectors: ['.standings', '.table-card', '.section-head', '.standings-table-wrapper', '.standings-table-compact', '.see-all'],
notes: 'Compact table; mind overflow-x on small screens.'
},
sponsors: {
tsx: `
<section data-element="sponsors" className="sponsors">
<div className="section-head"><h3>Sponzoři</h3></div>
<div className="sponsors-grid"><a className="sponsor-tile"><img /></a></div>
</section>
`.trim(),
selectors: ['.sponsors', '.section-head', '.sponsors-grid', '.sponsor-tile'],
},
};
@@ -0,0 +1,177 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import '../../styles/sparta-styles.css';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useClubTheme } from '../../contexts/ClubThemeContext';
import { getNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { getCategories, Category } from '../../services/public';
// Minimal NavLink type used to render items
type NavLink = { label: string; to?: string; items?: { label: string; to: string }[]; external?: boolean };
const SpartaNavbar: React.FC = () => {
const { data: settings } = usePublicSettings();
const theme = useClubTheme();
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const [dynamicNavItems, setDynamicNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
const [navCategories, setNavCategories] = useState<Category[] | null>(null);
// Load dynamic navigation
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getNavigationItems();
if (active && Array.isArray(items)) {
const publicItems = items.filter(item => !item.requires_admin);
if (publicItems.length === 0) {
try {
await seedDefaultNavigation();
const newItems = await getNavigationItems();
if (active && Array.isArray(newItems)) {
const publicNewItems = newItems.filter(item => !item.requires_admin);
setDynamicNavItems(publicNewItems);
}
} catch {
setDynamicNavItems([]);
}
} else {
setDynamicNavItems(publicItems);
}
}
} catch {
// leave empty, fallback will handle
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, []);
// Load categories (for Blog dropdown fallback)
useEffect(() => {
let active = true;
(async () => {
try {
const cats = await getCategories();
if (active && Array.isArray(cats) && cats.length > 0) {
setNavCategories(cats);
} else if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
} catch {
if (active && Array.isArray(settings?.categories)) {
setNavCategories(settings!.categories as any);
}
}
})();
return () => { active = false };
}, [settings?.categories]);
const isPathActive = (to?: string) => {
if (!to) return false;
return location.pathname === to || location.pathname.startsWith((to || '') + '/');
};
const convertToNavLink = (item: NavigationItem): NavLink => {
const link: NavLink = {
label: item.label,
to: item.url || '#',
external: item.type === 'external',
};
if (item.type === 'dropdown' && item.children && item.children.length > 0) {
link.items = item.children.map(child => ({ label: child.label, to: child.url || '#' }));
}
return link;
};
const categoryItems = useMemo(() => {
const source = Array.isArray(navCategories) && navCategories.length > 0 ? navCategories : [];
return source.map((cat: any) => ({ label: cat.name, to: cat.url || (cat.slug ? `/blog?category=${encodeURIComponent(cat.slug)}` : '/blog') }));
}, [navCategories]);
const NAV_LINKS: NavLink[] = useMemo(() => {
if (!navLoading && dynamicNavItems.length > 0) {
const navLinks = dynamicNavItems.map(convertToNavLink);
if (categoryItems.length > 0) {
const idx = navLinks.findIndex(l => l.label === 'Články' || l.label === 'Blog' || l.to === '/blog');
if (idx !== -1) navLinks[idx] = { ...navLinks[idx], items: categoryItems };
}
return navLinks;
}
// Fallback minimal menu
const links: NavLink[] = [
{ label: 'Domů', to: '/' },
...(settings?.show_about_in_nav === false ? [] : [{ label: 'O klubu', to: '/o-klubu' } as NavLink]),
{ label: 'Kalendář', to: '/kalendar' },
{ label: 'Zápasy', to: '/zapasy' },
{ label: 'Aktivity', to: '/aktivity' },
{ label: 'Hráči', to: '/hraci' },
categoryItems.length > 0 ? { label: 'Články', to: '/blog', items: categoryItems } : { label: 'Články', to: '/blog' },
{ label: 'Videa', to: '/videa' },
{ label: settings?.gallery_label || 'Fotogalerie', to: '/galerie' },
...(settings?.shop_url ? [{ label: 'Fanshop', to: settings.shop_url, external: true } as NavLink] : []),
{ label: 'Sponzoři', to: '/sponzori' },
{ label: 'Kontakt', to: '/kontakt' },
];
return links;
}, [navLoading, dynamicNavItems, settings?.show_about_in_nav, settings?.shop_url, settings?.gallery_label, categoryItems]);
const logoUrl = settings?.club_logo_url || theme.logoUrl || '/dist/img/logo-club-empty.svg';
const clubName = settings?.club_name || theme.name || 'Klub';
return (
<div className="sparta-navbar-container">
<div className="sparta-navbar">
{/* Burger toggle for mobile */}
<button
aria-label="Menu"
className="sparta-navbar-toggle"
onClick={() => setMobileOpen(o => !o)}
>
<div className="sparta-burger-icon" aria-hidden>
<div className="sparta-burger-line" />
<div className="sparta-burger-line" />
<div className="sparta-burger-line" />
</div>
</button>
{/* Brand */}
<RouterLink to="/" className="sparta-navbar-brand" onClick={() => setMobileOpen(false)}>
<img src={logoUrl} alt={clubName} />
</RouterLink>
{/* Links */}
<nav
className="sparta-navbar-links"
style={{ display: mobileOpen ? 'flex' : undefined, flexWrap: 'wrap' }}
>
{NAV_LINKS.map((nav) => {
const isActive = isPathActive(nav.to);
const className = isActive ? 'sparta-button-tertiary' : 'sparta-button-tertiary';
if (nav.external && nav.to) {
return (
<a key={nav.label} href={nav.to} target="_blank" rel="noreferrer" className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</a>
);
}
return (
<RouterLink key={nav.label} to={nav.to || '#'} className={className} onClick={() => setMobileOpen(false)}>
{nav.label}
</RouterLink>
);
})}
</nav>
</div>
</div>
);
};
export default SpartaNavbar;
+1 -1
View File
@@ -237,7 +237,7 @@ const Footer: React.FC = () => {
</Box>
{/* MyClub Watermark - Clean White Branding */}
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6}>
<Box bg="white" borderTop="1px" borderColor="gray.200" py={6} data-watermark="myclub">
<Container maxW="container.xl">
<Stack
direction={{ base: 'column', md: 'row' }}
+39 -6
View File
@@ -3,13 +3,18 @@ import { ReactNode, useEffect, useState } from 'react';
import { FiChevronUp } from 'react-icons/fi';
import Navbar from '../Navbar';
import Footer from './Footer';
import { useAllPageElementConfigs } from '../../hooks/usePageElementConfig';
import SpartaNavbar from '../elements/SpartaNavbar';
interface MainLayoutProps {
children: ReactNode;
headerInsideContainer?: boolean;
}
export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
export const MainLayout: React.FC<MainLayoutProps> = ({ children, headerInsideContainer = false }) => {
const [showTop, setShowTop] = useState(false);
const { getStyles, getVariant } = useAllPageElementConfigs('homepage');
const headerVariant = getVariant('header', 'unified');
useEffect(() => {
const onScroll = () => {
@@ -33,11 +38,39 @@ export const MainLayout: React.FC<MainLayoutProps> = ({ children }) => {
return (
<Box minH="100vh" bg="bg.app" overflowX="hidden">
<Box id="top" position="absolute" top={0} left={0} />
<Navbar />
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Footer />
{headerInsideContainer ? (
<>
<Container maxW="container.xl" py={8}>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
)}
</Box>
{children}
</Container>
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
) : (
<>
<Box as="header" data-element="header" style={{ ...getStyles('header') }}>
{headerVariant === 'sparta_navbar' ? (
<SpartaNavbar />
) : (
<Navbar fullWidth={headerVariant === 'fullwidth'} />
)}
</Box>
<Container maxW="container.xl" py={8}>
{children}
</Container>
<Box as="footer" data-element="footer" style={{ ...getStyles('footer') }}>
<Footer />
</Box>
</>
)}
{showTop && (
<IconButton
aria-label="Zpět nahoru"
@@ -40,9 +40,9 @@ export default function NewsletterSubscribe() {
toast({
title: 'Přihlášení k odběru proběhlo úspěšně',
description: 'Děkujeme za přihlášení k odběru našeho newsletteru!',
description: 'Vytvořili jsme vám fanouškovský účet a poslali email s heslem a odkazy pro správu newsletteru.',
status: 'success',
duration: 5000,
duration: 7000,
isClosable: true,
});
reset();
@@ -77,7 +77,7 @@ export default function NewsletterSubscribe() {
Přihlaste se k odběru novinek
</Text>
<Text textAlign="center" color={textColor} mb={2}>
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu.
Budeme vás informovat o novinkách, zápasech a akcích našeho klubu. Současně pro vás vytvoříme fanouškovský účet a pošleme heslo emailem.
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
@@ -118,8 +118,7 @@ export default function NewsletterSubscribe() {
</form>
<Text fontSize="xs" color={disclaimerColor} textAlign="center" mt={2}>
Odesláním formuláře souhlasíte se zpracováním osobních údajů.
Vaši e-mailovou adresu budeme používat pouze pro zasílání novinek.
Odesláním formuláře souhlasíte se zpracováním osobních údajů. Z odběru se můžete kdykoli odhlásit a nastavení upravit v zaslaném emailu. Heslo lze změnit přes stránku pro obnovení hesla.
</Text>
</VStack>
</Box>
+148 -4
View File
@@ -15,9 +15,13 @@ import {
Image,
Heading,
useColorModeValue,
Input,
FormControl,
FormLabel,
Link,
} from '@chakra-ui/react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckIcon } from '@chakra-ui/icons';
import { CheckIcon, StarIcon } from '@chakra-ui/icons';
import {
Poll,
PollOption,
@@ -25,6 +29,9 @@ import {
getPollResults,
generateSessionToken,
} from '../../services/polls';
import { useUmami } from '../../hooks/useUmami';
import { useAuth } from '../../contexts/AuthContext';
import { Link as RouterLink, useLocation } from 'react-router-dom';
interface PollCardProps {
poll: Poll;
@@ -43,11 +50,32 @@ const PollCard: React.FC<PollCardProps> = ({
}) => {
const toast = useToast();
const queryClient = useQueryClient();
const { trackEvent } = useUmami();
const { isAuthenticated, user } = useAuth();
const location = useLocation();
const [selectedOptions, setSelectedOptions] = useState<number[]>([]);
const [hasVoted, setHasVoted] = useState(initialHasVoted);
const [canShowResults, setCanShowResults] = useState(initialCanShowResults);
const [results, setResults] = useState<any[]>([]);
const [showingResults, setShowingResults] = useState(initialCanShowResults);
const [voterName, setVoterName] = useState('');
const [voterEmail, setVoterEmail] = useState('');
const isRating = poll.type === 'rating';
const ratingOptionsSorted = [...poll.options].sort((a, b) => (a.display_order || 0) - (b.display_order || 0));
const maxRating = ratingOptionsSorted.length > 0
? Math.max(...ratingOptionsSorted.map(o => (o.display_order || 0))) || ratingOptionsSorted.length
: 5;
const [ratingValue, setRatingValue] = useState<number | null>(null);
const selectOptionForRating = (value: number) => {
setRatingValue(value);
const byOrder = ratingOptionsSorted.find(o => (o.display_order || 0) === value);
const fallback = ratingOptionsSorted[value - 1];
const opt = byOrder || fallback;
if (opt) {
setSelectedOptions([opt.id]);
}
};
const bgCard = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -60,6 +88,8 @@ const PollCard: React.FC<PollCardProps> = ({
return votePoll(poll.id, {
option_ids: selectedOptions,
session_token: sessionToken,
voter_name: isAuthenticated ? (voterName || (user as any)?.name || undefined) : undefined,
voter_email: isAuthenticated ? (voterEmail || user?.email || undefined) : undefined,
});
},
onSuccess: async () => {
@@ -85,6 +115,19 @@ const PollCard: React.FC<PollCardProps> = ({
duration: 3000,
});
// Analytics tracking (Umami + backend)
try {
trackEvent('Poll Vote', {
poll_id: poll.id,
poll_title: poll.title,
type: poll.type,
option_ids: selectedOptions,
rating: ratingValue || undefined,
});
} catch (e) {
// swallow
}
if (onVoteSuccess) {
onVoteSuccess();
}
@@ -134,6 +177,28 @@ const PollCard: React.FC<PollCardProps> = ({
}
};
const handleOptionClick = (optionId: number) => {
if (poll.allow_multiple) {
const isSelected = selectedOptions.includes(optionId);
if (isSelected) {
setSelectedOptions(selectedOptions.filter((id) => id !== optionId));
} else {
if (selectedOptions.length >= poll.max_choices) {
toast({
title: 'Příliš mnoho voleb',
description: `Můžete vybrat maximálně ${poll.max_choices} možností.`,
status: 'warning',
duration: 3000,
});
return;
}
setSelectedOptions([...selectedOptions, optionId]);
}
} else {
setSelectedOptions([optionId]);
}
};
const loadResults = async () => {
try {
const resultsData = await getPollResults(poll.id);
@@ -263,7 +328,38 @@ const PollCard: React.FC<PollCardProps> = ({
{isActive && (
<>
{poll.allow_multiple ? (
{isRating ? (
<VStack align="stretch" spacing={2}>
{maxRating <= 5 ? (
<HStack>
{Array.from({ length: maxRating }).map((_, i) => (
<StarIcon
key={i}
boxSize={6}
cursor="pointer"
color={i < (ratingValue || 0) ? 'yellow.400' : 'gray.300'}
onClick={() => selectOptionForRating(i + 1)}
/>
))}
<Text ml={2}>{ratingValue ? `${ratingValue}/${maxRating}` : 'Vyberte hodnocení'}</Text>
</HStack>
) : (
<HStack flexWrap="wrap" spacing={2}>
{Array.from({ length: maxRating }).map((_, i) => (
<Button
key={i}
size="sm"
variant={ratingValue === i + 1 ? 'solid' : 'outline'}
colorScheme="blue"
onClick={() => selectOptionForRating(i + 1)}
>
{i + 1}
</Button>
))}
</HStack>
)}
</VStack>
) : poll.allow_multiple ? (
<CheckboxGroup
value={selectedOptions.map(String)}
onChange={handleMultipleChoice}
@@ -280,8 +376,17 @@ const PollCard: React.FC<PollCardProps> = ({
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Checkbox value={String(option.id)}>
<Checkbox value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
@@ -309,8 +414,17 @@ const PollCard: React.FC<PollCardProps> = ({
borderRadius="md"
_hover={{ bg: hoverBg }}
cursor="pointer"
role="button"
tabIndex={0}
onClick={() => handleOptionClick(option.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOptionClick(option.id);
}
}}
>
<Radio value={String(option.id)}>
<Radio value={String(option.id)} onClick={(e) => e.stopPropagation()}>
<VStack align="start" spacing={1}>
<Text>{option.text}</Text>
{option.description && (
@@ -342,6 +456,36 @@ const PollCard: React.FC<PollCardProps> = ({
</RadioGroup>
)}
{isAuthenticated ? (
<VStack spacing={3} align="stretch">
<FormControl>
<FormLabel fontSize="sm">Jméno (volitelné)</FormLabel>
<Input
size="sm"
value={voterName || ((user as any)?.name || '')}
onChange={(e) => setVoterName(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">E-mail (volitelné)</FormLabel>
<Input
size="sm"
type="email"
value={voterEmail || (user?.email || '')}
onChange={(e) => setVoterEmail(e.target.value)}
/>
</FormControl>
</VStack>
) : (
<Text fontSize="sm" color="gray.500">
Chcete připojit své jméno k hlasu?{' '}
<Link as={RouterLink} color="blue.500" to="/login" state={{ from: location }}>
Přihlaste se
</Link>
.
</Text>
)}
<Button
colorScheme="blue"
onClick={handleVote}