215 lines
6.6 KiB
TypeScript
215 lines
6.6 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useRef, type DragEvent } from 'react';
|
|
import { Button } from '@/components/ui/Button';
|
|
import {
|
|
uploadFile,
|
|
ALLOWED_MIME_TYPES,
|
|
MAX_FILE_SIZE_MB,
|
|
MAX_FILE_SIZE_BYTES,
|
|
type FileResponse,
|
|
} from '@/lib/files';
|
|
|
|
interface FileUploadProps {
|
|
vehicleId: string;
|
|
onUploadComplete?: (file: FileResponse) => void;
|
|
onUploadError?: (error: string) => void;
|
|
}
|
|
|
|
interface UploadStatus {
|
|
filename: string;
|
|
status: 'pending' | 'uploading' | 'success' | 'error';
|
|
error?: string;
|
|
}
|
|
|
|
export function FileUpload({ vehicleId, onUploadComplete, onUploadError }: FileUploadProps) {
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [uploadStatuses, setUploadStatuses] = useState<UploadStatus[]>([]);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const validateFile = (file: File): string | null => {
|
|
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
|
return `Unsupported file type: ${file.type || 'unknown'}`;
|
|
}
|
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
return `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const handleFiles = useCallback(async (files: FileList) => {
|
|
setIsUploading(true);
|
|
const fileArray = Array.from(files);
|
|
const statuses: UploadStatus[] = fileArray.map((f) => ({
|
|
filename: f.name,
|
|
status: 'pending' as const,
|
|
}));
|
|
setUploadStatuses(statuses);
|
|
|
|
for (let i = 0; i < fileArray.length; i++) {
|
|
const file = fileArray[i];
|
|
const validationError = validateFile(file);
|
|
|
|
if (validationError) {
|
|
setUploadStatuses((prev) =>
|
|
prev.map((s, idx) =>
|
|
idx === i ? { ...s, status: 'error', error: validationError } : s
|
|
)
|
|
);
|
|
onUploadError?.(validationError);
|
|
continue;
|
|
}
|
|
|
|
setUploadStatuses((prev) =>
|
|
prev.map((s, idx) =>
|
|
idx === i ? { ...s, status: 'uploading' } : s
|
|
)
|
|
);
|
|
|
|
try {
|
|
const result = await uploadFile(vehicleId, file);
|
|
setUploadStatuses((prev) =>
|
|
prev.map((s, idx) =>
|
|
idx === i ? { ...s, status: 'success' } : s
|
|
)
|
|
);
|
|
onUploadComplete?.(result);
|
|
} catch (err: unknown) {
|
|
const apiErr = err as { error?: { message?: string } };
|
|
const errorMsg = apiErr?.error?.message || 'Upload failed';
|
|
setUploadStatuses((prev) =>
|
|
prev.map((s, idx) =>
|
|
idx === i ? { ...s, status: 'error', error: errorMsg } : s
|
|
)
|
|
);
|
|
onUploadError?.(errorMsg);
|
|
}
|
|
}
|
|
|
|
setIsUploading(false);
|
|
// Clear statuses after 3 seconds
|
|
setTimeout(() => setUploadStatuses([]), 3000);
|
|
}, [vehicleId, onUploadComplete, onUploadError]);
|
|
|
|
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(true);
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, []);
|
|
|
|
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDragging(false);
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
handleFiles(e.dataTransfer.files);
|
|
}
|
|
}, [handleFiles]);
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
if (e.target.files && e.target.files.length > 0) {
|
|
handleFiles(e.target.files);
|
|
// Reset input so same file can be selected again
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="w-full" data-testid="file-upload">
|
|
<div
|
|
onDragEnter={handleDragEnter}
|
|
onDragLeave={handleDragLeave}
|
|
onDragOver={handleDragOver}
|
|
onDrop={handleDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
className={`
|
|
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
|
|
${isDragging
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-border hover:border-primary/50'
|
|
}
|
|
`}
|
|
data-testid="dropzone"
|
|
>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
multiple
|
|
accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx"
|
|
onChange={handleInputChange}
|
|
className="hidden"
|
|
data-testid="file-input"
|
|
/>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<svg
|
|
className="h-12 w-12 text-text-muted"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
<p className="text-text font-medium">
|
|
{isDragging ? 'Drop files here' : 'Drag & drop files or click to browse'}
|
|
</p>
|
|
<p className="text-text-muted text-sm">
|
|
Supports: JPG, PNG, WebP, PDF, DOC, DOCX (max {MAX_FILE_SIZE_MB}MB)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{uploadStatuses.length > 0 && (
|
|
<div className="mt-4 space-y-2" data-testid="upload-statuses">
|
|
{uploadStatuses.map((status, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={`flex items-center justify-between p-2 rounded ${
|
|
status.status === 'success'
|
|
? 'bg-success/10 text-success'
|
|
: status.status === 'error'
|
|
? 'bg-error/10 text-error'
|
|
: 'bg-surface text-text'
|
|
}`}
|
|
data-testid={`upload-status-${idx}`}
|
|
>
|
|
<span className="text-sm">{status.filename}</span>
|
|
<span className="text-sm font-medium">
|
|
{status.status === 'pending' && 'Pending...'}
|
|
{status.status === 'uploading' && 'Uploading...'}
|
|
{status.status === 'success' && '✓ Uploaded'}
|
|
{status.status === 'error' && `✗ ${status.error}`}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{isUploading && (
|
|
<div className="mt-2">
|
|
<Button variant="secondary" disabled loading>
|
|
Uploading...
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|