mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #63
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user