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
266 lines
6.9 KiB
TypeScript
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;
|