localgreenchain/components/upload/PhotoGallery.tsx
Claude d74128d3cd
Add Agent 3: File Upload & Storage System
Implements cloud-based file storage for plant photos, documents, and certificates:

Storage Layer:
- Multi-provider support (AWS S3, Cloudflare R2, MinIO, local filesystem)
- S3-compatible provider with presigned URL generation
- Local storage provider for development with signed URL verification
- Configurable via environment variables

Image Processing:
- Automatic thumbnail generation (150x150, 300x300, 600x600, 1200x1200)
- WebP conversion for optimized file sizes
- EXIF data extraction for image metadata
- Image optimization with Sharp

API Endpoints:
- POST /api/upload/image - Upload images with automatic processing
- POST /api/upload/document - Upload documents (PDF, DOC, DOCX)
- POST /api/upload/presigned - Get presigned URLs for direct uploads
- GET/DELETE /api/upload/[fileId] - File management

UI Components:
- ImageUploader - Drag & drop image upload with preview
- PhotoGallery - Grid gallery with lightbox view
- DocumentUploader - Document upload with file type icons
- ProgressBar - Animated upload progress indicator

Database:
- FileStore service with in-memory storage (Prisma schema ready for Agent 2)
- File metadata tracking with soft delete support
- Category-based file organization
2025-11-23 03:51:31 +00:00

213 lines
6.7 KiB
TypeScript

/**
* Photo Gallery Component
* Agent 3: File Upload & Storage System
*
* Displays a grid of plant photos with lightbox view
*/
import React, { useState } from 'react';
interface Photo {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
caption?: string;
uploadedAt?: string;
}
interface PhotoGalleryProps {
photos: Photo[];
onDelete?: (photoId: string) => void;
editable?: boolean;
columns?: 2 | 3 | 4;
className?: string;
}
export function PhotoGallery({
photos,
onDelete,
editable = false,
columns = 3,
className = '',
}: PhotoGalleryProps) {
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
const [isDeleting, setIsDeleting] = useState<string | null>(null);
const handleDelete = async (photoId: string) => {
if (!onDelete) return;
setIsDeleting(photoId);
try {
await onDelete(photoId);
} finally {
setIsDeleting(null);
}
};
const gridCols = {
2: 'grid-cols-2',
3: 'grid-cols-2 sm:grid-cols-3',
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4',
};
if (photos.length === 0) {
return (
<div className={`text-center py-8 ${className}`}>
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="mt-2 text-sm text-gray-500">No photos yet</p>
</div>
);
}
return (
<>
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
{photos.map((photo) => (
<div
key={photo.id}
className="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
>
<img
src={photo.thumbnailUrl || photo.url}
alt={photo.caption || 'Plant photo'}
className="w-full h-full object-cover cursor-pointer transition-transform duration-200 group-hover:scale-105"
onClick={() => setSelectedPhoto(photo)}
loading="lazy"
/>
{/* Overlay on hover */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
<button
onClick={() => setSelectedPhoto(photo)}
className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-gray-100"
title="View full size"
>
<svg
className="w-5 h-5 text-gray-700"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
/>
</svg>
</button>
{editable && onDelete && (
<button
onClick={() => handleDelete(photo.id)}
disabled={isDeleting === photo.id}
className="opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-red-600 disabled:opacity-50"
title="Delete photo"
>
{isDeleting === photo.id ? (
<svg
className="w-5 h-5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
)}
</button>
)}
</div>
{/* Caption */}
{photo.caption && (
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.caption}</p>
</div>
)}
</div>
))}
</div>
{/* Lightbox */}
{selectedPhoto && (
<div
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
onClick={() => setSelectedPhoto(null)}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
onClick={() => setSelectedPhoto(null)}
>
<svg
className="w-8 h-8"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<img
src={selectedPhoto.url}
alt={selectedPhoto.caption || 'Plant photo'}
className="max-w-full max-h-full object-contain"
onClick={(e) => e.stopPropagation()}
/>
{selectedPhoto.caption && (
<div className="absolute bottom-4 left-4 right-4 text-center">
<p className="text-white text-lg">{selectedPhoto.caption}</p>
</div>
)}
</div>
)}
</>
);
}
export default PhotoGallery;