Add full-resolution image modal with zoom and navigation capabilities

- 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 <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-27 08:58:33 -05:00
parent 868ef2eeaa
commit f6b651eeda
3 changed files with 502 additions and 22 deletions

View File

@ -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<string, string> = {
'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 }
)
}
}

View File

@ -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<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(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 = (
<div
className="fixed inset-0 bg-black bg-opacity-90 z-[9999] flex flex-col"
onClick={onClose}
>
{/* Header */}
<div className="flex items-center justify-between p-4 bg-black bg-opacity-50 text-white">
<div className="flex-1 min-w-0">
<h2 className="text-lg font-medium truncate">{photo.filename}</h2>
{metaInfo && (
<div className="text-sm text-gray-300 space-y-1">
{metaInfo.camera && <div>{metaInfo.camera}</div>}
{metaInfo.settings && <div>{metaInfo.settings}</div>}
<div className="flex gap-4 text-xs">
{metaInfo.dimensions && <span>{metaInfo.dimensions}</span>}
<span>{metaInfo.fileSize}</span>
{metaInfo.dateTime && <span>{formatDate(metaInfo.dateTime)}</span>}
</div>
</div>
)}
</div>
{/* Controls */}
<div className="flex items-center gap-2 ml-4">
{photos.length > 1 && (
<>
<button
onClick={(e) => { e.stopPropagation(); handlePrevious() }}
disabled={!hasPrevious}
className={`p-2 rounded transition-colors ${
hasPrevious
? 'hover:bg-white hover:bg-opacity-10'
: 'opacity-50 cursor-not-allowed'
}`}
title="Previous image (←)"
>
<IconChevronLeft className="w-5 h-5" />
</button>
<span className="text-sm px-2 min-w-[4rem] text-center">
{currentPhotoIndex + 1} / {photos.length}
</span>
<button
onClick={(e) => { e.stopPropagation(); handleNext() }}
disabled={!hasNext}
className={`p-2 rounded transition-colors ${
hasNext
? 'hover:bg-white hover:bg-opacity-10'
: 'opacity-50 cursor-not-allowed'
}`}
title="Next image (→)"
>
<IconChevronRight className="w-5 h-5" />
</button>
<div className="w-px h-6 bg-gray-600 mx-1" />
</>
)}
<button
onClick={(e) => { e.stopPropagation(); handleZoomOut() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Zoom out (-)"
>
<IconZoomOut className="w-5 h-5" />
</button>
<span className="text-sm px-2 min-w-[4rem] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={(e) => { e.stopPropagation(); handleZoomIn() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Zoom in (+)"
>
<IconZoomIn className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleResetZoom() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Fit to screen (0)"
>
<IconMaximize className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleRotate() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Rotate (R)"
>
<IconRotate className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); onClose() }}
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
title="Close (Esc)"
>
<IconX className="w-5 h-5" />
</button>
</div>
</div>
{/* Image Container */}
<div
ref={containerRef}
className="flex-1 relative overflow-hidden cursor-grab"
onClick={(e) => e.stopPropagation()}
onWheel={handleWheel}
>
{!imageLoaded && !imageError && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
</div>
)}
{imageError && (
<div className="absolute inset-0 flex items-center justify-center text-white">
<div className="text-center">
<div className="text-6xl mb-4">📷</div>
<div>Failed to load image</div>
<div className="text-sm text-gray-400 mt-2">{photo.filepath}</div>
</div>
</div>
)}
{photo && (
<img
ref={imageRef}
src={`/api/photos/${photo.id}/full`}
alt={photo.filename}
className={`absolute top-1/2 left-1/2 max-w-none transition-transform duration-200 ${
isDragging ? 'cursor-grabbing' : zoom > 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}
/>
)}
</div>
{/* Footer with keyboard shortcuts */}
<div className="p-2 bg-black bg-opacity-50 text-center text-xs text-gray-400">
Keyboard: <span className="text-gray-300">Esc</span> close <span className="text-gray-300"></span> navigate <span className="text-gray-300">+/-</span> zoom <span className="text-gray-300">0</span> fit <span className="text-gray-300">R</span> rotate <span className="text-gray-300">drag</span> to pan
</div>
</div>
)
return createPortal(modalContent, document.body)
}

View File

@ -2,6 +2,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import PhotoThumbnail from './PhotoThumbnail' import PhotoThumbnail from './PhotoThumbnail'
import ImageModal from './ImageModal'
import { Photo } from '@/types/photo' import { Photo } from '@/types/photo'
import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending } from '@tabler/icons-react' import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending } from '@tabler/icons-react'
@ -213,28 +214,14 @@ export default function PhotoGrid({
))} ))}
</div> </div>
{/* Photo Detail Modal (placeholder for future implementation) */} {/* Full Resolution Image Modal */}
{selectedPhoto && ( <ImageModal
<div photo={selectedPhoto}
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4" isOpen={!!selectedPhoto}
onClick={() => setSelectedPhoto(null)} onClose={() => setSelectedPhoto(null)}
> photos={filteredPhotos}
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full max-h-screen overflow-y-auto"> onNavigate={setSelectedPhoto}
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white"> />
{selectedPhoto.filename}
</h3>
<p className="text-gray-600 dark:text-gray-400">
Photo details modal - implement full photo viewer here
</p>
<button
onClick={() => setSelectedPhoto(null)}
className="mt-4 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
>
Close
</button>
</div>
</div>
)}
</div> </div>
) )
} }