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

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;