mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
upload
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user