feat: adjust image card
This commit is contained in:
parent
f942480fc8
commit
b7bb37f2a7
|
|
@ -5,6 +5,9 @@ import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
|
|||
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
|
||||
import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
|
||||
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview';
|
||||
import { ImageMetadataBar } from '@/components/shared/ImageMetadataBar';
|
||||
import { ImageCard } from '@/components/shared/ImageCard';
|
||||
import { calculateAspectRatio } from '@/utils/imageUtils';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
|
||||
|
|
@ -37,6 +40,10 @@ interface UploadHistoryItem {
|
|||
size: number;
|
||||
contentType: string;
|
||||
durationMs: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
aspectRatio?: string;
|
||||
downloadMs?: number;
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
|
|
@ -60,6 +67,11 @@ export default function DemoUploadPage() {
|
|||
const [uploadError, setUploadError] = useState('');
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [imageDimensions, setImageDimensions] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: string;
|
||||
} | null>(null);
|
||||
|
||||
// History State
|
||||
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
|
||||
|
|
@ -219,6 +231,7 @@ export default function DemoUploadPage() {
|
|||
setValidationError(error);
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setImageDimensions(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +239,20 @@ export default function DemoUploadPage() {
|
|||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setPreviewUrl(reader.result as string);
|
||||
const dataUrl = reader.result as string;
|
||||
setPreviewUrl(dataUrl);
|
||||
|
||||
// Extract image dimensions
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const aspectRatio = calculateAspectRatio(img.width, img.height);
|
||||
setImageDimensions({
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
aspectRatio,
|
||||
});
|
||||
};
|
||||
img.src = dataUrl;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
|
@ -300,12 +326,16 @@ export default function DemoUploadPage() {
|
|||
size: result.data.size,
|
||||
contentType: result.data.contentType,
|
||||
durationMs,
|
||||
width: imageDimensions?.width,
|
||||
height: imageDimensions?.height,
|
||||
aspectRatio: imageDimensions?.aspectRatio,
|
||||
};
|
||||
|
||||
setUploadHistory((prev) => [historyItem, ...prev]);
|
||||
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setImageDimensions(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
|
@ -319,15 +349,12 @@ export default function DemoUploadPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
const handleDownloadMeasured = (itemId: string, downloadMs: number) => {
|
||||
setUploadHistory((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === itemId ? { ...item, downloadMs } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
|
||||
|
|
@ -533,14 +560,23 @@ Body (form-data):
|
|||
/>
|
||||
)}
|
||||
<div className="text-center">
|
||||
<p className="text-white font-medium">{selectedFile.name}</p>
|
||||
<p className="text-sm text-gray-400">{formatFileSize(selectedFile.size)}</p>
|
||||
<p className="text-white font-medium mb-2">{selectedFile.name}</p>
|
||||
{imageDimensions && (
|
||||
<ImageMetadataBar
|
||||
width={imageDimensions.width}
|
||||
height={imageDimensions.height}
|
||||
fileSize={selectedFile.size}
|
||||
fileType={selectedFile.type}
|
||||
showVisualIndicator={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFile(null);
|
||||
setPreviewUrl(null);
|
||||
setImageDimensions(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
|
@ -599,69 +635,18 @@ Body (form-data):
|
|||
<div key={item.id} className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* Column 1: Image Card */}
|
||||
<div className="lg:col-span-1">
|
||||
<div 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"
|
||||
onClick={() => setZoomedImageUrl(item.url)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="View full size image"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setZoomedImageUrl(item.url);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.originalName}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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 className="space-y-2">
|
||||
<p className="text-white text-sm font-medium truncate" title={item.originalName}>
|
||||
{item.originalName}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="px-2 py-1 bg-slate-700/50 text-gray-300 rounded">
|
||||
{formatFileSize(item.size)}
|
||||
</span>
|
||||
<span className="px-2 py-1 bg-green-600/20 text-green-400 rounded border border-green-600/30">
|
||||
{formatDuration(item.durationMs)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
{item.timestamp.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setZoomedImageUrl(item.url)}
|
||||
className="mt-2 w-full text-center px-3 py-1.5 text-xs text-amber-400 hover:text-amber-300 border border-amber-600/30 hover:border-amber-500/50 rounded-lg transition-colors"
|
||||
>
|
||||
View Full Size
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ImageCard
|
||||
imageUrl={item.url}
|
||||
filename={item.originalName}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
fileSize={item.size}
|
||||
fileType={item.contentType}
|
||||
timestamp={item.timestamp}
|
||||
onZoom={setZoomedImageUrl}
|
||||
measureDownloadTime={true}
|
||||
onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Columns 2-3: API Code Examples Widget */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
export const EmptyGalleryState = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-6">
|
||||
<div className="w-24 h-24 mb-6 rounded-full bg-slate-800 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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>
|
||||
|
||||
<h3 className="text-xl md:text-2xl font-bold text-white mb-3 text-center">
|
||||
No Generated Images Yet
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-400 text-center mb-8 max-w-md">
|
||||
Your generated images will appear here. Start creating AI-powered images using the text-to-image tool.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/demo/tti"
|
||||
className="px-6 py-3 rounded-lg bg-gradient-to-r from-amber-600 to-orange-600 text-white font-semibold hover:from-amber-500 hover:to-orange-500 transition-all shadow-lg shadow-amber-900/30 focus:ring-2 focus:ring-amber-500"
|
||||
>
|
||||
Generate Images
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
'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';
|
||||
|
||||
type GalleryImageCardProps = {
|
||||
imageUrl: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
lastModified: string;
|
||||
onZoom: (url: string) => void;
|
||||
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||
};
|
||||
|
||||
export const GalleryImageCard = ({
|
||||
imageUrl,
|
||||
filename,
|
||||
size,
|
||||
contentType,
|
||||
lastModified,
|
||||
onZoom,
|
||||
onDownloadMeasured,
|
||||
}: GalleryImageCardProps) => {
|
||||
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 handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onZoom(imageUrl);
|
||||
}
|
||||
};
|
||||
|
||||
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"
|
||||
onClick={() => onZoom(imageUrl)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="View full size image"
|
||||
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 transition-opacity">
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className="px-2.5 py-1 bg-black/50 backdrop-blur-sm text-white text-xs font-medium rounded border border-white/10">
|
||||
{formatTimestamp(lastModified)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-3 right-3 max-w-[60%]">
|
||||
<span
|
||||
className="px-2.5 py-1 bg-black/50 backdrop-blur-sm text-white text-xs font-medium rounded border border-white/10 truncate block"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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 className="w-full h-full flex items-center justify-center">
|
||||
<div className="animate-pulse text-gray-600">
|
||||
<svg
|
||||
className="w-12 h-12"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
'use client';
|
||||
|
||||
import { GalleryImageCard } from './GalleryImageCard';
|
||||
|
||||
type ImageItem = {
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
contentType: string;
|
||||
lastModified: string;
|
||||
};
|
||||
|
||||
type ImageGridProps = {
|
||||
images: ImageItem[];
|
||||
onImageZoom: (url: string) => void;
|
||||
onDownloadMeasured: (imageId: string, downloadMs: number) => void;
|
||||
};
|
||||
|
||||
export const ImageGrid = ({ images, onImageZoom, onDownloadMeasured }: ImageGridProps) => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{images.map((image) => (
|
||||
<GalleryImageCard
|
||||
key={image.url}
|
||||
imageUrl={image.url}
|
||||
filename={image.name}
|
||||
size={image.size}
|
||||
contentType={image.contentType}
|
||||
lastModified={image.lastModified}
|
||||
onZoom={onImageZoom}
|
||||
onDownloadMeasured={onDownloadMeasured}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { ImageMetadataBar } from '../ImageMetadataBar';
|
||||
import { useImageDownloadTime } from './useImageDownloadTime';
|
||||
|
||||
export interface ImageCardProps {
|
||||
imageUrl: string;
|
||||
filename: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
onZoom: (url: string) => void;
|
||||
timestamp?: Date;
|
||||
className?: string;
|
||||
measureDownloadTime?: boolean;
|
||||
onDownloadMeasured?: (downloadMs: number) => void;
|
||||
}
|
||||
|
||||
export const ImageCard = ({
|
||||
imageUrl,
|
||||
filename,
|
||||
width,
|
||||
height,
|
||||
fileSize,
|
||||
fileType,
|
||||
onZoom,
|
||||
timestamp,
|
||||
className = '',
|
||||
measureDownloadTime = false,
|
||||
onDownloadMeasured,
|
||||
}: ImageCardProps) => {
|
||||
const { downloadTime, isLoading } = useImageDownloadTime(
|
||||
measureDownloadTime ? imageUrl : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadTime !== null && onDownloadMeasured) {
|
||||
onDownloadMeasured(downloadTime);
|
||||
}
|
||||
}, [downloadTime, onDownloadMeasured]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onZoom(imageUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimestamp = (date: Date): string => {
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all h-full ${className}`}
|
||||
>
|
||||
<div
|
||||
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative"
|
||||
onClick={() => onZoom(imageUrl)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="View full size image"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<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 transition-opacity">
|
||||
{timestamp && (
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className="px-2.5 py-1 bg-black/50 backdrop-blur-sm text-white text-xs font-medium rounded border border-white/10">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-3 right-3 max-w-[60%]">
|
||||
<span
|
||||
className="px-2.5 py-1 bg-black/50 backdrop-blur-sm text-white text-xs font-medium rounded border border-white/10 truncate block"
|
||||
title={filename}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<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>
|
||||
|
||||
{width && height && (
|
||||
<ImageMetadataBar
|
||||
width={width}
|
||||
height={height}
|
||||
fileSize={fileSize}
|
||||
fileType={fileType}
|
||||
showVisualIndicator={true}
|
||||
downloadMs={downloadTime}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type UseImageDownloadTimeReturn = {
|
||||
downloadTime: number | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
export const useImageDownloadTime = (imageUrl: string | null): UseImageDownloadTimeReturn => {
|
||||
const [downloadTime, setDownloadTime] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
setDownloadTime(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const measureDownloadTime = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const startTime = performance.now();
|
||||
const cacheBustUrl = `${imageUrl}?_t=${Date.now()}`;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
const endTime = performance.now();
|
||||
const duration = Math.round(endTime - startTime);
|
||||
setDownloadTime(duration);
|
||||
setIsLoading(false);
|
||||
resolve();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
setDownloadTime(null);
|
||||
setIsLoading(false);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
|
||||
img.src = cacheBustUrl;
|
||||
});
|
||||
} catch (error) {
|
||||
setDownloadTime(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
measureDownloadTime();
|
||||
}, [imageUrl]);
|
||||
|
||||
return { downloadTime, isLoading };
|
||||
};
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
'use client';
|
||||
|
||||
import { calculateAspectRatio, formatFileSize, getFileTypeFromMimeType, formatDuration } from '@/utils/imageUtils';
|
||||
import { getDownloadPerformance } from '@/utils/performanceColors';
|
||||
|
||||
interface ImageMetadataBarProps {
|
||||
width: number;
|
||||
height: number;
|
||||
fileSize: number;
|
||||
fileType: string;
|
||||
className?: string;
|
||||
showVisualIndicator?: boolean;
|
||||
downloadMs?: number | null;
|
||||
}
|
||||
|
||||
const AspectRatioIcon = ({ width, height }: { width: number; height: number }) => {
|
||||
const ratio = width / height;
|
||||
const maxSize = 16;
|
||||
|
||||
let rectWidth: number;
|
||||
let rectHeight: number;
|
||||
|
||||
if (ratio > 1) {
|
||||
rectWidth = maxSize;
|
||||
rectHeight = maxSize / ratio;
|
||||
} else {
|
||||
rectHeight = maxSize;
|
||||
rectWidth = maxSize * ratio;
|
||||
}
|
||||
|
||||
const x = (20 - rectWidth) / 2;
|
||||
const y = (20 - rectHeight) / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
className="flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect
|
||||
x={x}
|
||||
y={y}
|
||||
width={rectWidth}
|
||||
height={rectHeight}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-slate-400"
|
||||
rx="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 1 L6 8 M6 8 L3 5 M6 8 L9 5" />
|
||||
<path d="M1 11 L11 11" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ImageMetadataBar = ({
|
||||
width,
|
||||
height,
|
||||
fileSize,
|
||||
fileType,
|
||||
className = '',
|
||||
showVisualIndicator = true,
|
||||
downloadMs,
|
||||
}: ImageMetadataBarProps) => {
|
||||
const aspectRatio = calculateAspectRatio(width, height);
|
||||
const formattedSize = formatFileSize(fileSize);
|
||||
const formattedType = getFileTypeFromMimeType(fileType);
|
||||
|
||||
const dimensionsTooltip = `Image dimensions: ${width} pixels wide by ${height} pixels tall`;
|
||||
const aspectRatioTooltip = `Aspect ratio: ${aspectRatio} (width to height proportion)`;
|
||||
const fileSizeTooltip = `File size: ${formattedSize} (${fileSize.toLocaleString()} bytes)`;
|
||||
const fileTypeTooltip = `File format: ${formattedType} image`;
|
||||
const visualIndicatorTooltip = 'Aspect ratio visual indicator';
|
||||
|
||||
let downloadTooltip = '';
|
||||
let downloadTimeText = '';
|
||||
let downloadPerformance = null;
|
||||
|
||||
if (downloadMs !== null && downloadMs !== undefined) {
|
||||
downloadPerformance = getDownloadPerformance(downloadMs);
|
||||
downloadTimeText = formatDuration(downloadMs);
|
||||
downloadTooltip = `Download time: ${downloadTimeText} (${downloadPerformance.label}). ${downloadPerformance.description}`;
|
||||
}
|
||||
|
||||
const ariaLabel = `Image details: ${width} by ${height} pixels, ${aspectRatio} aspect ratio, ${formattedSize}, ${formattedType} format${downloadMs ? `, downloaded in ${downloadTimeText}` : ''}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-xs ${className}`}
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-white cursor-help whitespace-nowrap" title={dimensionsTooltip}>
|
||||
{width} × {height}
|
||||
</span>
|
||||
|
||||
<span className="text-gray-600" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1 text-gray-400 cursor-help whitespace-nowrap" title={aspectRatioTooltip}>
|
||||
<span>{aspectRatio}</span>
|
||||
{showVisualIndicator && (
|
||||
<span title={visualIndicatorTooltip}>
|
||||
<AspectRatioIcon width={width} height={height} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span className="text-gray-600" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
|
||||
<span className="text-gray-400 cursor-help whitespace-nowrap" title={fileSizeTooltip}>
|
||||
{formattedSize}
|
||||
</span>
|
||||
|
||||
<span className="text-gray-600" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
|
||||
<span className="text-gray-400 cursor-help whitespace-nowrap" title={fileTypeTooltip}>
|
||||
{formattedType}
|
||||
</span>
|
||||
|
||||
{downloadMs !== null && downloadMs !== undefined && downloadPerformance && (
|
||||
<>
|
||||
<span className="text-gray-600" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
|
||||
<span className="flex items-center gap-1.5 cursor-help whitespace-nowrap" title={downloadTooltip}>
|
||||
<DownloadIcon />
|
||||
<span className={downloadPerformance.color}>{downloadTimeText}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type UseIntersectionObserverOptions = {
|
||||
threshold?: number;
|
||||
rootMargin?: string;
|
||||
onIntersect: () => void;
|
||||
};
|
||||
|
||||
type UseIntersectionObserverReturn = {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
isIntersecting: boolean;
|
||||
};
|
||||
|
||||
export const useIntersectionObserver = ({
|
||||
onIntersect,
|
||||
threshold = 0.1,
|
||||
rootMargin = '0px',
|
||||
}: UseIntersectionObserverOptions): UseIntersectionObserverReturn => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isIntersecting, setIsIntersecting] = useState(false);
|
||||
const hasIntersectedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const intersecting = entry.isIntersecting;
|
||||
setIsIntersecting(intersecting);
|
||||
|
||||
if (intersecting && !hasIntersectedRef.current) {
|
||||
hasIntersectedRef.current = true;
|
||||
onIntersect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold,
|
||||
rootMargin,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [onIntersect, threshold, rootMargin]);
|
||||
|
||||
return { ref, isIntersecting };
|
||||
};
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Image utility functions for metadata processing
|
||||
*/
|
||||
|
||||
interface StandardRatio {
|
||||
ratio: string;
|
||||
decimal: number;
|
||||
tolerance: number;
|
||||
}
|
||||
|
||||
const STANDARD_RATIOS: StandardRatio[] = [
|
||||
{ ratio: '1:1', decimal: 1.0, tolerance: 0.02 }, // Square
|
||||
{ ratio: '4:3', decimal: 1.333, tolerance: 0.02 }, // Standard
|
||||
{ ratio: '3:2', decimal: 1.5, tolerance: 0.02 }, // Classic photo
|
||||
{ ratio: '16:10', decimal: 1.6, tolerance: 0.02 }, // Common monitor
|
||||
{ ratio: '16:9', decimal: 1.778, tolerance: 0.02 }, // Widescreen
|
||||
{ ratio: '21:9', decimal: 2.333, tolerance: 0.02 }, // Ultrawide
|
||||
{ ratio: '9:16', decimal: 0.5625, tolerance: 0.02 }, // Vertical video
|
||||
{ ratio: '2:3', decimal: 0.667, tolerance: 0.02 }, // Portrait photo
|
||||
{ ratio: '3:4', decimal: 0.75, tolerance: 0.02 }, // Portrait standard
|
||||
];
|
||||
|
||||
/**
|
||||
* Calculate aspect ratio with tolerance for standard ratios
|
||||
* @param width - Image width in pixels
|
||||
* @param height - Image height in pixels
|
||||
* @returns Aspect ratio as string (e.g., "16:9" or "1.78")
|
||||
*/
|
||||
export const calculateAspectRatio = (width: number, height: number): string => {
|
||||
const decimal = width / height;
|
||||
|
||||
// Check if it matches a standard ratio within tolerance
|
||||
for (const standard of STANDARD_RATIOS) {
|
||||
const diff = Math.abs(decimal - standard.decimal) / standard.decimal;
|
||||
if (diff <= standard.tolerance) {
|
||||
return standard.ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// If no match, return decimal with 2 places
|
||||
return decimal.toFixed(2);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format file size in human-readable format
|
||||
* @param bytes - File size in bytes
|
||||
* @returns Formatted string (e.g., "2.4 MB")
|
||||
*/
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract file type from MIME type
|
||||
* @param contentType - MIME type (e.g., "image/png")
|
||||
* @returns File type (e.g., "PNG")
|
||||
*/
|
||||
export const getFileTypeFromMimeType = (contentType: string): string => {
|
||||
const type = contentType.split('/')[1];
|
||||
return type ? type.toUpperCase() : 'UNKNOWN';
|
||||
};
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable format
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted string (e.g., "143ms" or "1.23s")
|
||||
*/
|
||||
export const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
export const DOWNLOAD_THRESHOLDS = {
|
||||
FAST: 200,
|
||||
MODERATE: 600,
|
||||
} as const;
|
||||
|
||||
export type DownloadPerformance = {
|
||||
color: string;
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const getDownloadPerformance = (downloadMs: number): DownloadPerformance => {
|
||||
if (downloadMs < DOWNLOAD_THRESHOLDS.FAST) {
|
||||
return {
|
||||
color: 'text-green-400',
|
||||
label: 'Excellent',
|
||||
description: 'CDN cache hit with optimal image size',
|
||||
};
|
||||
}
|
||||
|
||||
if (downloadMs < DOWNLOAD_THRESHOLDS.MODERATE) {
|
||||
return {
|
||||
color: 'text-yellow-400',
|
||||
label: 'Good',
|
||||
description: 'Cache hit but large file, or slower CDN response',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'text-red-400',
|
||||
label: 'Poor',
|
||||
description: 'CDN miss, large file size, or optimization needed',
|
||||
};
|
||||
};
|
||||
Loading…
Reference in New Issue