mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
600 lines
20 KiB
TypeScript
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;
|