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:
parent
868ef2eeaa
commit
f6b651eeda
91
src/app/api/photos/[id]/full/route.ts
Normal file
91
src/app/api/photos/[id]/full/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
402
src/components/ImageModal.tsx
Normal file
402
src/components/ImageModal.tsx
Normal 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)
|
||||
}
|
@ -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({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Photo Detail Modal (placeholder for future implementation) */}
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full max-h-screen overflow-y-auto">
|
||||
<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>
|
||||
)}
|
||||
{/* Full Resolution Image Modal */}
|
||||
<ImageModal
|
||||
photo={selectedPhoto}
|
||||
isOpen={!!selectedPhoto}
|
||||
onClose={() => setSelectedPhoto(null)}
|
||||
photos={filteredPhotos}
|
||||
onNavigate={setSelectedPhoto}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user