changemaker.lite/admin/src/components/media/ExpandedPhotoCard.tsx

239 lines
6.9 KiB
TypeScript

import { useRef, useState, useEffect } from 'react';
import { Button, Space, Tag, Grid, theme, Spin, message } from 'antd';
import {
CloseOutlined,
LikeOutlined,
LikeFilled,
EyeOutlined,
CommentOutlined,
ZoomInOutlined,
ZoomOutOutlined,
CameraOutlined,
} from '@ant-design/icons';
import { useExpandedVideo } from '@/contexts/ExpandedVideoContext';
import { mediaPublicApi } from '@/lib/media-public-api';
import type { PublicPhoto } from '@/types/media';
const { useBreakpoint } = Grid;
interface ExpandedPhotoCardProps {
photo: PublicPhoto;
}
export default function ExpandedPhotoCard({ photo }: ExpandedPhotoCardProps) {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { collapseVideo } = useExpandedVideo();
const containerRef = useRef<HTMLDivElement>(null);
const [hasUpvoted, setHasUpvoted] = useState(false);
const [upvoteCount, setUpvoteCount] = useState(photo.upvoteCount);
const [upvoting, setUpvoting] = useState(false);
const [isExpanding, setIsExpanding] = useState(true);
const [zoomed, setZoomed] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const pad = isMobile ? 8 : 12;
const title = photo.title || 'Untitled Photo';
// Use large image URL
const imageUrl = photo.imageUrl || photo.thumbnailUrl;
// Handle keyboard
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') collapseVideo();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [collapseVideo]);
// Expand animation
useEffect(() => {
const timer = requestAnimationFrame(() => {
requestAnimationFrame(() => setIsExpanding(false));
});
return () => cancelAnimationFrame(timer);
}, []);
// Scroll into view
useEffect(() => {
const timer = setTimeout(() => {
containerRef.current?.scrollIntoView({
behavior: isMobile ? 'auto' : 'smooth',
block: 'nearest',
});
}, 350);
return () => clearTimeout(timer);
}, [isMobile]);
// Track view
useEffect(() => {
mediaPublicApi.post('/photos/' + photo.id + '/view').catch(() => {});
}, [photo.id]);
const handleUpvote = async () => {
if (upvoting || hasUpvoted) return;
try {
setUpvoting(true);
await mediaPublicApi.post(`/photos/${photo.id}/upvote`);
setHasUpvoted(true);
setUpvoteCount(prev => prev + 1);
} catch (error: any) {
if (error.response?.status === 401) {
message.info('Please log in to upvote');
}
} finally {
setUpvoting(false);
}
};
const formatCount = (count: number) => {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return count.toString();
};
return (
<div
ref={containerRef}
style={{
gridColumn: '1 / -1',
background: token.colorBgContainer,
borderRadius: 0,
overflow: 'hidden',
transition: 'opacity 300ms ease-out, max-height 300ms ease-out',
maxHeight: isExpanding ? 0 : 3000,
opacity: isExpanding ? 0 : 1,
marginLeft: -pad,
marginRight: -pad,
width: `calc(100% + ${pad * 2}px)`,
}}
>
{/* Image section */}
<div
style={{
position: 'relative',
width: '100%',
maxHeight: isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)',
background: '#000',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
cursor: zoomed ? 'zoom-out' : 'zoom-in',
}}
onClick={() => setZoomed(!zoomed)}
>
{imageLoading && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
)}
<img
src={imageUrl}
alt={title}
onLoad={() => setImageLoading(false)}
style={{
maxWidth: zoomed ? 'none' : '100%',
maxHeight: zoomed ? 'none' : isMobile ? 'calc(100vh - 100px)' : 'calc(100vh - 60px)',
width: zoomed ? 'auto' : undefined,
objectFit: 'contain',
transition: 'transform 0.3s ease',
transform: zoomed ? 'scale(1.5)' : 'scale(1)',
}}
/>
{/* Zoom indicator */}
<div
style={{
position: 'absolute',
bottom: 12,
right: 12,
background: 'rgba(0,0,0,0.6)',
borderRadius: 4,
padding: '4px 8px',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
{zoomed ? <ZoomOutOutlined /> : <ZoomInOutlined />}
{zoomed ? 'Click to zoom out' : 'Click to zoom in'}
</div>
</div>
{/* Bottom info bar */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: isMobile ? '6px 12px' : '6px 16px',
borderTop: `1px solid ${token.colorBorder}`,
flexWrap: 'wrap',
}}
>
{/* Close button */}
<Button
type="text"
icon={<CloseOutlined />}
onClick={collapseVideo}
size="small"
style={{ flexShrink: 0 }}
/>
{/* Title */}
<div
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: isMobile ? 12 : 14,
fontWeight: 500,
color: token.colorText,
}}
>
{title}
</div>
{/* Tags */}
<Space size={8} style={{ flexShrink: 0 }}>
<Tag style={{ margin: 0, fontSize: 10 }}>
<CameraOutlined /> Photo
</Tag>
{photo.format && (
<Tag color="purple" style={{ margin: 0, fontSize: 10 }}>{photo.format.toUpperCase()}</Tag>
)}
{photo.width && photo.height && (
<Tag style={{ margin: 0, fontSize: 10 }}>{photo.width}&times;{photo.height}</Tag>
)}
</Space>
<Space size={12} style={{ color: token.colorTextSecondary, fontSize: 12, flexShrink: 0 }}>
<span><EyeOutlined /> {formatCount(photo.viewCount)}</span>
<span><CommentOutlined /> {formatCount(photo.commentCount)}</span>
</Space>
{/* Upvote */}
<Button
type={hasUpvoted ? 'primary' : 'text'}
icon={hasUpvoted ? <LikeFilled /> : <LikeOutlined />}
onClick={handleUpvote}
loading={upvoting}
disabled={hasUpvoted}
size="small"
style={{ flexShrink: 0 }}
>
{formatCount(upvoteCount)}
</Button>
</div>
</div>
);
}