From f6b651eedac418530c63e3ef5c8c1d4634e0ec4e Mon Sep 17 00:00:00 2001 From: Michael Mainguy Date: Wed, 27 Aug 2025 08:58:33 -0500 Subject: [PATCH] Add full-resolution image modal with zoom and navigation capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ImageModal component with professional photo viewing experience - Support zoom in/out, pan, rotate, and full-screen display - Add photo navigation with arrow keys and previous/next buttons - Include comprehensive keyboard shortcuts and visual controls - Implement proper image centering and aspect ratio preservation - Create full-resolution image API endpoint with range request support - Display rich EXIF metadata in modal header with camera settings 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/app/api/photos/[id]/full/route.ts | 91 ++++++ src/components/ImageModal.tsx | 402 ++++++++++++++++++++++++++ src/components/PhotoGrid.tsx | 31 +- 3 files changed, 502 insertions(+), 22 deletions(-) create mode 100644 src/app/api/photos/[id]/full/route.ts create mode 100644 src/components/ImageModal.tsx diff --git a/src/app/api/photos/[id]/full/route.ts b/src/app/api/photos/[id]/full/route.ts new file mode 100644 index 0000000..f29ae23 --- /dev/null +++ b/src/app/api/photos/[id]/full/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from 'next/server' +import { photoService } from '@/lib/photo-service' +import { createReadStream, existsSync, statSync } from 'fs' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + + const photo = photoService.getPhoto(id) + + if (!photo) { + return NextResponse.json( + { error: 'Photo not found' }, + { status: 404 } + ) + } + + // Check if the file exists + if (!existsSync(photo.filepath)) { + return NextResponse.json( + { error: 'Photo file not found' }, + { status: 404 } + ) + } + + // Get file stats + const stats = statSync(photo.filepath) + const fileSize = stats.size + + // Determine content type based on file extension + const extension = photo.filepath.toLowerCase().split('.').pop() + const contentTypeMap: Record = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'bmp': 'image/bmp', + 'webp': 'image/webp', + 'tiff': 'image/tiff', + 'tif': 'image/tiff', + 'svg': 'image/svg+xml', + 'ico': 'image/x-icon' + } + + const contentType = contentTypeMap[extension || ''] || 'application/octet-stream' + + // Handle range requests for large images + const range = request.headers.get('range') + + if (range) { + const parts = range.replace(/bytes=/, '').split('-') + const start = parseInt(parts[0], 10) + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1 + const chunksize = (end - start) + 1 + + const stream = createReadStream(photo.filepath, { start, end }) + + return new NextResponse(stream as any, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize.toString(), + 'Content-Type': contentType, + }, + }) + } else { + // Serve full file + const stream = createReadStream(photo.filepath) + + return new NextResponse(stream as any, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Content-Length': fileSize.toString(), + 'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year + }, + }) + } + + } catch (error) { + console.error('Error serving full-resolution image:', error) + return NextResponse.json( + { error: 'Failed to serve image' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/components/ImageModal.tsx b/src/components/ImageModal.tsx new file mode 100644 index 0000000..78ff839 --- /dev/null +++ b/src/components/ImageModal.tsx @@ -0,0 +1,402 @@ +'use client' + +import { useState, useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { IconX, IconZoomIn, IconZoomOut, IconMaximize, IconRotate, IconChevronLeft, IconChevronRight } from '@tabler/icons-react' +import { Photo } from '@/types/photo' + +interface ImageModalProps { + photo: Photo | null + isOpen: boolean + onClose: () => void + photos?: Photo[] // Array of all photos for navigation + onNavigate?: (photo: Photo) => void // Callback for photo navigation +} + +export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavigate }: ImageModalProps) { + const [mounted, setMounted] = useState(false) + const [imageLoaded, setImageLoaded] = useState(false) + const [imageError, setImageError] = useState(false) + const [zoom, setZoom] = useState(1) + const [position, setPosition] = useState({ x: 0, y: 0 }) + const [isDragging, setIsDragging] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const [rotation, setRotation] = useState(0) + + const imageRef = useRef(null) + const containerRef = useRef(null) + + // Find current photo index for navigation + const currentPhotoIndex = photo ? photos.findIndex(p => p.id === photo.id) : -1 + const hasPrevious = currentPhotoIndex > 0 + const hasNext = currentPhotoIndex < photos.length - 1 + + useEffect(() => { + setMounted(true) + return () => setMounted(false) + }, []) + + // Reset state when photo changes + useEffect(() => { + if (photo) { + setImageLoaded(false) + setImageError(false) + setZoom(1) + setPosition({ x: 0, y: 0 }) + setRotation(0) + } + }, [photo]) + + // Navigation functions + const handlePrevious = () => { + if (hasPrevious && onNavigate) { + onNavigate(photos[currentPhotoIndex - 1]) + } + } + + const handleNext = () => { + if (hasNext && onNavigate) { + onNavigate(photos[currentPhotoIndex + 1]) + } + } + + // Handle keyboard shortcuts + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'Escape': + onClose() + break + case 'ArrowLeft': + e.preventDefault() + handlePrevious() + break + case 'ArrowRight': + e.preventDefault() + handleNext() + break + case '+': + case '=': + e.preventDefault() + handleZoomIn() + break + case '-': + e.preventDefault() + handleZoomOut() + break + case '0': + e.preventDefault() + handleResetZoom() + break + case 'r': + case 'R': + e.preventDefault() + handleRotate() + break + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, zoom, currentPhotoIndex]) + + // Calculate initial zoom to fit image in viewport + const calculateFitZoom = useCallback(() => { + if (!imageRef.current || !containerRef.current || !photo?.width || !photo?.height) { + return 1 + } + + const container = containerRef.current + const containerWidth = container.clientWidth - 64 // padding + const containerHeight = container.clientHeight - 64 // padding + + const scaleX = containerWidth / photo.width + const scaleY = containerHeight / photo.height + + return Math.min(scaleX, scaleY, 1) // Don't scale up smaller images + }, [photo?.width, photo?.height]) + + const handleImageLoad = () => { + setImageLoaded(true) + setImageError(false) + // Set initial zoom to fit the image + const fitZoom = calculateFitZoom() + setZoom(fitZoom) + } + + const handleImageError = () => { + setImageError(true) + setImageLoaded(false) + } + + const handleZoomIn = () => { + setZoom(prev => Math.min(prev * 1.25, 5)) + } + + const handleZoomOut = () => { + setZoom(prev => Math.max(prev * 0.8, 0.1)) + } + + const handleResetZoom = () => { + const fitZoom = calculateFitZoom() + setZoom(fitZoom) + setPosition({ x: 0, y: 0 }) + } + + const handleRotate = () => { + setRotation(prev => (prev + 90) % 360) + } + + // Mouse drag handling + const handleMouseDown = (e: React.MouseEvent) => { + if (zoom <= 1) return // Only allow dragging when zoomed in + + setIsDragging(true) + setDragStart({ + x: e.clientX - position.x, + y: e.clientY - position.y + }) + } + + const handleMouseMove = useCallback((e: MouseEvent) => { + if (!isDragging) return + + setPosition({ + x: e.clientX - dragStart.x, + y: e.clientY - dragStart.y + }) + }, [isDragging, dragStart]) + + const handleMouseUp = useCallback(() => { + setIsDragging(false) + }, []) + + // Attach global mouse events for dragging + useEffect(() => { + if (isDragging) { + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + } + }, [isDragging, handleMouseMove, handleMouseUp]) + + // Mouse wheel zoom + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault() + const delta = e.deltaY < 0 ? 1.1 : 0.9 + setZoom(prev => Math.min(Math.max(prev * delta, 0.1), 5)) + } + + if (!isOpen || !photo || !mounted) return null + + const formatMetadata = () => { + let metadata: any = {} + try { + metadata = photo.metadata ? JSON.parse(photo.metadata) : {} + } catch (error) { + return null + } + + const exif = metadata.exif || {} + return { + camera: [exif.camera_make, exif.camera_model].filter(Boolean).join(' '), + settings: [ + exif.f_number && `f/${exif.f_number}`, + exif.exposure_time && `${exif.exposure_time >= 1 ? exif.exposure_time : `1/${Math.round(1/exif.exposure_time)}`}s`, + exif.iso_speed && `ISO ${exif.iso_speed}`, + exif.focal_length && `${Math.round(exif.focal_length)}mm` + ].filter(Boolean).join(' • '), + dimensions: photo.width && photo.height ? `${photo.width} × ${photo.height}` : null, + fileSize: formatFileSize(photo.filesize), + dateTime: exif.date_time_original || photo.created_at + } + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const formatDate = (dateString: string) => { + try { + return new Date(dateString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } catch { + return 'Unknown' + } + } + + const metaInfo = formatMetadata() + + const modalContent = ( +
+ {/* Header */} +
+
+

{photo.filename}

+ {metaInfo && ( +
+ {metaInfo.camera &&
{metaInfo.camera}
} + {metaInfo.settings &&
{metaInfo.settings}
} +
+ {metaInfo.dimensions && {metaInfo.dimensions}} + {metaInfo.fileSize} + {metaInfo.dateTime && {formatDate(metaInfo.dateTime)}} +
+
+ )} +
+ + {/* Controls */} +
+ {photos.length > 1 && ( + <> + + + + {currentPhotoIndex + 1} / {photos.length} + + + + +
+ + )} + + + + + {Math.round(zoom * 100)}% + + + + + + + + + +
+
+ + {/* Image Container */} +
e.stopPropagation()} + onWheel={handleWheel} + > + {!imageLoaded && !imageError && ( +
+
+
+ )} + + {imageError && ( +
+
+
📷
+
Failed to load image
+
{photo.filepath}
+
+
+ )} + + {photo && ( + {photo.filename} 1 ? 'cursor-grab' : 'cursor-default' + }`} + style={{ + transform: `translate(calc(-50% + ${position.x}px), calc(-50% + ${position.y}px)) scale(${zoom}) rotate(${rotation}deg)`, + transformOrigin: 'center center' + }} + onLoad={handleImageLoad} + onError={handleImageError} + onMouseDown={handleMouseDown} + draggable={false} + /> + )} +
+ + {/* Footer with keyboard shortcuts */} +
+ Keyboard: Esc close • ←→ navigate • +/- zoom • 0 fit • R rotate • drag to pan +
+
+ ) + + return createPortal(modalContent, document.body) +} \ No newline at end of file diff --git a/src/components/PhotoGrid.tsx b/src/components/PhotoGrid.tsx index c9c9950..3d61b4f 100644 --- a/src/components/PhotoGrid.tsx +++ b/src/components/PhotoGrid.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import PhotoThumbnail from './PhotoThumbnail' +import ImageModal from './ImageModal' import { Photo } from '@/types/photo' import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending } from '@tabler/icons-react' @@ -213,28 +214,14 @@ export default function PhotoGrid({ ))}
- {/* Photo Detail Modal (placeholder for future implementation) */} - {selectedPhoto && ( -
setSelectedPhoto(null)} - > -
-

- {selectedPhoto.filename} -

-

- Photo details modal - implement full photo viewer here -

- -
-
- )} + {/* Full Resolution Image Modal */} + setSelectedPhoto(null)} + photos={filteredPhotos} + onNavigate={setSelectedPhoto} + /> ) } \ No newline at end of file