localgreenchain/components/upload/ImageUploader.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

266 lines
6.9 KiB
TypeScript

/**
* Image Uploader Component
* Agent 3: File Upload & Storage System
*
* Drag & drop image upload with preview and progress
*/
import React, { useState, useCallback, useRef } from 'react';
import type { FileCategory } from '../../lib/storage/types';
interface UploadedFile {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
size: number;
}
interface ImageUploaderProps {
category?: FileCategory;
plantId?: string;
farmId?: string;
userId?: string;
onUpload?: (file: UploadedFile) => void;
onError?: (error: string) => void;
maxFiles?: number;
accept?: string;
className?: string;
}
interface UploadState {
isUploading: boolean;
progress: number;
error?: string;
preview?: string;
}
export function ImageUploader({
category = 'plant-photo',
plantId,
farmId,
userId,
onUpload,
onError,
maxFiles = 1,
accept = 'image/*',
className = '',
}: ImageUploaderProps) {
const [uploadState, setUploadState] = useState<UploadState>({
isUploading: false,
progress: 0,
});
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const uploadFile = async (file: File) => {
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setUploadState((prev) => ({
...prev,
preview: e.target?.result as string,
}));
};
reader.readAsDataURL(file);
// Start upload
setUploadState((prev) => ({
...prev,
isUploading: true,
progress: 0,
error: undefined,
}));
const formData = new FormData();
formData.append('file', file);
formData.append('category', category);
if (plantId) formData.append('plantId', plantId);
if (farmId) formData.append('farmId', farmId);
if (userId) formData.append('userId', userId);
try {
const response = await fetch('/api/upload/image', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Upload failed');
}
setUploadState({
isUploading: false,
progress: 100,
preview: data.file.thumbnailUrl || data.file.url,
});
onUpload?.(data.file);
} catch (error) {
const message = error instanceof Error ? error.message : 'Upload failed';
setUploadState((prev) => ({
...prev,
isUploading: false,
error: message,
}));
onError?.(message);
}
};
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleFileChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []).slice(0, maxFiles);
if (files.length > 0) {
await uploadFile(files[0]);
}
},
[maxFiles]
);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleRemove = () => {
setUploadState({
isUploading: false,
progress: 0,
preview: undefined,
error: undefined,
});
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className={`relative ${className}`}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileChange}
className="hidden"
/>
{uploadState.preview ? (
<div className="relative rounded-lg overflow-hidden border-2 border-green-200">
<img
src={uploadState.preview}
alt="Uploaded preview"
className="w-full h-48 object-cover"
/>
<button
onClick={handleRemove}
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
title="Remove image"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
) : (
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
transition-colors duration-200
${isDragging
? 'border-green-500 bg-green-50'
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50'
}
${uploadState.isUploading ? 'pointer-events-none opacity-50' : ''}
`}
>
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="mt-2 text-sm text-gray-600">
<span className="text-green-600 font-medium">Click to upload</span>
{' '}or drag and drop
</p>
<p className="mt-1 text-xs text-gray-500">
PNG, JPG, GIF, WEBP up to 5MB
</p>
</div>
)}
{uploadState.isUploading && (
<div className="mt-2">
<div className="bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadState.progress}%` }}
/>
</div>
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
</div>
)}
{uploadState.error && (
<p className="mt-2 text-sm text-red-500 text-center">{uploadState.error}</p>
)}
</div>
);
}
export default ImageUploader;