239 lines
6.9 KiB
TypeScript
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}×{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>
|
|
);
|
|
}
|