Files
MyClub/frontend/src/pages/admin/GalleryAdminPage.tsx
T
Tomáš Dvořák 35d0954afd dev day #62
2025-10-16 17:10:13 +02:00

362 lines
12 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import {
Box,
Container,
Heading,
Text,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
HStack,
VStack,
Badge,
Spinner,
useToast,
Image,
Link,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
useColorModeValue,
} from '@chakra-ui/react';
import { RefreshCw, ExternalLink, Calendar, Image as ImageIcon, Eye } from 'lucide-react';
import AdminLayout from '../../layouts/AdminLayout';
import api from '../../services/api';
interface Album {
id: string;
title: string;
url: string;
date: string;
photos_count: number;
views_count?: number;
photos: Array<{
id: string;
page_url: string;
image_1500: string;
}>;
}
const resolveBackendUrl = (path: string) => {
try {
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/cache') || path.startsWith('/uploads') || path.startsWith('/api/')) {
const base = process.env.REACT_APP_API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8080/api/v1';
const b = new URL(base);
const abs = new URL(path, `${b.protocol}//${b.host}`);
return abs.toString();
}
return path;
} catch {
return path;
}
};
const GalleryAdminPage: React.FC = () => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string>('');
const toast = useToast();
const fetchAlbums = async () => {
setLoading(true);
setError('');
try {
// Try to load from both sources and combine them (similar to frontpage)
const [profileRes, albumsRes] = await Promise.allSettled([
fetch(resolveBackendUrl('/cache/prefetch/zonerama_profile.json'), { cache: 'no-cache' }),
fetch(resolveBackendUrl('/cache/prefetch/zonerama_albums.json'), { cache: 'no-cache' })
]);
let combinedAlbums: Album[] = [];
// Get profile albums (main source)
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
const profileData = await profileRes.value.json();
if (profileData.albums && Array.isArray(profileData.albums)) {
combinedAlbums = [...profileData.albums];
}
}
// Get additional albums (fallback source)
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
const albumsData = await albumsRes.value.json();
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
// Filter out empty albums and avoid duplicates
const validBlogAlbums = blogAlbums.filter((album: any) =>
album.id &&
album.title &&
!combinedAlbums.some(existing => existing.id === album.id)
);
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
setAlbums(combinedAlbums);
} catch (err: any) {
setError(err.message || 'Nepodařilo se načíst alba');
} finally {
setLoading(false);
}
};
const handleRefresh = async () => {
setRefreshing(true);
try {
// Use the api service which automatically includes authentication
await api.post('/admin/gallery/refresh');
toast({
title: 'Galerie obnovena',
description: 'Data z Zonerama byla úspěšně načtena',
status: 'success',
duration: 3000,
isClosable: true,
});
// Reload albums after refresh
await fetchAlbums();
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Nepodařilo se obnovit galerii';
toast({
title: 'Chyba při obnově galerie',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
console.error('Gallery refresh error:', err);
} finally {
setRefreshing(false);
}
};
useEffect(() => {
fetchAlbums();
}, []);
const totalPhotos = albums.reduce((sum, album) => sum + album.photos_count, 0);
const totalViews = albums.reduce((sum, album) => sum + (album.views_count || 0), 0);
return (
<AdminLayout>
<Container maxW="7xl" py={8}>
<VStack align="stretch" spacing={6}>
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl">Správa galerie</Heading>
<Text color="gray.600">
Správa alb a fotografií ze Zonerama
</Text>
</VStack>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
loadingText="Obnova..."
>
Obnovit z Zonerama
</Button>
</HStack>
{/* Zonerama Info */}
<Alert status="info" borderRadius="md">
<AlertIcon />
<Box>
<AlertTitle>Zonerama integrace</AlertTitle>
<AlertDescription>
Alba jsou automaticky načítána ze Zonerama profilu. Klikněte na "Obnovit z Zonerama" pro synchronizaci s nejnovějšími daty.
</AlertDescription>
</Box>
</Alert>
{/* Statistics */}
{!loading && !error && albums.length > 0 && (
<HStack spacing={4} flexWrap="wrap">
<Badge colorScheme="purple" fontSize="md" p={3} borderRadius="md">
{albums.length} alb
</Badge>
<Badge colorScheme="blue" fontSize="md" p={3} borderRadius="md">
{totalPhotos} fotografií
</Badge>
<Badge colorScheme="green" fontSize="md" p={3} borderRadius="md">
{totalViews} zhlédnutí
</Badge>
</HStack>
)}
{/* Loading State */}
{loading && (
<VStack spacing={4} py={12}>
<Spinner size="xl" color="brand.primary" />
<Text color="gray.600">Načítám alba...</Text>
</VStack>
)}
{/* Error State */}
{error && !loading && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Empty State */}
{!loading && !error && albums.length === 0 && (
<Box
bg={cardBg}
borderWidth="1px"
borderRadius="lg"
p={12}
textAlign="center"
>
<VStack spacing={4}>
<ImageIcon size={48} color="gray" />
<Heading size="md" color="gray.600">
Zatím nejsou k dispozici žádná alba
</Heading>
<Text color="gray.500">
Klikněte na tlačítko "Obnovit z Zonerama" pro načtení alb.
</Text>
<Button
leftIcon={<RefreshCw size={18} />}
colorScheme="blue"
onClick={handleRefresh}
isLoading={refreshing}
>
Obnovit z Zonerama
</Button>
</VStack>
</Box>
)}
{/* Albums Table */}
{!loading && !error && albums.length > 0 && (
<Box
bg={cardBg}
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
boxShadow="sm"
>
<Table variant="simple">
<Thead bg={useColorModeValue('gray.50', 'gray.900')}>
<Tr>
<Th width="100px">Náhled</Th>
<Th>Název</Th>
<Th width="120px">Datum</Th>
<Th width="100px" isNumeric>Fotky</Th>
<Th width="120px" isNumeric>Zhlédnutí</Th>
<Th width="180px">Akce</Th>
</Tr>
</Thead>
<Tbody>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0
? album.photos[0]
: null;
return (
<Tr key={album.id} _hover={{ bg: 'gray.50' }}>
<Td>
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
alt={album.title}
boxSize="60px"
objectFit="cover"
borderRadius="md"
/>
) : (
<Box
boxSize="60px"
bg="gray.200"
borderRadius="md"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={24} color="gray" />
</Box>
)}
</Td>
<Td>
<Text fontWeight="600" color="gray.800" noOfLines={2}>
{album.title}
</Text>
</Td>
<Td>
<HStack spacing={1} fontSize="sm" color="gray.600">
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
</Td>
<Td isNumeric>
<Badge colorScheme="blue">
{album.photos_count}
</Badge>
</Td>
<Td isNumeric>
<HStack spacing={1} justify="flex-end">
<Eye size={14} />
<Text fontSize="sm">{album.views_count || 0}</Text>
</HStack>
</Td>
<Td>
<HStack spacing={2}>
<Button
as={Link}
href={`/galerie/album/${album.id}`}
target="_blank"
size="sm"
colorScheme="purple"
variant="outline"
>
Náhled
</Button>
<Button
as={Link}
href={album.url}
target="_blank"
rel="noopener noreferrer"
size="sm"
colorScheme="blue"
variant="ghost"
rightIcon={<ExternalLink size={14} />}
>
Zonerama
</Button>
</HStack>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</Box>
)}
</VStack>
</Container>
</AdminLayout>
);
};
export default GalleryAdminPage;