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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user