Files
MyClub/frontend/src/components/home/GallerySection.tsx
T
Tomas Dvorak dfc079288f hot fix #1
2026-01-26 08:13:18 +01:00

330 lines
11 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { API_URL } from '../../services/api';
import { getZoneramaManifestWithFallbacks } from '../../services/zonerama';
import { Link as RouterLink } from 'react-router-dom';
import {
Box,
Heading,
SimpleGrid,
Image,
Text,
VStack,
HStack,
Button,
Skeleton,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { Calendar, Image as ImageIcon, ExternalLink, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
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/')) {
// Prefer explicit asset base (backend origin) when provided; fallback to API_URL origin
let origin = '';
try {
const assetBase = (process.env.REACT_APP_ASSET_BASE_URL || '').trim();
if (assetBase) {
origin = new URL(assetBase, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
}
} catch {}
if (!origin) {
try {
origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
} catch {}
}
const abs = new URL(path, origin || (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'));
return abs.toString();
}
return path;
} catch {
return path;
}
};
const GallerySection: React.FC<{ zoneramaUrl?: string | null }> = ({ zoneramaUrl }) => {
const { t } = useTranslation();
const [albums, setAlbums] = useState<Album[]>([]);
const [loading, setLoading] = useState(true);
const [profileUrl, setProfileUrl] = useState<string | null>(null);
// Dark mode colors
const cardBg = useColorModeValue('white', 'gray.800');
const headingColor = useColorModeValue('gray.800', 'gray.100');
const textColor = useColorModeValue('gray.600', 'gray.300');
const infoBg = useColorModeValue('blue.50', 'blue.900');
const infoBorder = useColorModeValue('blue.200', 'blue.700');
const infoText = useColorModeValue('blue.700', 'blue.200');
useEffect(() => {
const fetchAlbums = async () => {
setLoading(true);
try {
// Load from both sources and combine
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 (newest/main source)
if (profileRes.status === 'fulfilled' && profileRes.value.ok) {
const profileData = await profileRes.value.json();
if (profileData && profileData.input_link) {
setProfileUrl(profileData.input_link);
}
combinedAlbums = [...(profileData.albums || [])];
}
// Get blog-related albums (additional source)
if (albumsRes.status === 'fulfilled' && albumsRes.value.ok) {
const albumsData = await albumsRes.value.json();
const blogAlbums = Array.isArray(albumsData) ? albumsData : [];
// Filter out albums with empty/invalid data and avoid duplicates
const validBlogAlbums = blogAlbums.filter((album: any) =>
album.id &&
album.title &&
!combinedAlbums.some(existing => existing.id === album.id)
);
combinedAlbums = [...combinedAlbums, ...validBlogAlbums];
}
// Fallback: synthesize albums from manifest/picks when both sources are empty or invalid
if ((!combinedAlbums || combinedAlbums.length === 0)) {
try {
const items = await getZoneramaManifestWithFallbacks();
if (Array.isArray(items) && items.length > 0) {
const byAlbum: Record<string, typeof items> = {} as any;
items.forEach((it) => {
const aid = String(it.album_id || 'unknown');
(byAlbum[aid] = byAlbum[aid] || []).push(it);
});
const synthesized: Album[] = Object.entries(byAlbum).map(([aid, arr]) => ({
id: aid,
title: 'Album',
url: (arr[0] as any).page_url || '#',
date: '',
photos_count: arr.length,
photos: arr.slice(0, 12).map((p: any) => ({ id: String(p.id || ''), page_url: String(p.page_url || ''), image_1500: String(p.src || p.local || '') })),
}));
combinedAlbums = synthesized;
}
} catch {}
}
// Sort by date (newest first)
combinedAlbums.sort((a, b) => {
const parseDate = (dateStr: string) => {
if (!dateStr) return new Date(0);
const parts = dateStr.split(/[.\s]+/).filter(Boolean);
if (parts.length === 3) {
const [day, month, year] = parts;
return new Date(`${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`);
}
return new Date(dateStr);
};
return parseDate(b.date).getTime() - parseDate(a.date).getTime();
});
// Get the 3 most recent albums
const recentAlbums = combinedAlbums.slice(0, 3);
setAlbums(recentAlbums);
} catch (err) {
console.error('Error loading albums:', err);
} finally {
setLoading(false);
}
};
fetchAlbums();
}, []);
if (loading) {
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
<Heading size="xl">Galerie</Heading>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height="300px" borderRadius="lg" />
))}
</SimpleGrid>
</VStack>
</Box>
);
}
if (albums.length === 0) {
return null;
}
return (
<Box py={12}>
<VStack spacing={6} align="stretch">
{/* Header */}
<HStack justify="space-between" align="center" flexWrap="wrap">
<VStack align="start" spacing={1}>
<Heading size="xl" color={headingColor} id="home-gallery-heading">
{t('homepage.gallery')}
</Heading>
<Text color={textColor} fontSize="sm">
{t('gallery.latest_albums')}
</Text>
</VStack>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
variant="outline"
size="md"
>
{t('nav.view_all')}
</Button>
</HStack>
{/* Zonerama Attribution (single source of truth) */}
<Box
bg={infoBg}
borderWidth="1px"
borderColor={infoBorder}
borderRadius="md"
px={4}
py={2}
>
<Text fontSize="xs" color={infoText}>
© Fotografie z{' '}
<Text
as="a"
href={zoneramaUrl || profileUrl || 'https://zonerama.com'}
target="_blank"
rel="noopener noreferrer"
fontWeight="600"
color="blue.600"
_hover={{ textDecoration: 'underline' }}
>
Zonerama
</Text>
</Text>
</Box>
{/* Albums Grid */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{albums.map((album) => {
const coverPhoto = album.photos && album.photos.length > 0
? album.photos[0]
: null;
return (
<Box
key={album.id}
as={RouterLink}
to={`/galerie/album/${album.id}`}
className="card"
bg={cardBg}
overflow="hidden"
transition="all 0.3s"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
_hover={{
transform: 'translateY(-8px)',
boxShadow: '2xl',
borderColor: useColorModeValue('gray.300', 'gray.600'),
}}
cursor="pointer"
>
{/* Cover Image */}
{coverPhoto ? (
<Image
src={coverPhoto.image_1500}
alt={album.title}
w="100%"
h="200px"
objectFit="cover"
loading="lazy"
decoding="async"
/>
) : (
<Box
w="100%"
h="200px"
bg="gray.200"
display="flex"
alignItems="center"
justifyContent="center"
>
<ImageIcon size={48} color="gray" />
</Box>
)}
{/* Album Info */}
<VStack align="stretch" p={4} spacing={2}>
<Heading size="sm" color={headingColor} noOfLines={2} minH="40px">
{album.title}
</Heading>
<VStack spacing={2} fontSize="xs" color={textColor} align="stretch">
{album.date && (
<HStack spacing={1}>
<Calendar size={14} />
<Text>{album.date}</Text>
</HStack>
)}
<HStack spacing={1}>
<ImageIcon size={14} />
<Text>{album.photos_count} foto</Text>
</HStack>
</VStack>
{album.views_count !== undefined && album.views_count > 0 && (
<Badge colorScheme="purple" fontSize="2xs" alignSelf="flex-start">
{album.views_count} zhlédnutí
</Badge>
)}
</VStack>
</Box>
);
})}
</SimpleGrid>
{/* Bottom CTA */}
<Box textAlign="center" pt={4}>
<Button
as={RouterLink}
to="/galerie"
rightIcon={<ArrowRight size={18} />}
colorScheme="blue"
size="lg"
>
{t('gallery.view_all_albums')}
</Button>
</Box>
</VStack>
</Box>
);
};
export default GallerySection;