From b7bb37f2a73b4064dce1178f6c5d8a497b9279bb Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sat, 11 Oct 2025 21:46:27 +0700 Subject: [PATCH] feat: adjust image card --- .../src/components/shared/ImageCard/index.tsx | 0 .../shared/ImageCard/useImageDownloadTime.ts | 0 apps/landing/src/app/demo/upload/page.tsx | 135 +++++++------- .../demo/gallery/EmptyGalleryState.tsx | 40 +++++ .../demo/gallery/GalleryImageCard.tsx | 164 ++++++++++++++++++ .../src/components/demo/gallery/ImageGrid.tsx | 36 ++++ .../src/components/shared/ImageCard/index.tsx | 126 ++++++++++++++ .../shared/ImageCard/useImageDownloadTime.ts | 57 ++++++ .../components/shared/ImageMetadataBar.tsx | 161 +++++++++++++++++ .../src/hooks/useIntersectionObserver.ts | 55 ++++++ apps/landing/src/utils/imageUtils.ts | 73 ++++++++ apps/landing/src/utils/performanceColors.ts | 34 ++++ 12 files changed, 806 insertions(+), 75 deletions(-) create mode 100644 apps/landing/apps/landing/src/components/shared/ImageCard/index.tsx create mode 100644 apps/landing/apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts create mode 100644 apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx create mode 100644 apps/landing/src/components/demo/gallery/GalleryImageCard.tsx create mode 100644 apps/landing/src/components/demo/gallery/ImageGrid.tsx create mode 100644 apps/landing/src/components/shared/ImageCard/index.tsx create mode 100644 apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts create mode 100644 apps/landing/src/components/shared/ImageMetadataBar.tsx create mode 100644 apps/landing/src/hooks/useIntersectionObserver.ts create mode 100644 apps/landing/src/utils/imageUtils.ts create mode 100644 apps/landing/src/utils/performanceColors.ts diff --git a/apps/landing/apps/landing/src/components/shared/ImageCard/index.tsx b/apps/landing/apps/landing/src/components/shared/ImageCard/index.tsx new file mode 100644 index 0000000..e69de29 diff --git a/apps/landing/apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts b/apps/landing/apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/landing/src/app/demo/upload/page.tsx b/apps/landing/src/app/demo/upload/page.tsx index 11db857..9c3d16b 100644 --- a/apps/landing/src/app/demo/upload/page.tsx +++ b/apps/landing/src/app/demo/upload/page.tsx @@ -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([]); @@ -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): /> )}
-

{selectedFile.name}

-

{formatFileSize(selectedFile.size)}

+

{selectedFile.name}

+ {imageDimensions && ( + + )}
- - + handleDownloadMeasured(item.id, downloadMs)} + /> {/* Columns 2-3: API Code Examples Widget */} diff --git a/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx b/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx new file mode 100644 index 0000000..cb8ba83 --- /dev/null +++ b/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx @@ -0,0 +1,40 @@ +'use client'; + +import Link from 'next/link'; + +export const EmptyGalleryState = () => { + return ( +
+
+ + + +
+ +

+ No Generated Images Yet +

+ +

+ Your generated images will appear here. Start creating AI-powered images using the text-to-image tool. +

+ + + Generate Images + +
+ ); +}; diff --git a/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx b/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx new file mode 100644 index 0000000..b3bfd9c --- /dev/null +++ b/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx @@ -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 ( +
+
onZoom(imageUrl)} + role="button" + tabIndex={0} + aria-label="View full size image" + onKeyDown={handleKeyDown} + > + {isVisible ? ( + <> + {filename} + +
+
+ + {formatTimestamp(lastModified)} + +
+ +
+ + {filename} + +
+ +
+ + + +
+
+ + ) : ( +
+
+ + + +
+
+ )} +
+ + {imageDimensions && ( + + )} +
+ ); +}; diff --git a/apps/landing/src/components/demo/gallery/ImageGrid.tsx b/apps/landing/src/components/demo/gallery/ImageGrid.tsx new file mode 100644 index 0000000..e4999cd --- /dev/null +++ b/apps/landing/src/components/demo/gallery/ImageGrid.tsx @@ -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 ( +
+ {images.map((image) => ( + + ))} +
+ ); +}; diff --git a/apps/landing/src/components/shared/ImageCard/index.tsx b/apps/landing/src/components/shared/ImageCard/index.tsx new file mode 100644 index 0000000..d041f93 --- /dev/null +++ b/apps/landing/src/components/shared/ImageCard/index.tsx @@ -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 ( +
+
onZoom(imageUrl)} + role="button" + tabIndex={0} + aria-label="View full size image" + onKeyDown={handleKeyDown} + > + {filename} + +
+ {timestamp && ( +
+ + {formatTimestamp(timestamp)} + +
+ )} + +
+ + {filename} + +
+ +
+ + + +
+
+
+ + {width && height && ( + + )} +
+ ); +}; diff --git a/apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts b/apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts new file mode 100644 index 0000000..ff38aaf --- /dev/null +++ b/apps/landing/src/components/shared/ImageCard/useImageDownloadTime.ts @@ -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(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((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 }; +}; diff --git a/apps/landing/src/components/shared/ImageMetadataBar.tsx b/apps/landing/src/components/shared/ImageMetadataBar.tsx new file mode 100644 index 0000000..66f81e6 --- /dev/null +++ b/apps/landing/src/components/shared/ImageMetadataBar.tsx @@ -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 ( + + ); +}; + +const DownloadIcon = () => ( + +); + +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 ( +
+
+ + {width} × {height} + + + + + + {aspectRatio} + {showVisualIndicator && ( + + + + )} + + + + + + {formattedSize} + + + + + + {formattedType} + + + {downloadMs !== null && downloadMs !== undefined && downloadPerformance && ( + <> + + + + + {downloadTimeText} + + + )} +
+
+ ); +}; diff --git a/apps/landing/src/hooks/useIntersectionObserver.ts b/apps/landing/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..1765e58 --- /dev/null +++ b/apps/landing/src/hooks/useIntersectionObserver.ts @@ -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; + isIntersecting: boolean; +}; + +export const useIntersectionObserver = ({ + onIntersect, + threshold = 0.1, + rootMargin = '0px', +}: UseIntersectionObserverOptions): UseIntersectionObserverReturn => { + const ref = useRef(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 }; +}; diff --git a/apps/landing/src/utils/imageUtils.ts b/apps/landing/src/utils/imageUtils.ts new file mode 100644 index 0000000..6a1d25b --- /dev/null +++ b/apps/landing/src/utils/imageUtils.ts @@ -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`; +}; diff --git a/apps/landing/src/utils/performanceColors.ts b/apps/landing/src/utils/performanceColors.ts new file mode 100644 index 0000000..54d5246 --- /dev/null +++ b/apps/landing/src/utils/performanceColors.ts @@ -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', + }; +};