Files
MyClub/frontend/src/pages/admin/MediaAdminPage.tsx
T
Tomáš Dvořák 12cba639b9 upload
2025-10-16 13:32:05 +02:00

600 lines
20 KiB
TypeScript

import {
Box,
Button,
Heading,
HStack,
IconButton,
Image,
Input,
InputGroup,
InputLeftElement,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
SimpleGrid,
useDisclosure,
useToast,
VStack,
Text,
Badge,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
useColorModeValue,
Tooltip,
Select,
Flex,
Spacer,
Stack,
AspectRatio,
Divider,
Code,
Skeleton,
} from '@chakra-ui/react';
import { useState, useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FiTrash2, FiSearch, FiRefreshCw, FiCopy, FiUpload, FiImage, FiVideo, FiFile, FiDownload, FiExternalLink } from 'react-icons/fi';
import AdminLayout from '../../layouts/AdminLayout';
import {
FileInfo,
getAllFiles,
deleteFile,
scanAndSyncFiles,
formatFileSize,
} from '../../services/files';
import { uploadFile } from '../../services/articles';
const MediaAdminPage: React.FC = () => {
const toast = useToast();
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<'all' | 'images' | 'videos' | 'documents'>('all');
const [selectedFile, setSelectedFile] = useState<FileInfo | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FileInfo | null>(null);
const [uploadFiles, setUploadFiles] = useState<FileList | null>(null);
const [uploading, setUploading] = useState(false);
const { isOpen: isDetailOpen, onOpen: onDetailOpen, onClose: onDetailClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
const { isOpen: isUploadOpen, onOpen: onUploadOpen, onClose: onUploadClose } = useDisclosure();
const borderColor = useColorModeValue('gray.200', 'gray.600');
const bgHover = useColorModeValue('gray.50', 'gray.700');
const cardBg = useColorModeValue('white', 'gray.800');
// Build MIME filter based on type
const mimeFilter = useMemo(() => {
if (typeFilter === 'images') return 'image/';
if (typeFilter === 'videos') return 'video/';
if (typeFilter === 'documents') return 'application/';
return '';
}, [typeFilter]);
// Fetch all files
const { data: allFiles = [], isLoading, refetch } = useQuery({
queryKey: ['admin-media-files', search, mimeFilter],
queryFn: () => getAllFiles({ search, mime_type: mimeFilter }),
});
// Filter files by type
const filteredFiles = useMemo(() => {
return allFiles.filter(file => {
if (search && !file.filename?.toLowerCase().includes(search.toLowerCase())) {
return false;
}
return true;
});
}, [allFiles, search]);
// Separate by media type
const imageFiles = filteredFiles.filter(f => f.mime_type?.startsWith('image/'));
const videoFiles = filteredFiles.filter(f => f.mime_type?.startsWith('video/'));
const documentFiles = filteredFiles.filter(f =>
f.mime_type?.startsWith('application/') || f.mime_type?.startsWith('text/')
);
// Delete mutation
const deleteMutation = useMutation({
mutationFn: ({ id, force }: { id: number; force: boolean }) => deleteFile(id, force),
onSuccess: () => {
toast({ title: 'Soubor smazán', status: 'success' });
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
onDeleteClose();
setDeleteTarget(null);
},
onError: (error: any) => {
toast({
title: 'Chyba při mazání',
description: error?.response?.data?.error || 'Nepodařilo se smazat soubor',
status: 'error',
});
},
});
// Scan mutation
const scanMutation = useMutation({
mutationFn: scanAndSyncFiles,
onSuccess: (data) => {
toast({
title: 'Skenování dokončeno',
description: `Přidáno: ${data.new_files || 0}, Smazáno: ${data.orphaned_files || 0}`,
status: 'success'
});
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
},
onError: () => {
toast({ title: 'Chyba při skenování', status: 'error' });
},
});
const handleDelete = (file: FileInfo) => {
setDeleteTarget(file);
onDeleteOpen();
};
const confirmDelete = () => {
if (deleteTarget) {
deleteMutation.mutate({ id: deleteTarget.id, force: false });
}
};
const handleViewDetails = (file: FileInfo) => {
setSelectedFile(file);
onDetailOpen();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: 'Zkopírováno do schránky', status: 'success', duration: 2000 });
};
const handleUpload = async () => {
if (!uploadFiles || uploadFiles.length === 0) return;
setUploading(true);
try {
const promises = Array.from(uploadFiles).map(file => uploadFile(file));
await Promise.all(promises);
toast({
title: 'Soubory nahrány',
description: `Úspěšně nahráno ${uploadFiles.length} souborů`,
status: 'success'
});
qc.invalidateQueries({ queryKey: ['admin-media-files'] });
onUploadClose();
setUploadFiles(null);
} catch (error) {
toast({ title: 'Chyba při nahrávání', status: 'error' });
} finally {
setUploading(false);
}
};
const getFileUrl = (file: FileInfo) => {
if (file.url.startsWith('http')) return file.url;
const base = window.location.origin;
return `${base}${file.url}`;
};
const MediaCard = ({ file }: { file: FileInfo }) => {
const isImage = file.mime_type?.startsWith('image/');
const isVideo = file.mime_type?.startsWith('video/');
return (
<Box
bg={cardBg}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
cursor="pointer"
onClick={() => handleViewDetails(file)}
_hover={{ shadow: 'md', transform: 'translateY(-2px)' }}
transition="all 0.2s"
>
<AspectRatio ratio={16 / 9}>
<Box bg="gray.100" display="flex" alignItems="center" justifyContent="center">
{isImage ? (
<Image
src={getFileUrl(file)}
alt={file.filename}
objectFit="cover"
w="100%"
h="100%"
/>
) : isVideo ? (
<Box fontSize="48px" color="gray.400">
<FiVideo />
</Box>
) : (
<Box fontSize="48px" color="gray.400">
<FiFile />
</Box>
)}
</Box>
</AspectRatio>
<VStack align="stretch" p={3} spacing={2}>
<Text fontSize="sm" fontWeight="semibold" noOfLines={1}>
{file.filename}
</Text>
<HStack justify="space-between" fontSize="xs" color="gray.500">
<Text>{formatFileSize(file.size || 0)}</Text>
<Badge colorScheme="blue" fontSize="9px">
{file.mime_type?.split('/')[0]}
</Badge>
</HStack>
<HStack spacing={1}>
<Tooltip label="Kopírovat URL">
<IconButton
aria-label="Copy URL"
icon={<FiCopy />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(getFileUrl(file));
}}
/>
</Tooltip>
<Tooltip label="Smazat">
<IconButton
aria-label="Delete"
icon={<FiTrash2 />}
size="xs"
variant="ghost"
colorScheme="red"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
/>
</Tooltip>
</HStack>
</VStack>
</Box>
);
};
return (
<AdminLayout requireAdmin={false}>
<Box>
<Flex justify="space-between" align="center" mb={6}>
<Box>
<Heading size="lg" mb={1}>Média</Heading>
<Text color="gray.500">Správa obrázků, videí a dalších souborů</Text>
</Box>
<HStack>
<Button
leftIcon={<FiRefreshCw />}
onClick={() => scanMutation.mutate()}
isLoading={scanMutation.isPending}
size="sm"
>
Skenovat
</Button>
<Button
leftIcon={<FiUpload />}
colorScheme="blue"
onClick={onUploadOpen}
size="sm"
>
Nahrát
</Button>
</HStack>
</Flex>
{/* Stats */}
<HStack spacing={4} mb={6}>
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<HStack>
<FiImage />
<VStack align="start" spacing={0}>
<Text fontSize="2xl" fontWeight="bold">{imageFiles.length}</Text>
<Text fontSize="xs" color="gray.500">Obrázků</Text>
</VStack>
</HStack>
</Box>
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<HStack>
<FiVideo />
<VStack align="start" spacing={0}>
<Text fontSize="2xl" fontWeight="bold">{videoFiles.length}</Text>
<Text fontSize="xs" color="gray.500">Videí</Text>
</VStack>
</HStack>
</Box>
<Box p={4} bg={cardBg} borderRadius="lg" borderWidth="1px" borderColor={borderColor}>
<HStack>
<FiFile />
<VStack align="start" spacing={0}>
<Text fontSize="2xl" fontWeight="bold">{documentFiles.length}</Text>
<Text fontSize="xs" color="gray.500">Dokumentů</Text>
</VStack>
</HStack>
</Box>
</HStack>
{/* Filters */}
<HStack mb={6} spacing={4}>
<InputGroup maxW="400px">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray" />
</InputLeftElement>
<Input
placeholder="Hledat soubory..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</InputGroup>
<Select
maxW="200px"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as any)}
>
<option value="all">Všechny typy</option>
<option value="images">Pouze obrázky</option>
<option value="videos">Pouze videa</option>
<option value="documents">Pouze dokumenty</option>
</Select>
</HStack>
{/* Tabs */}
<Tabs>
<TabList>
<Tab>Všechny ({filteredFiles.length})</Tab>
<Tab>Obrázky ({imageFiles.length})</Tab>
<Tab>Videa ({videoFiles.length})</Tab>
<Tab>Dokumenty ({documentFiles.length})</Tab>
</TabList>
<TabPanels>
{/* All Files */}
<TabPanel px={0}>
{isLoading ? (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{[...Array(8)].map((_, i) => (
<Skeleton key={i} height="250px" borderRadius="lg" />
))}
</SimpleGrid>
) : filteredFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádné soubory</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{filteredFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
{/* Images */}
<TabPanel px={0}>
{imageFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádné obrázky</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{imageFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
{/* Videos */}
<TabPanel px={0}>
{videoFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádná videa</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{videoFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
{/* Documents */}
<TabPanel px={0}>
{documentFiles.length === 0 ? (
<Box textAlign="center" py={12}>
<Text color="gray.500">Žádné dokumenty</Text>
</Box>
) : (
<SimpleGrid columns={{ base: 1, md: 3, lg: 4 }} spacing={4}>
{documentFiles.map(file => (
<MediaCard key={file.id} file={file} />
))}
</SimpleGrid>
)}
</TabPanel>
</TabPanels>
</Tabs>
{/* File Details Modal */}
<Modal isOpen={isDetailOpen} onClose={onDetailClose} size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Detail souboru</ModalHeader>
<ModalCloseButton />
<ModalBody>
{selectedFile && (
<VStack align="stretch" spacing={4}>
{selectedFile.mime_type?.startsWith('image/') && (
<Image
src={getFileUrl(selectedFile)}
alt={selectedFile.filename}
borderRadius="lg"
maxH="400px"
objectFit="contain"
/>
)}
<Divider />
<Stack spacing={2}>
<HStack justify="space-between">
<Text fontWeight="semibold">Název:</Text>
<Text>{selectedFile.filename}</Text>
</HStack>
<HStack justify="space-between">
<Text fontWeight="semibold">Velikost:</Text>
<Text>{formatFileSize(selectedFile.size || 0)}</Text>
</HStack>
<HStack justify="space-between">
<Text fontWeight="semibold">Typ:</Text>
<Badge>{selectedFile.mime_type}</Badge>
</HStack>
<HStack justify="space-between">
<Text fontWeight="semibold">Vytvořeno:</Text>
<Text fontSize="sm">
{selectedFile.created_at ? new Date(selectedFile.created_at).toLocaleString('cs-CZ') : 'N/A'}
</Text>
</HStack>
</Stack>
<Divider />
<Box>
<Text fontWeight="semibold" mb={2}>URL:</Text>
<HStack>
<Code flex={1} p={2} fontSize="xs" borderRadius="md">
{getFileUrl(selectedFile)}
</Code>
<IconButton
aria-label="Copy URL"
icon={<FiCopy />}
size="sm"
onClick={() => copyToClipboard(getFileUrl(selectedFile))}
/>
</HStack>
</Box>
</VStack>
)}
</ModalBody>
<ModalFooter>
<HStack spacing={2}>
{selectedFile && (
<Button
as="a"
href={getFileUrl(selectedFile)}
target="_blank"
leftIcon={<FiExternalLink />}
size="sm"
>
Otevřít
</Button>
)}
<Button
colorScheme="red"
leftIcon={<FiTrash2 />}
onClick={() => {
if (selectedFile) {
onDetailClose();
handleDelete(selectedFile);
}
}}
size="sm"
>
Smazat
</Button>
<Button onClick={onDetailClose} size="sm">Zavřít</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* Delete Confirmation Modal */}
<Modal isOpen={isDeleteOpen} onClose={onDeleteClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Smazat soubor</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text>
Opravdu chcete smazat soubor <strong>{deleteTarget?.filename}</strong>?
</Text>
<Text mt={2} fontSize="sm" color="gray.500">
Tato akce je nevratná.
</Text>
</ModalBody>
<ModalFooter>
<HStack spacing={2}>
<Button onClick={onDeleteClose} size="sm">Zrušit</Button>
<Button
colorScheme="red"
onClick={confirmDelete}
isLoading={deleteMutation.isPending}
size="sm"
>
Smazat
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
{/* Upload Modal */}
<Modal isOpen={isUploadOpen} onClose={onUploadClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Nahrát soubory</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack align="stretch" spacing={4}>
<Text fontSize="sm" color="gray.500">
Vyberte jeden nebo více souborů k nahrání.
</Text>
<Input
type="file"
multiple
accept="image/*,video/*,application/pdf"
onChange={(e) => setUploadFiles(e.target.files)}
p={1}
/>
{uploadFiles && uploadFiles.length > 0 && (
<Box>
<Text fontSize="sm" fontWeight="semibold" mb={2}>
Vybrané soubory ({uploadFiles.length}):
</Text>
<VStack align="stretch" spacing={1}>
{Array.from(uploadFiles).map((file, i) => (
<Text key={i} fontSize="sm">
{file.name} ({formatFileSize(file.size)})
</Text>
))}
</VStack>
</Box>
)}
</VStack>
</ModalBody>
<ModalFooter>
<HStack spacing={2}>
<Button onClick={onUploadClose} size="sm">Zrušit</Button>
<Button
colorScheme="blue"
onClick={handleUpload}
isLoading={uploading}
isDisabled={!uploadFiles || uploadFiles.length === 0}
size="sm"
>
Nahrát
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
</Box>
</AdminLayout>
);
};
export default MediaAdminPage;