This commit is contained in:
Tomáš Dvořák
2025-10-17 11:15:09 +02:00
parent 35d0954afd
commit 96ff7895a6
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;
+14 -24
View File
@@ -8,6 +8,7 @@ import DOMPurify from 'dompurify';
import { assetUrl } from '../utils/url';
import EventLocationMap from '../components/events/EventLocationMap';
import EmbeddedPoll from '../components/polls/EmbeddedPoll';
import FilePreview from '../components/common/FilePreview';
const ActivityDetailPage: React.FC = () => {
const { id } = useParams();
@@ -163,32 +164,21 @@ const ActivityDetailPage: React.FC = () => {
/>
)}
{/* Attachments */}
{/* Attachments with Preview */}
{(Array.isArray(data.attachments) && data.attachments.length > 0) && (
<VStack align="stretch" spacing={2}>
<VStack align="stretch" spacing={3}>
<Heading as="h3" size="sm">Přílohy</Heading>
<VStack align="stretch" spacing={2}>
{data.attachments.map((att: any, idx: number) => {
const sizeKB = typeof att.size === 'number' ? Math.round(att.size / 1024) : undefined;
const mime = String(att.mime_type || '').toLowerCase();
const isImg = mime.startsWith('image/');
return (
<HStack key={idx} justify="space-between" p={2.5} borderWidth="1px" borderColor={borderColor} borderRadius="md" bg={cardBg}>
<HStack>
<Icon as={isImg ? FiImage : FiFile} color={isImg ? 'purple.500' : 'gray.600'} />
<ChakraLink href={assetUrl(att.url) || att.url} isExternal color={linkColor} _hover={{ textDecoration: 'underline', color: linkHoverColor }}>
{att.name || att.url}
</ChakraLink>
</HStack>
<HStack>
{sizeKB && <Text fontSize="xs" color={mutedText}>{sizeKB} kB</Text>}
<Button as={ChakraLink} href={assetUrl(att.url) || att.url} isExternal size="sm" leftIcon={<FiDownload />}>
Stáhnout
</Button>
</HStack>
</HStack>
);
})}
<VStack align="stretch" spacing={3}>
{data.attachments.map((att: any, idx: number) => (
<FilePreview
key={idx}
url={att.url}
name={att.name}
mimeType={att.mime_type}
size={att.size}
showInline={att.mime_type?.startsWith('image/')}
/>
))}
</VStack>
</VStack>
)}
+11 -44
View File
@@ -1367,12 +1367,12 @@ const HomePage: React.FC = () => {
{/* Hero section: variant controlled by MyUIbrix (getVariant) or fallback to settings.hero_style */}
{getVariant('hero', heroStyle) === 'grid' && isVisible('hero', true) && (
<section data-element="hero" className="hero-grid">
{news[0] ? (
<a href={`/news/${news[0].slug || news[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url(${assetUrl(news[0].image) || '/images/news/placeholder.jpg'})` }} />
{featured[0] ? (
<a href={`/news/${featured[0].slug || featured[0].id}`} className="hero-card big" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url(${assetUrl(featured[0].image) || '/images/news/placeholder.jpg'})` }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>{news[0].category || 'Aktuality'}</div>
<h2 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>{news[0].title}</h2>
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: 'var(--text-on-primary)' }}>{featured[0].category || 'Aktuality'}</div>
<h2 style={{ margin: '4px 0 0 0', color: 'var(--text-on-primary)' }}>{featured[0].title}</h2>
</div>
</a>
) : (
@@ -1385,7 +1385,7 @@ const HomePage: React.FC = () => {
</a>
)}
<div className="small-col">
{news.slice(1, 3).map((n, idx) => (
{featured.slice(1, 3).map((n, idx) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div className="overlay">
@@ -1394,14 +1394,14 @@ const HomePage: React.FC = () => {
</div>
</a>
))}
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, news.length - 1))) }).map((_, idx) => (
<div key={`placeholder-${idx}`} className="hero-card small" style={{ pointerEvents: 'none' }}>
{Array.from({ length: Math.max(0, 2 - Math.min(2, Math.max(0, featured.length - 1))) }).map((_, idx) => (
<a key={`placeholder-${idx}`} href="/news" className="hero-card small" style={{ textDecoration: 'none' }}>
<div className="bg" style={{ backgroundImage: `url('/images/news/placeholder.jpg')`, filter: 'grayscale(50%) brightness(0.7)' }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Aktuality</div>
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>Připravujeme...</h3>
</div>
</div>
</a>
))}
</div>
</section>
@@ -1418,20 +1418,7 @@ const HomePage: React.FC = () => {
</section>
)}
{/* Featured articles grid (uses Articles.featured flag) */}
{featured.length > 0 && isVisible('news', true) && (
<section data-element="news" className="three-cols" style={{ marginTop: 8 }}>
{featured.map((n) => (
<a key={n.id} href={`/news/${n.slug || n.id}`} className="hero-card small" style={{ textDecoration: 'none', height: 220 }}>
<div className="bg" style={{ backgroundImage: `url(${assetUrl(n.image) || '/images/news/placeholder.jpg'})` }} />
<div className="overlay">
<div style={{ opacity: 0.8, fontSize: '0.8rem', color: '#fff' }}>Vybrané</div>
<h3 style={{ margin: '4px 0 0 0', color: '#fff' }}>{n.title}</h3>
</div>
</a>
))}
</section>
)}
{/* Featured articles are now shown in the hero grid above, not here */}
{/* Sidebar banners (homepage_sidebar) */}
{(banners || []).some(b => b.placement === 'homepage_sidebar') && (
@@ -1545,27 +1532,7 @@ const HomePage: React.FC = () => {
<a href="/kalendar" className="see-all">Všechny zápasy <FiArrowRight /></a>
</div>
<div className="matches-grid">
<div className="matches-track"
ref={trackRef}
onMouseDown={(e) => {
const el = e.currentTarget as HTMLDivElement;
el.dataset.dragging = '1';
el.dataset.startX = String(e.pageX - el.offsetLeft);
el.dataset.scrollLeft = String(el.scrollLeft);
}}
onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).dataset.dragging = ''; }}
onMouseUp={(e) => { (e.currentTarget as HTMLDivElement).dataset.dragging = ''; }}
onMouseMove={(e) => {
const el = e.currentTarget as HTMLDivElement;
if (el.dataset.dragging !== '1') return;
e.preventDefault();
const startX = Number(el.dataset.startX || 0);
const scrollLeft = Number(el.dataset.scrollLeft || 0);
const x = e.pageX - el.offsetLeft;
const walk = (x - startX) * 1; // scroll-fast factor
el.scrollLeft = scrollLeft - walk;
}}
>
<div className="matches-track" ref={trackRef}>
{(facrCompetitions[matchesTab]?.matches || []).map((m:any, idx:number) => {
const handleMatchClick = (e: React.MouseEvent) => {
e.preventDefault();
+10 -3
View File
@@ -13,7 +13,7 @@ import { SearchResult } from '../services/facr/types';
import { extractPalette, pickTextColor, generateJwtSecret, contrastRatio, isContrastAccessible, generateThemeCandidates, ThemeCandidate, adjustForContrast } from '../utils/colors';
import { clearToken, setHasAdmin } from '../utils/auth';
import ContactMap from '../components/home/ContactMap';
import { FONT_PAIRINGS, loadGoogleFont, getFontStyleColor } from '../config/fonts';
import { FONT_PAIRINGS, applyFontPairing, getFontStyleColor } from '../config/fonts';
import MapLinkImporter from '../components/admin/MapLinkImporter';
import MapStyleSelector from '../components/admin/MapStyleSelector';
import { MapCoordinates } from '../utils/mapUrlParser';
@@ -165,14 +165,21 @@ const SetupPage: React.FC = () => {
return () => clearTimeout(t);
}, [clubQuery, searchClubs]);
// Load selected font for preview
// Load and apply selected font for preview
useEffect(() => {
const pairing = FONT_PAIRINGS.find((f) => f.id === selectedFont);
if (pairing) {
loadGoogleFont(pairing.googleFontsUrl);
applyFontPairing(pairing);
}
}, [selectedFont]);
// Auto-fill SMTP username from contact email
useEffect(() => {
if (contactEmail && !smtpUser) {
setSmtpUser(contactEmail);
}
}, [contactEmail, smtpUser]);
const handleSelectClub = async (item: SearchResult) => {
const clubIdValue = item.club_id || '';
setClubId(clubIdValue);
@@ -57,6 +57,8 @@ import ContactMap from '../../components/home/ContactMap';
import RichTextEditor from '../../components/common/RichTextEditor';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { assetUrl } from '../../utils/url';
const types: Array<{ value: Event['type']; label: string }> = [
{ value: 'match', label: 'Zápas' },
@@ -373,6 +375,7 @@ const AdminActivitiesPage: React.FC = () => {
<Table size="sm">
<Thead>
<Tr>
<Th>Náhled</Th>
<Th>Název</Th>
<Th>Typ</Th>
<Th>Začátek</Th>
@@ -384,10 +387,28 @@ const AdminActivitiesPage: React.FC = () => {
</Thead>
<Tbody>
{isLoading && (
<Tr><Td colSpan={7}>Načítání</Td></Tr>
<Tr><Td colSpan={8}>Načítání</Td></Tr>
)}
{!isLoading && events.map(ev => (
<Tr key={ev.id}>
<Td>
{(ev as any).image_url ? (
<ThumbnailPreview
src={assetUrl((ev as any).image_url) || (ev as any).image_url}
alt={ev.title}
size="48px"
previewSize="350px"
/>
) : (
<ChakraImage
src={settingsQ.data?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="No image"
boxSize="48px"
objectFit="contain"
opacity={0.3}
/>
)}
</Td>
<Td>{ev.title}</Td>
<Td>{ev.type}</Td>
<Td>{new Date(ev.start_time).toLocaleString()}</Td>
@@ -716,89 +716,6 @@ const AnalyticsAdminPage: React.FC = () => {
</Card>
)}
{/* Pageviews Chart */}
<Card bg={bgColor} borderColor={borderColor}>
<CardHeader>
<HStack spacing={2}>
<Icon as={FiTrendingUp} color="blue.500" boxSize={5} />
<Heading size="md">Zobrazení stránek v čase</Heading>
</HStack>
</CardHeader>
<CardBody>
{loading && pageviewsData.length === 0 ? (
<Flex justify="center" py={8}>
<Spinner size="lg" />
</Flex>
) : pageviewsData.length === 0 || pageviewsData.every(d => d.value === 0) ? (
<Flex justify="center" align="center" direction="column" py={8}>
<Icon as={FiTrendingUp} color="gray.300" boxSize={12} mb={3} />
<Text color="gray.500" fontWeight="medium">Žádná data pro zobrazení</Text>
<Text color="gray.400" fontSize="sm" mt={1}>Pro vybrané časové období nejsou k dispozici žádná data o návštěvnosti</Text>
</Flex>
) : (
<Box height="300px">
<Bar
data={{
labels: pageviewsData.map(d => d.date),
datasets: [
{
label: 'Zobrazení',
data: pageviewsData.map(d => d.value),
backgroundColor: 'rgba(66, 153, 225, 0.6)',
borderColor: 'rgb(66, 153, 225)',
borderWidth: 1,
borderRadius: 4,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#ffffff',
bodyColor: '#ffffff',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return `Zobrazení: ${context.parsed.y}`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: '#718096',
},
grid: {
color: 'rgba(0, 0, 0, 0.1)',
},
},
x: {
ticks: {
color: '#718096',
},
grid: {
display: false,
},
},
},
}}
/>
</Box>
)}
</CardBody>
</Card>
<Divider />
{/* Country Flags Section */}
+151 -13
View File
@@ -22,6 +22,7 @@ import { getZoneramaManifestWithFallbacks, getZoneramaAlbum, putZoneramaPick, sa
import { facrApi } from '../../services/facr/facrApi';
import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
import PollLinker from '../../components/admin/PollLinker';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
@@ -30,8 +31,9 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
const linkQ = useQuery({
queryKey: ['article-match-link', articleId],
queryFn: () => getArticleMatchLink(articleId),
enabled: typeof articleId !== 'undefined' && articleId !== null,
enabled: typeof articleId !== 'undefined' && articleId !== null && (typeof articleId === 'number' ? articleId > 0 : String(articleId).trim() !== ''),
staleTime: 60_000,
retry: false,
});
const mid = (linkQ.data as any)?.external_match_id;
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
@@ -169,12 +171,18 @@ const ArticlesAdminPage = () => {
if (!res.ok) return;
const json = await res.json();
const comps = Array.isArray(json?.competitions) ? json.competitions : [];
const items: any[] = comps.flatMap((c: any) => (Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({
id: String(m.match_id || m.id || ''),
date: m.date_time || m.date || '',
label: `${m.date_time || m.date || ''}${m.home || m.home_team || ''} ${m.score || (m.result_home!=null&&m.result_away!=null?`${m.result_home}:${m.result_away}`:'vs')} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
competition: c?.name || ''
})));
const items: any[] = comps.flatMap((c: any) => (Array.isArray(c.matches) ? c.matches : []).map((m: any) => {
const score = m.score || (m.result_home!=null&&m.result_away!=null?`${m.result_home}:${m.result_away}`:'vs');
return {
id: String(m.match_id || m.id || ''),
date: m.date_time || m.date || '',
label: `${m.date_time || m.date || ''}${m.home || m.home_team || ''} ${score} ${m.away || m.away_team || ''} ${c?.name ? '('+c.name+')' : ''}`.trim(),
competition: c?.name || '',
home: m.home || m.home_team || '',
away: m.away || m.away_team || '',
score: score
};
}));
// keep latest 200 for performance
setMatchOptions(items.slice(-200).reverse());
} catch { /* ignore */ }
@@ -207,7 +215,7 @@ const ArticlesAdminPage = () => {
const [linkedMatchId, setLinkedMatchId] = useState<string>('');
const [linkedMatchTitle, setLinkedMatchTitle] = useState<string>('');
const [matchIdInput, setMatchIdInput] = useState<string>('');
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string }>>([]);
const [matchOptions, setMatchOptions] = useState<Array<{ id: string; label: string; date?: string; competition?: string; home?: string; away?: string; score?: string }>>([]);
const [matchSearch, setMatchSearch] = useState<string>('');
const [matchDateFilter, setMatchDateFilter] = useState<string>('');
const [tempMatchLink, setTempMatchLink] = useState<string>(''); // Temporary storage for new articles
@@ -218,12 +226,40 @@ const ArticlesAdminPage = () => {
const [zLoading, setZLoading] = useState<boolean>(false);
const [albumPickerOpen, setAlbumPickerOpen] = useState<boolean>(false);
const { isOpen: isAlbumPickerOpen, onOpen: onAlbumPickerOpen, onClose: onAlbumPickerClose } = useDisclosure();
const { isOpen: isGalleryPickerOpen, onOpen: onGalleryPickerOpen, onClose: onGalleryPickerClose } = useDisclosure();
const [cachedAlbums, setCachedAlbums] = useState<Array<{ id: string; date: string; title?: string; photos: Array<{ id: string; image_1500: string; page_url: string }> }>>([]);
const [galleryLoading, setGalleryLoading] = useState<boolean>(false);
const [youtubeVideos, setYoutubeVideos] = useState<YouTubeVideo[]>([]);
const [youtubeLoading, setYoutubeLoading] = useState<boolean>(false);
const [youtubeSearch, setYoutubeSearch] = useState<string>('');
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
// Fetch cached Zonerama gallery from prefetch
const fetchCachedGallery = useCallback(async () => {
try {
setGalleryLoading(true);
const apiUrl = process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const origin = new URL(apiUrl).origin;
const url = `${origin}/cache/prefetch/zonerama_profile.json`;
const res = await fetch(url, { cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to load gallery cache');
const data = await res.json();
const albums = Array.isArray(data?.albums) ? data.albums : [];
// Filter albums with photos
const validAlbums = albums.filter((a: any) => Array.isArray(a.photos) && a.photos.length > 0);
setCachedAlbums(validAlbums);
if (validAlbums.length === 0) {
toast({ title: 'Žádné alba nenalezena', description: 'Cache galerie je prázdná nebo neobsahuje fotografie.', status: 'info', duration: 4000 });
}
} catch (e: any) {
toast({ title: 'Načtení galerie selhalo', description: e?.message || 'Zkuste to prosím znovu.', status: 'error' });
} finally {
setGalleryLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Remove toast from dependencies to prevent infinite loops
const fetchYouTubeVideos = useCallback(async () => {
try {
@@ -251,6 +287,12 @@ const ArticlesAdminPage = () => {
}
}, [isYouTubeModalOpen, youtubeVideos.length, youtubeLoading, fetchYouTubeVideos]);
React.useEffect(() => {
if (isGalleryPickerOpen && cachedAlbums.length === 0 && !galleryLoading) {
fetchCachedGallery();
}
}, [isGalleryPickerOpen, cachedAlbums.length, galleryLoading, fetchCachedGallery]);
const filteredYoutubeVideos = useMemo(() => {
const q = youtubeSearch.trim().toLowerCase();
if (!q) return youtubeVideos;
@@ -943,7 +985,12 @@ const ArticlesAdminPage = () => {
{!isLoading && articles.map((a) => (
<Tr key={a.id}>
<Td>
<Image src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'} alt={a.title} boxSize="48px" objectFit="cover" />
<ThumbnailPreview
src={assetUrl(a.image_url) || '/dist/img/logo-club-empty.svg'}
alt={a.title}
size="48px"
previewSize="350px"
/>
</Td>
<Td>{a.title}</Td>
<Td>{a.published ? 'Ano' : 'Ne'}</Td>
@@ -1156,11 +1203,12 @@ const ArticlesAdminPage = () => {
minute: '2-digit'
}) : '';
// Parse match info from label
const parts = match.label.split('');
const teams = parts[1]?.split(/\(|vs/)[0]?.trim() || '';
const score = teams.match(/\d+:\d+/)?.[0] || 'vs';
// Use match data directly
const home = match.home || '';
const away = match.away || '';
const score = match.score || 'vs';
const hasScore = score !== 'vs';
const teams = `${home} ${score} ${away}`.trim();
return (
<Box
@@ -1275,6 +1323,7 @@ const ArticlesAdminPage = () => {
</FormControl>
<HStack>
<Button size="sm" onClick={fetchAlbumByLink} isLoading={zLoading}>Načíst album</Button>
<Button size="sm" colorScheme="purple" onClick={onGalleryPickerOpen}>Vybrat z galerie</Button>
{zAlbumLink ? (
<Button size="sm" as="a" href={zAlbumLink} target="_blank" rel="noopener noreferrer" rightIcon={<FiExternalLink />}>Otevřít album</Button>
) : null}
@@ -1561,6 +1610,95 @@ const ArticlesAdminPage = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Zonerama Gallery Picker Modal */}
<Modal isOpen={isGalleryPickerOpen} onClose={onGalleryPickerClose} size="6xl">
<ModalOverlay />
<ModalContent maxH="90vh">
<ModalHeader>Vybrat fotku z galerie</ModalHeader>
<ModalCloseButton />
<ModalBody overflowY="auto">
<VStack align="stretch" spacing={4}>
{/* Loading State */}
{galleryLoading && (
<HStack spacing={2} justify="center" py={8}>
<Spinner size="lg" color="purple.500" />
<Text color="gray.600">Načítám alba z galerie...</Text>
</HStack>
)}
{/* Albums Grid */}
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
<Text fontSize="sm" color="gray.500">{album.date} {album.photos.length} fotografií</Text>
</VStack>
</HStack>
<SimpleGrid columns={{ base: 3, md: 4, lg: 6 }} spacing={2}>
{album.photos.map((photo) => (
<Box
key={photo.id}
borderWidth="1px"
borderRadius="md"
overflow="hidden"
cursor="pointer"
transition="all 0.2s"
_hover={{ boxShadow: 'lg', transform: 'scale(1.05)' }}
onClick={() => {
pickZoneramaImage({
id: photo.id,
album_id: album.id,
album_url: `https://eu.zonerama.com/FKKofolaKrnov/Album/${album.id}`,
page_url: photo.page_url,
image_url: photo.image_1500,
title: album.title
});
onGalleryPickerClose();
}}
>
<AspectRatio ratio={1}>
<Image
src={photo.image_1500}
alt={photo.id}
objectFit="cover"
/>
</AspectRatio>
</Box>
))}
</SimpleGrid>
</Box>
))}
</VStack>
)}
{/* Empty State */}
{!galleryLoading && cachedAlbums.length === 0 && (
<VStack py={8} spacing={3}>
<Icon as={FiSearch} boxSize={12} color="gray.400" />
<Text color="gray.600" textAlign="center">
Žádná alba nebyla nalezena v cache.
</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Zkontrolujte nastavení Zonerama nebo obnovte cache.
</Text>
<Button size="sm" onClick={fetchCachedGallery} leftIcon={<FiRefreshCcw />}>
Obnovit seznam
</Button>
</VStack>
)}
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onGalleryPickerClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
};
+114 -9
View File
@@ -59,6 +59,7 @@ import {
getDuplicateFiles,
deleteFile,
scanAndSyncFiles,
refreshFileTracking,
formatFileSize,
getFileIcon,
} from '../../services/files';
@@ -72,10 +73,12 @@ const FilesAdminPage: React.FC = () => {
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState<any>(null);
const [refreshResult, setRefreshResult] = useState<any>(null);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const { isOpen: isScanResultOpen, onOpen: onScanResultOpen, onClose: onScanResultClose } = useDisclosure();
const { isOpen: isRefreshResultOpen, onOpen: onRefreshResultOpen, onClose: onRefreshResultClose } = useDisclosure();
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgHover = useColorModeValue('gray.50', 'gray.700');
@@ -145,6 +148,21 @@ const FilesAdminPage: React.FC = () => {
},
});
// Refresh tracking mutation
const refreshTrackingMutation = useMutation({
mutationFn: refreshFileTracking,
onSuccess: (data) => {
setRefreshResult(data);
onRefreshResultOpen();
qc.invalidateQueries({ queryKey: ['admin-files'] });
qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
},
onError: () => {
toast({ title: 'Chyba při aktualizaci sledování', status: 'error' });
},
});
const handleDelete = (file: FileInfo) => {
setDeleteTarget(file);
setForceDelete(false);
@@ -266,15 +284,27 @@ const FilesAdminPage: React.FC = () => {
<VStack align="stretch" spacing={6}>
<HStack justify="space-between">
<Heading size="lg">Správa souborů</Heading>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
colorScheme="blue"
size="sm"
>
Skenovat soubory
</Button>
<HStack spacing={2}>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => refreshTrackingMutation.mutate(undefined)}
isLoading={refreshTrackingMutation.isPending}
colorScheme="green"
size="sm"
variant="outline"
>
Aktualizovat sledování
</Button>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
colorScheme="blue"
size="sm"
>
Skenovat soubory
</Button>
</HStack>
</HStack>
<Tabs colorScheme="blue" variant="enclosed">
@@ -657,6 +687,81 @@ const FilesAdminPage: React.FC = () => {
</ModalFooter>
</ModalContent>
</Modal>
{/* Refresh Tracking Result Modal */}
<Modal isOpen={isRefreshResultOpen} onClose={onRefreshResultClose} size="lg">
<ModalOverlay />
<ModalContent>
<ModalHeader>Výsledky aktualizace sledování</ModalHeader>
<ModalCloseButton />
<ModalBody>
{refreshResult && (
<VStack align="stretch" spacing={4}>
<Alert status="success">
<AlertIcon />
<Box>
<AlertTitle>Sledování aktualizováno!</AlertTitle>
<AlertDescription>
{refreshResult.message}
</AlertDescription>
</Box>
</Alert>
<Stack spacing={3}>
<Text fontWeight="bold" fontSize="lg" mb={2}>Statistiky:</Text>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Články:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.articles_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Aktivity:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.events_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Hráči:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.players_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Sponzoři:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.sponsors_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Kontakty:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.contacts_scanned}
</Badge>
</HStack>
<HStack justify="space-between" p={3} borderWidth="1px" borderRadius="md">
<Text fontWeight="medium">Týmy:</Text>
<Badge colorScheme="blue" fontSize="md" px={3} py={1}>
{refreshResult.stats.teams_scanned}
</Badge>
</HStack>
</Stack>
</VStack>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" onClick={onRefreshResultClose}>
Zavřít
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</AdminLayout>
);
};
+126 -10
View File
@@ -482,6 +482,10 @@ const MatchesAdminPage = () => {
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const [lastX, setLastX] = useState(0);
const [lastTime, setLastTime] = useState(0);
const velocityRef = useRef(0);
const animationRef = useRef<number | null>(null);
// Color modes for past/future matches
const pastMatchBg = useColorModeValue('gray.100', 'gray.700');
@@ -499,11 +503,20 @@ const MatchesAdminPage = () => {
// Drag-to-scroll handlers
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
// Cancel any ongoing momentum animation
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
setIsDragging(true);
setStartX(e.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(e.pageX);
setLastTime(Date.now());
velocityRef.current = 0;
scrollRef.current.style.cursor = 'grabbing';
scrollRef.current.style.userSelect = 'none';
scrollRef.current.style.scrollBehavior = 'auto'; // Disable smooth scroll during drag
};
const handleMouseLeave = () => {
@@ -519,6 +532,24 @@ const MatchesAdminPage = () => {
if (scrollRef.current) {
scrollRef.current.style.cursor = 'grab';
scrollRef.current.style.userSelect = 'auto';
scrollRef.current.style.scrollBehavior = 'smooth';
// Apply momentum scrolling
const velocity = velocityRef.current;
if (Math.abs(velocity) > 0.5) {
const applyMomentum = () => {
if (!scrollRef.current) return;
velocityRef.current *= 0.95; // Deceleration factor
scrollRef.current.scrollLeft -= velocityRef.current;
if (Math.abs(velocityRef.current) > 0.5) {
animationRef.current = requestAnimationFrame(applyMomentum);
} else {
animationRef.current = null;
}
};
animationRef.current = requestAnimationFrame(applyMomentum);
}
}
};
@@ -526,8 +557,77 @@ const MatchesAdminPage = () => {
if (!isDragging || !scrollRef.current) return;
e.preventDefault();
const x = e.pageX - scrollRef.current.offsetLeft;
const walk = (x - startX) * 2; // Scroll speed multiplier
const walk = (x - startX) * 1.5; // Scroll speed multiplier (reduced for smoother feel)
scrollRef.current.scrollLeft = scrollLeft - walk;
// Calculate velocity for momentum
const now = Date.now();
const timeDelta = now - lastTime;
if (timeDelta > 0) {
const currentX = e.pageX;
const distance = currentX - lastX;
velocityRef.current = distance / timeDelta * 16; // Normalize to ~60fps
setLastX(currentX);
setLastTime(now);
}
};
// Touch handlers for mobile
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
if (!scrollRef.current) return;
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
const touch = e.touches[0];
setIsDragging(true);
setStartX(touch.pageX - scrollRef.current.offsetLeft);
setScrollLeft(scrollRef.current.scrollLeft);
setLastX(touch.pageX);
setLastTime(Date.now());
velocityRef.current = 0;
if (scrollRef.current) scrollRef.current.style.scrollBehavior = 'auto';
};
const handleTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
if (!isDragging || !scrollRef.current) return;
const touch = e.touches[0];
const x = touch.pageX - scrollRef.current.offsetLeft;
const walk = (x - startX) * 1.5;
scrollRef.current.scrollLeft = scrollLeft - walk;
const now = Date.now();
const timeDelta = now - lastTime;
if (timeDelta > 0) {
const currentX = touch.pageX;
const distance = currentX - lastX;
velocityRef.current = distance / timeDelta * 16;
setLastX(currentX);
setLastTime(now);
}
};
const handleTouchEnd = () => {
setIsDragging(false);
if (scrollRef.current) {
scrollRef.current.style.scrollBehavior = 'smooth';
const velocity = velocityRef.current;
if (Math.abs(velocity) > 0.5) {
const applyMomentum = () => {
if (!scrollRef.current) return;
velocityRef.current *= 0.95;
scrollRef.current.scrollLeft -= velocityRef.current;
if (Math.abs(velocityRef.current) > 0.5) {
animationRef.current = requestAnimationFrame(applyMomentum);
} else {
animationRef.current = null;
}
};
animationRef.current = requestAnimationFrame(applyMomentum);
}
}
};
// Utility to check if match is in the past
@@ -551,7 +651,13 @@ const MatchesAdminPage = () => {
updateScrollShadow();
const onResize = () => updateScrollShadow();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
// Cleanup momentum animation on unmount
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, []);
const headerBg = useColorModeValue('brand.primary', 'gray.700');
@@ -656,8 +762,8 @@ const MatchesAdminPage = () => {
</WrapItem>
</Wrap>
{showScrollHint && (
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2}>
💡 Tip: Tabulku můžete posouvat tažením myší nebo touchem
<Text fontSize="xs" color="blue.600" fontWeight="600" mb={2} display="flex" alignItems="center" gap={1}>
💡 Tip: Tabulku můžete plynule posouvat tažením myší nebo prstem
</Text>
)}
<Box
@@ -676,23 +782,33 @@ const MatchesAdminPage = () => {
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onScroll={(e) => {
updateScrollShadow();
if ((e.currentTarget as HTMLDivElement).scrollLeft > 0 && showScrollHint) setShowScrollHint(false);
}}
sx={{
WebkitOverflowScrolling: 'touch',
scrollBehavior: 'smooth',
'th, td': { whiteSpace: 'nowrap' },
'::-webkit-scrollbar': { height: '12px' },
'::-webkit-scrollbar': { height: '14px' },
'::-webkit-scrollbar-thumb': {
background: '#3182ce',
borderRadius: '8px',
'&:hover': { background: '#2c5aa0' }
borderRadius: '10px',
border: '3px solid transparent',
backgroundClip: 'content-box',
transition: 'background 0.2s ease',
'&:hover': { background: '#2c5aa0', backgroundClip: 'content-box' },
'&:active': { background: '#2a4e8a', backgroundClip: 'content-box' }
},
'::-webkit-scrollbar-track': {
background: '#e2e8f0',
borderRadius: '8px',
margin: '0 4px'
background: useColorModeValue('#f7fafc', '#2d3748'),
borderRadius: '10px',
margin: '0 8px',
border: '1px solid',
borderColor: useColorModeValue('#e2e8f0', '#4a5568')
},
}}
>
@@ -40,6 +40,7 @@ import AdminLayout from '../../layouts/AdminLayout';
import { Player, getPlayers, createPlayer, updatePlayer, deletePlayer } from '../../services/players';
import { uploadFile } from '../../services/articles';
import { translateNationality } from '../../utils/nationality';
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
type Editing = Partial<Player> & { id?: number };
@@ -337,7 +338,13 @@ const PlayersAdminPage: React.FC = () => {
{!isLoading && (data || []).map((p) => (
<Tr key={p.id}>
<Td>
<Image src={normalizeImageUrl(p.image_url)} alt={p.first_name} boxSize="48px" objectFit="cover" borderRadius="md" />
<ThumbnailPreview
src={normalizeImageUrl(p.image_url)}
alt={`${p.first_name} ${p.last_name}`}
size="48px"
previewSize="300px"
borderRadius="md"
/>
</Td>
<Td>{p.first_name} {p.last_name}</Td>
<Td>{p.position || '-'}</Td>
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -808,27 +808,28 @@ html {
display: flex;
gap: 18px;
overflow-x: auto;
padding: 8px 2px 12px 2px;
cursor: grab;
padding: 8px 2px 16px 2px;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
.matches-slider .matches-track::-webkit-scrollbar {
height: 8px;
height: 12px;
}
.matches-slider .matches-track::-webkit-scrollbar-track {
background: var(--bg-soft);
border-radius: 999px;
margin: 0 12px;
}
.matches-slider .matches-track::-webkit-scrollbar-thumb {
background: var(--light-gray);
background: linear-gradient(90deg, var(--primary), color-mix(in srgb, var(--primary) 80%, var(--secondary) 20%));
border-radius: 999px;
transition: background 0.2s;
transition: all 0.3s ease;
border: 2px solid var(--bg-soft);
}
.matches-slider .matches-track::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--primary) 40%, var(--light-gray));
background: linear-gradient(90deg, color-mix(in srgb, var(--primary) 120%, #000), var(--primary));
transform: scaleY(1.1);
}
.matches-slider .matches-track:active { cursor: grabbing; }
.match-card {
flex: 0 0 auto;
min-width: 340px;
+19
View File
@@ -100,6 +100,25 @@ export const scanAndSyncFiles = async (): Promise<{
return response.data;
};
export const refreshFileTracking = async (entityType?: string): Promise<{
message: string;
stats: {
articles_scanned: number;
events_scanned: number;
players_scanned: number;
sponsors_scanned: number;
contacts_scanned: number;
teams_scanned: number;
settings_scanned: number;
};
}> => {
const response = await axios.post(`${API_URL}/admin/files/refresh-tracking`, {}, {
params: entityType ? { entity_type: entityType } : {},
withCredentials: true,
});
return response.data;
};
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
+1 -1
View File
@@ -72,7 +72,7 @@ export function parseGoogleMapsUrl(url: string): MapCoordinates | null {
const urlObj = new URL(url);
// Check if it's a Google Maps domain
if (!urlObj.hostname.includes('google.com')) {
if (!urlObj.hostname.includes('google.com') && !urlObj.hostname.includes('google.cz')) {
return null;
}