This commit is contained in:
Tomas Dvorak
2025-10-17 17:39:11 +02:00
parent 35d0954afd
commit e9a63073e5
61 changed files with 3824 additions and 1061 deletions
@@ -142,13 +142,8 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
onChange={handleUrlChange}
size="md"
/>
<FormHelperText>
Podporované formáty:
<Text as="span" fontWeight="semibold" ml={1}>mapy.cz</Text> (mapy.com/en/letecka?x=...&y=...),
<Text as="span" fontWeight="semibold" ml={1}>Google Maps</Text> (google.com/maps/place/@lat,lng,zoom)
</FormHelperText>
<HStack mt={2} spacing={3} fontSize="sm">
<Text color="gray.600">Quick links:</Text>
<Text color="gray.600">Rychlé odkazy:</Text>
<Link
href="https://mapy.com/cs/"
isExternal
@@ -311,27 +306,6 @@ const MapLinkImporter: React.FC<MapLinkImporterProps> = ({
</>
)}
{/* Example URLs */}
<Box
bg={bgColor}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
fontSize="sm"
>
<Text fontWeight="semibold" mb={2}>Příklady podporovaných URL:</Text>
<VStack align="start" spacing={1}>
<Text fontSize="xs" color="gray.600">
<strong>Mapy.cz:</strong><br />
mapy.cz/en/letecka?x=17.6996859&y=50.0947150&z=19
</Text>
<Text fontSize="xs" color="gray.600">
<strong>Google Maps:</strong><br />
google.com/maps/place/@50.0948669,17.7001456,226m
</Text>
</VStack>
</Box>
</VStack>
);
};
@@ -71,7 +71,6 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}) => {
const toast = useToast();
const quillRef = useRef<ReactQuill | null>(null);
const [editorMode, setEditorMode] = useState<'rich' | 'html'>('rich');
// Crop modal state
const [cropOpen, setCropOpen] = useState(false);
@@ -113,8 +112,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['link', 'image', 'video'],
['blockquote', 'code-block'],
['link', 'image'],
['blockquote'],
['clean'],
],
basic: [
@@ -369,11 +368,22 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}
}
// Show toolbar and position it
// Show toolbar and position it above the image
const rect = img.getBoundingClientRect();
const editorRect = editor.root.getBoundingClientRect();
const scrollTop = editor.root.scrollTop;
const toolbarHeight = 400; // Approximate toolbar height
// Calculate position relative to editor, accounting for scroll
let topPos = rect.top - editorRect.top + scrollTop - 60;
// If toolbar would go above visible area, position it below the image
if (topPos < scrollTop) {
topPos = rect.bottom - editorRect.top + scrollTop + 10;
}
setToolbarPosition({
top: rect.top - editorRect.top - 50,
top: topPos,
left: rect.left - editorRect.left,
});
setShowImageToolbar(true);
@@ -533,55 +543,52 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
return (
<Box>
{/* Editor Controls */}
{!readOnly && (
<HStack mb={2} spacing={2} justify="space-between" flexWrap="wrap">
<ButtonGroup size="sm" isAttached variant="outline">
<Button
leftIcon={<Type size={16} />}
variant={editorMode === 'rich' ? 'solid' : 'outline'}
colorScheme={editorMode === 'rich' ? 'blue' : 'gray'}
onClick={() => setEditorMode('rich')}
>
Editor
</Button>
<Button
leftIcon={<Code size={16} />}
variant={editorMode === 'html' ? 'solid' : 'outline'}
colorScheme={editorMode === 'html' ? 'blue' : 'gray'}
onClick={() => setEditorMode('html')}
>
HTML
</Button>
</ButtonGroup>
{editorMode === 'rich' && onImageUpload && (
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
)}
{!readOnly && onImageUpload && (
<HStack mb={2} spacing={2} justify="flex-start" flexWrap="wrap">
<Button
size="sm"
leftIcon={<ImageIcon size={16} />}
colorScheme="purple"
onClick={handleImageUpload}
>
Vložit obrázek
</Button>
<Text fontSize="xs" color="gray.500">
nebo použijte tlačítko obrázku v nástrojové liště
</Text>
</HStack>
)}
{editorMode === 'rich' ? (
<Box
position="relative"
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={bgColor}
sx={{
<Box
position="relative"
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
overflow="hidden"
bg={bgColor}
sx={{
'.ql-toolbar': {
borderBottom: '1px solid',
borderColor: borderColor,
bg: hoverBg,
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '12px',
'& button': {
color: 'gray.700 !important',
width: '32px !important',
height: '32px !important',
borderRadius: '6px',
transition: 'all 0.2s',
'&:hover': {
background: 'rgba(49, 130, 206, 0.1) !important',
transform: 'scale(1.05)',
},
'&.ql-active': {
background: 'rgba(49, 130, 206, 0.2) !important',
color: '#3182ce !important',
},
},
'& .ql-stroke': {
stroke: 'gray.700 !important',
@@ -589,6 +596,29 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
'& .ql-fill': {
fill: 'gray.700 !important',
},
'& .ql-active .ql-stroke': {
stroke: '#3182ce !important',
},
'& .ql-active .ql-fill': {
fill: '#3182ce !important',
},
'& .ql-picker': {
color: 'gray.700 !important',
},
'& .ql-picker-label': {
borderRadius: '6px',
padding: '4px 8px',
transition: 'all 0.2s',
'&:hover': {
background: 'rgba(49, 130, 206, 0.1) !important',
},
},
'& .ql-picker-options': {
background: 'white',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
padding: '8px',
},
},
'.ql-container': {
fontSize: '16px',
@@ -601,6 +631,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
overflowY: 'auto',
bg: 'white !important',
color: 'gray.800 !important',
padding: '16px',
lineHeight: '1.6',
'&::-webkit-scrollbar': {
width: '8px',
},
@@ -611,6 +643,27 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
bg: 'gray.400',
borderRadius: '4px',
},
'h1': {
fontSize: '2em !important',
fontWeight: 'bold !important',
marginTop: '0.67em !important',
marginBottom: '0.67em !important',
lineHeight: '1.2 !important',
},
'h2': {
fontSize: '1.5em !important',
fontWeight: 'bold !important',
marginTop: '0.83em !important',
marginBottom: '0.83em !important',
lineHeight: '1.3 !important',
},
'h3': {
fontSize: '1.17em !important',
fontWeight: 'bold !important',
marginTop: '1em !important',
marginBottom: '1em !important',
lineHeight: '1.4 !important',
},
img: {
cursor: 'pointer',
maxWidth: '100%',
@@ -652,26 +705,8 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
}}
/>
</Box>
) : (
<Box
as="textarea"
value={value}
onChange={(e: any) => onChange(e.target.value)}
fontFamily="mono"
fontSize="sm"
p={4}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
bg={bgColor}
resize="vertical"
minH={height}
maxH="70vh"
width="100%"
/>
)}
{!readOnly && editorMode === 'rich' && (
{!readOnly && (
<Text fontSize="xs" color="gray.500" mt={2}>
💡 Tip: Klikněte na obrázek pro výběr a úpravu. Používejte nástrojovou lištu pro filtry a transformace.
</Text>
@@ -684,14 +719,16 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
top={`${toolbarPosition.top}px`}
left={`${toolbarPosition.left}px`}
bg={toolbarBg}
borderWidth="1px"
borderColor={toolbarBorder}
borderWidth="2px"
borderColor="blue.400"
borderRadius="lg"
boxShadow="lg"
p={3}
zIndex={1500}
minW="320px"
maxW="400px"
boxShadow="2xl"
p={4}
zIndex={9999}
minW="340px"
maxW="420px"
pointerEvents="auto"
onClick={(e) => e.stopPropagation()}
>
<VStack align="stretch" spacing={3}>
{/* Toolbar Header */}
@@ -894,7 +931,7 @@ const CustomRichEditor: React.FC<CustomRichEditorProps> = ({
>
<img
ref={imgRef as any}
src={cropSrc}
src={cropSrc || ''}
alt="Crop preview"
style={{
maxWidth: '100%',
@@ -0,0 +1,330 @@
import React, { useState } 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,
FiFile,
FiFileText,
FiImage,
FiVideo,
FiMusic,
} from 'react-icons/fi';
import { assetUrl } from '../../utils/url';
export interface FilePreviewProps {
url: string;
name?: string;
mimeType?: string;
size?: number;
showInline?: boolean;
}
const FilePreview: React.FC<FilePreviewProps> = ({
url,
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';
const mime = mimeType.toLowerCase();
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 = () => {
if (mime.startsWith('image/')) {
return { type: 'image', icon: FiImage, color: 'purple.500', canPreview: true };
}
if (mime === 'application/pdf') {
return { type: 'pdf', icon: FiFileText, color: 'red.500', canPreview: true };
}
if (mime.startsWith('video/')) {
return { type: 'video', icon: FiVideo, color: 'pink.500', canPreview: true };
}
if (mime.startsWith('audio/')) {
return { type: 'audio', icon: FiMusic, color: 'green.500', canPreview: true };
}
if (mime.includes('word') || mime.includes('document')) {
return { type: 'document', icon: FiFileText, color: 'blue.500', canPreview: false };
}
if (mime.includes('sheet') || mime.includes('excel')) {
return { type: 'spreadsheet', icon: FiFile, color: 'green.600', canPreview: false };
}
if (mime.includes('presentation') || mime.includes('powerpoint')) {
return { type: 'presentation', icon: FiFile, color: 'orange.500', canPreview: false };
}
return { type: 'other', icon: FiFile, color: 'gray.500', canPreview: false };
};
const fileInfo = getFileInfo();
const sizeKB = typeof size === 'number' ? Math.round(size / 1024) : undefined;
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') {
return (
<AspectRatio ratio={8.5 / 11} w="100%" minH="70vh">
<iframe
src={`${fullUrl}#view=FitH`}
title={fileName}
style={{ border: 'none', width: '100%', height: '100%' }}
/>
</AspectRatio>
);
}
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
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"
>
Stáhnout
</Button>
</HStack>
</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>
</>
);
};
export default FilePreview;
@@ -0,0 +1,82 @@
import React from 'react';
import {
Box,
Image,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
useColorModeValue,
Portal,
} from '@chakra-ui/react';
export interface ThumbnailPreviewProps {
src: string;
alt: string;
size?: string;
previewSize?: string;
borderRadius?: string;
objectFit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
}
/**
* ThumbnailPreview - Small thumbnail image with hover to show larger preview
* Perfect for admin table rows where you want to see images without clicking
*/
const ThumbnailPreview: React.FC<ThumbnailPreviewProps> = ({
src,
alt,
size = '48px',
previewSize = '300px',
borderRadius = 'md',
objectFit = 'cover',
}) => {
const borderColor = useColorModeValue('gray.200', 'gray.700');
const bgColor = useColorModeValue('white', 'gray.800');
return (
<Popover trigger="hover" placement="right" openDelay={200} closeDelay={100}>
<PopoverTrigger>
<Box
cursor="pointer"
transition="all 0.2s"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
>
<Image
src={src}
alt={alt}
boxSize={size}
objectFit={objectFit}
borderRadius={borderRadius}
borderWidth="1px"
borderColor={borderColor}
loading="lazy"
/>
</Box>
</PopoverTrigger>
<Portal>
<PopoverContent
width={previewSize}
borderColor={borderColor}
boxShadow="2xl"
bg={bgColor}
_focus={{ boxShadow: '2xl' }}
>
<PopoverBody p={0}>
<Image
src={src}
alt={`${alt} - preview`}
width="100%"
maxH="400px"
objectFit="contain"
borderRadius="md"
loading="lazy"
/>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};
export default ThumbnailPreview;
@@ -118,6 +118,9 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const clubTheme = useClubTheme();
// Early return if not admin - MUST be before any other hooks
if (!isAdmin) return null;
const [isEditing, setIsEditing] = useState(false);
const [configs, setConfigs] = useState<PageElementConfig[]>([]);
const [localChanges, setLocalChanges] = useState<Record<string, string>>({});
@@ -148,7 +151,6 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
// Auto-activate editing mode if URL parameter is present
useEffect(() => {
if (!isAdmin) return;
const params = new URLSearchParams(window.location.search);
if (params.get('myuibrix') === 'edit') {
setIsEditing(true);
@@ -164,12 +166,11 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
isClosable: true,
});
}
}, [isAdmin, toast]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Load configurations
useEffect(() => {
if (!isAdmin) return;
const loadConfigs = async () => {
try {
const data = await getPageElementConfigs(pageType);
@@ -225,7 +226,7 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
};
loadConfigs();
}, [pageType, isAdmin]);
}, [pageType]);
// Keyboard shortcuts
useEffect(() => {
@@ -630,8 +631,6 @@ const MyUIbrixStyleEditor: React.FC<MyUIbrixStyleEditorProps> = ({ pageType, onC
}
};
if (!isAdmin) return null;
const currentVariants = selectedElement ? ELEMENT_VARIANTS[selectedElement] : [];
const currentVariant = selectedElement ? (localChanges[selectedElement] || currentVariants[0]?.value) : null;