175 lines
5.7 KiB
TypeScript
175 lines
5.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
|
|
import { useImageDownloadTime } from '@/components/shared/ImageCard/useImageDownloadTime';
|
|
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
|
import { calculateAspectRatio } from '@/utils/imageUtils';
|
|
import { usePageContext } from '@/contexts/page-context';
|
|
import { ExpandedImageView } from '@/components/shared/ExpandedImageView';
|
|
|
|
type GalleryImageCardProps = {
|
|
imageUrl: string;
|
|
filename: string;
|
|
size: number;
|
|
contentType: string;
|
|
lastModified: string;
|
|
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
|
};
|
|
|
|
export const GalleryImageCard = ({
|
|
imageUrl,
|
|
filename,
|
|
size,
|
|
contentType,
|
|
lastModified,
|
|
onDownloadMeasured,
|
|
}: GalleryImageCardProps) => {
|
|
const { openModal } = usePageContext();
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const [imageDimensions, setImageDimensions] = useState<{
|
|
width: number;
|
|
height: number;
|
|
} | null>(null);
|
|
|
|
const { ref } = useIntersectionObserver({
|
|
onIntersect: () => setIsVisible(true),
|
|
threshold: 0.1,
|
|
});
|
|
|
|
const { downloadTime } = useImageDownloadTime(isVisible ? imageUrl : null);
|
|
|
|
useEffect(() => {
|
|
if (downloadTime !== null) {
|
|
onDownloadMeasured(imageUrl, downloadTime);
|
|
}
|
|
}, [downloadTime, imageUrl, onDownloadMeasured]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
setImageDimensions({
|
|
width: img.width,
|
|
height: img.height,
|
|
});
|
|
};
|
|
img.src = imageUrl;
|
|
}, [isVisible, imageUrl]);
|
|
|
|
const handleImageClick = () => {
|
|
openModal(<ExpandedImageView imageUrl={imageUrl} alt={filename} />);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleImageClick();
|
|
}
|
|
};
|
|
|
|
const formatTimestamp = (dateString: string): string => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all h-full"
|
|
>
|
|
<div
|
|
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative focus:outline-none focus:ring-2 focus:ring-amber-500"
|
|
onClick={handleImageClick}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`View full size image: ${filename}`}
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{isVisible ? (
|
|
<>
|
|
<img
|
|
src={imageUrl}
|
|
alt={filename}
|
|
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
|
|
<div className="absolute inset-0 bg-gradient-to-b from-black/70 via-transparent to-black/70 opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity">
|
|
<div className="absolute top-3 left-3">
|
|
<span className="px-3 py-2 bg-black/50 backdrop-blur-sm text-white text-xs sm:text-sm font-medium rounded border border-white/10" aria-label={`Created ${formatTimestamp(lastModified)}`}>
|
|
{formatTimestamp(lastModified)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="absolute top-3 right-3 max-w-[60%]">
|
|
<span
|
|
className="px-3 py-2 bg-black/50 backdrop-blur-sm text-white text-xs sm:text-sm font-medium rounded border border-white/10 truncate block"
|
|
title={filename}
|
|
aria-label={`Filename: ${filename}`}
|
|
>
|
|
{filename}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<div className="bg-black/60 backdrop-blur-sm rounded-full p-4">
|
|
<svg
|
|
className="w-10 h-10 sm:w-12 sm:h-12 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center bg-slate-800 animate-pulse" aria-label="Loading image">
|
|
<div className="text-gray-600">
|
|
<svg
|
|
className="w-12 h-12"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{imageDimensions && (
|
|
<ImageMetadataBar
|
|
width={imageDimensions.width}
|
|
height={imageDimensions.height}
|
|
fileSize={size}
|
|
fileType={contentType}
|
|
showVisualIndicator={true}
|
|
downloadMs={downloadTime}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|