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
213 lines
6.7 KiB
TypeScript
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;
|