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
236 lines
6.8 KiB
TypeScript
236 lines
6.8 KiB
TypeScript
/**
|
|
* Document Uploader Component
|
|
* Agent 3: File Upload & Storage System
|
|
*
|
|
* Upload interface for documents (PDF, DOC, etc.)
|
|
*/
|
|
|
|
import React, { useState, useCallback, useRef } from 'react';
|
|
import type { FileCategory } from '../../lib/storage/types';
|
|
import ProgressBar from './ProgressBar';
|
|
|
|
interface UploadedDocument {
|
|
id: string;
|
|
url: string;
|
|
size: number;
|
|
mimeType: string;
|
|
originalName: string;
|
|
}
|
|
|
|
interface DocumentUploaderProps {
|
|
category?: FileCategory;
|
|
plantId?: string;
|
|
farmId?: string;
|
|
userId?: string;
|
|
onUpload?: (file: UploadedDocument) => void;
|
|
onError?: (error: string) => void;
|
|
accept?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function DocumentUploader({
|
|
category = 'document',
|
|
plantId,
|
|
farmId,
|
|
userId,
|
|
onUpload,
|
|
onError,
|
|
accept = '.pdf,.doc,.docx',
|
|
className = '',
|
|
}: DocumentUploaderProps) {
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
const [error, setError] = useState<string>();
|
|
const [uploadedFile, setUploadedFile] = useState<UploadedDocument | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const uploadFile = async (file: File) => {
|
|
setIsUploading(true);
|
|
setProgress(10);
|
|
setError(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 {
|
|
setProgress(30);
|
|
|
|
const response = await fetch('/api/upload/document', {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
|
|
setProgress(80);
|
|
|
|
const data = await response.json();
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || 'Upload failed');
|
|
}
|
|
|
|
setProgress(100);
|
|
setUploadedFile(data.file);
|
|
onUpload?.(data.file);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Upload failed';
|
|
setError(message);
|
|
onError?.(message);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
};
|
|
|
|
const handleFileChange = useCallback(
|
|
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
await uploadFile(file);
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleClick = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleRemove = () => {
|
|
setUploadedFile(null);
|
|
setProgress(0);
|
|
setError(undefined);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const getFileIcon = (mimeType: string) => {
|
|
if (mimeType === 'application/pdf') {
|
|
return (
|
|
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zm-3 9.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zm3 3c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={accept}
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
{uploadedFile ? (
|
|
<div className="flex items-center p-4 border rounded-lg bg-gray-50">
|
|
{getFileIcon(uploadedFile.mimeType)}
|
|
<div className="ml-3 flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900 truncate">
|
|
{uploadedFile.originalName}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{formatFileSize(uploadedFile.size)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<a
|
|
href={uploadedFile.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-green-600 hover:text-green-700"
|
|
title="Download"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
<button
|
|
onClick={handleRemove}
|
|
className="text-red-500 hover:text-red-600"
|
|
title="Remove"
|
|
>
|
|
<svg className="w-5 h-5" 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>
|
|
) : (
|
|
<button
|
|
onClick={handleClick}
|
|
disabled={isUploading}
|
|
className={`
|
|
w-full border-2 border-dashed rounded-lg p-6 text-center
|
|
transition-colors duration-200
|
|
${isUploading
|
|
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
|
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50 cursor-pointer'
|
|
}
|
|
`}
|
|
>
|
|
<svg
|
|
className="mx-auto h-10 w-10 text-gray-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
<p className="mt-2 text-sm text-gray-600">
|
|
<span className="text-green-600 font-medium">Click to upload</span>
|
|
{' '}a document
|
|
</p>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
PDF, DOC, DOCX up to 10MB
|
|
</p>
|
|
</button>
|
|
)}
|
|
|
|
{isUploading && (
|
|
<div className="mt-3">
|
|
<ProgressBar progress={progress} />
|
|
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="mt-2 text-sm text-red-500 text-center">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default DocumentUploader;
|