feat: adjust image card

This commit is contained in:
Oleg Proskurin 2025-10-11 21:46:27 +07:00
parent f942480fc8
commit b7bb37f2a7
12 changed files with 806 additions and 75 deletions

View File

@ -5,6 +5,9 @@ import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey';
import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget'; import { CodeExamplesWidget } from '@/components/demo/CodeExamplesWidget';
import { ImageZoomModal } from '@/components/demo/ImageZoomModal'; import { ImageZoomModal } from '@/components/demo/ImageZoomModal';
import { SelectedFileCodePreview } from '@/components/demo/SelectedFileCodePreview'; 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_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const API_KEY_STORAGE_KEY = 'banatie_demo_api_key'; const API_KEY_STORAGE_KEY = 'banatie_demo_api_key';
@ -37,6 +40,10 @@ interface UploadHistoryItem {
size: number; size: number;
contentType: string; contentType: string;
durationMs: number; durationMs: number;
width?: number;
height?: number;
aspectRatio?: string;
downloadMs?: number;
} }
interface ApiKeyInfo { interface ApiKeyInfo {
@ -60,6 +67,11 @@ export default function DemoUploadPage() {
const [uploadError, setUploadError] = useState(''); const [uploadError, setUploadError] = useState('');
const [validationError, setValidationError] = useState(''); const [validationError, setValidationError] = useState('');
const [dragActive, setDragActive] = useState(false); const [dragActive, setDragActive] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
aspectRatio: string;
} | null>(null);
// History State // History State
const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]); const [uploadHistory, setUploadHistory] = useState<UploadHistoryItem[]>([]);
@ -219,6 +231,7 @@ export default function DemoUploadPage() {
setValidationError(error); setValidationError(error);
setSelectedFile(null); setSelectedFile(null);
setPreviewUrl(null); setPreviewUrl(null);
setImageDimensions(null);
return; return;
} }
@ -226,7 +239,20 @@ export default function DemoUploadPage() {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { 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); reader.readAsDataURL(file);
}; };
@ -300,12 +326,16 @@ export default function DemoUploadPage() {
size: result.data.size, size: result.data.size,
contentType: result.data.contentType, contentType: result.data.contentType,
durationMs, durationMs,
width: imageDimensions?.width,
height: imageDimensions?.height,
aspectRatio: imageDimensions?.aspectRatio,
}; };
setUploadHistory((prev) => [historyItem, ...prev]); setUploadHistory((prev) => [historyItem, ...prev]);
setSelectedFile(null); setSelectedFile(null);
setPreviewUrl(null); setPreviewUrl(null);
setImageDimensions(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
@ -319,15 +349,12 @@ export default function DemoUploadPage() {
} }
}; };
const formatFileSize = (bytes: number): string => { const handleDownloadMeasured = (itemId: string, downloadMs: number) => {
if (bytes < 1024) return `${bytes} B`; setUploadHistory((prev) =>
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; prev.map((item) =>
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; item.id === itemId ? { ...item, downloadMs } : item
}; )
);
const formatDuration = (ms: number): string => {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}; };
const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => { const generateUploadCodeExamples = (item: UploadHistoryItem, key: string, baseUrl: string) => {
@ -533,14 +560,23 @@ Body (form-data):
/> />
)} )}
<div className="text-center"> <div className="text-center">
<p className="text-white font-medium">{selectedFile.name}</p> <p className="text-white font-medium mb-2">{selectedFile.name}</p>
<p className="text-sm text-gray-400">{formatFileSize(selectedFile.size)}</p> {imageDimensions && (
<ImageMetadataBar
width={imageDimensions.width}
height={imageDimensions.height}
fileSize={selectedFile.size}
fileType={selectedFile.type}
showVisualIndicator={true}
/>
)}
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setSelectedFile(null); setSelectedFile(null);
setPreviewUrl(null); setPreviewUrl(null);
setImageDimensions(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; 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"> <div key={item.id} className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Column 1: Image Card */} {/* Column 1: Image Card */}
<div className="lg:col-span-1"> <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"> <ImageCard
<div imageUrl={item.url}
className="aspect-video bg-slate-800 rounded-lg mb-3 overflow-hidden cursor-pointer group relative" filename={item.originalName}
onClick={() => setZoomedImageUrl(item.url)} width={item.width}
role="button" height={item.height}
tabIndex={0} fileSize={item.size}
aria-label="View full size image" fileType={item.contentType}
onKeyDown={(e) => { timestamp={item.timestamp}
if (e.key === 'Enter' || e.key === ' ') { onZoom={setZoomedImageUrl}
e.preventDefault(); measureDownloadTime={true}
setZoomedImageUrl(item.url); onDownloadMeasured={(downloadMs) => handleDownloadMeasured(item.id, downloadMs)}
}
}}
>
<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>
</div> </div>
{/* Columns 2-3: API Code Examples Widget */} {/* Columns 2-3: API Code Examples Widget */}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 };
};

View File

@ -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>
);
};

View File

@ -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 };
};

View File

@ -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`;
};

View File

@ -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',
};
};