import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import axios from 'axios'; import { CheckCircle, Loader2 } from 'lucide-react'; import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import InputError from './input-error'; interface ChunkedUploaderInputProps { storage?: 's3' | 'local'; isSubmit: boolean; filetype: string; courseId?: string | number; sectionId?: string | number; delayUpload?: boolean; onError?: (message: string) => void; onCancelUpload?: () => void; onFileSelected?: (file: File) => void; onFileUploaded?: (fileData: any) => void; } export interface UploadedFileData { file_path: string; file_url: string; signed_url: string; mime_type: string; file_name: string; file_size: number; } const ChunkedUploaderInput: FC = ({ storage, isSubmit, courseId, sectionId, filetype, delayUpload = false, onError, onCancelUpload, onFileSelected, onFileUploaded, }) => { // Use external file if provided, or manage internally const [file, setFile] = useState(null); const [uploadId, setUploadId] = useState(null); const [errorMessage, setErrorMessage] = useState(''); const [uploadProgress, setUploadProgress] = useState(0); const [uploadStatus, setUploadStatus] = useState<'idle' | 'initializing' | 'uploading' | 'completing' | 'completed' | 'error'>('idle'); const fileInputRef = useRef(null); const abortControllerRef = useRef(null); const maxFileSize = 1024 * 1024 * 1024; const chunkSize = 5 * 1024 * 1024; // Configure axios to automatically handle CSRF tokens useEffect(() => { // Set up axios defaults for CSRF protection axios.defaults.withCredentials = true; // Get CSRF token from cookie if available const token = document.cookie .split('; ') .find((row) => row.startsWith('XSRF-TOKEN=')) ?.split('=')[1]; if (token) { axios.defaults.headers.common['X-XSRF-TOKEN'] = decodeURIComponent(token); } else { // If no XSRF-TOKEN cookie, try to get it from meta tag as fallback const metaToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); if (metaToken) { axios.defaults.headers.common['X-CSRF-TOKEN'] = metaToken; } } }, []); // Start upload automatically if external file is provided and delayUpload is false useEffect(() => { if (isSubmit) { initiateUpload(); } }, [isSubmit]); const handleFileChange = (event: ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { const selectedFile = event.target.files[0]; // Validate file size if (selectedFile.size > maxFileSize) { setErrorMessage(`File is too large. Maximum file size is ${maxFileSize / (1024 * 1024)} MB`); return; } setFile(selectedFile); setErrorMessage(''); setUploadStatus('idle'); setUploadProgress(0); // If using delayed upload, notify parent but don't upload yet if (delayUpload) { if (onFileSelected) { onFileSelected(selectedFile); } } else { // Upload immediately if not using delayed upload initiateUpload(); } } }; const initiateUpload = async () => { if (!file) return; setUploadStatus('initializing'); setErrorMessage(''); try { // Calculate total chunks const totalChunks = Math.ceil(file.size / chunkSize); // Initiate upload on the server const response = await axios.post( '/dashboard/uploads/chunked/initialize', { storage: storage, filename: file.name, mimetype: file.type, filesize: (file.size || 0) / 1024, filetype: filetype, total_chunks: totalChunks, course_id: courseId, course_section_id: sectionId, }, { timeout: 120000, // 2 minutes timeout for initialization request }, ); if (response.data.success) { setUploadId(response.data.upload_id); await uploadChunks(response.data.upload_id, totalChunks); } else { throw new Error(response.data.message || 'Failed to initialize upload'); } } catch (error: any) { setUploadStatus('error'); setErrorMessage(error.response?.data?.message || error.message || 'Failed to initialize upload'); if (onError) onError(error.response?.data?.message || error.message || 'Failed to initialize upload'); } }; const uploadChunks = async (uploadId: number, totalChunks: number) => { if (!file) return; setUploadStatus('uploading'); // Create a new AbortController for this upload abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; try { const uploadedParts: { PartNumber: number; ETag: string }[] = []; let uploadedChunks = 0; // Process chunks sequentially to avoid overwhelming the server for (let chunkIndex = 0; chunkIndex < totalChunks && !signal.aborted; chunkIndex++) { const start = chunkIndex * chunkSize; const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); // Convert chunk to base64 string instead of sending as file const reader = new FileReader(); // Create a promise to handle FileReader async behavior const readChunk = new Promise((resolve, reject) => { reader.onloadend = () => { const base64data = reader.result as string; resolve(base64data); }; reader.onerror = () => reject(reader.error); reader.readAsDataURL(chunk); }); // Wait for the chunk to be read as base64 const base64data = await readChunk; // Send the chunk as base64 encoded data instead of FormData const response = await axios.post( `/dashboard/uploads/chunked/${uploadId}/chunk`, { storage: storage, chunk_data: base64data, part_number: chunkIndex + 1, filename: file.name, mimetype: file.type, }, { signal: signal, timeout: 120000, // 2 minute timeout for chunk uploads maxContentLength: Infinity, // Allow large content maxBodyLength: Infinity, // Allow large body }, ); if (response.data.success) { uploadedChunks++; const progress = Math.round((uploadedChunks / totalChunks) * 100); setUploadProgress(progress); uploadedParts.push({ PartNumber: chunkIndex + 1, ETag: response.data.etag || '', // The server should return the ETag }); } else { throw new Error(response.data.message || 'Failed to upload chunk'); } } if (signal.aborted) { return; // Upload was cancelled } // Complete the upload await completeUpload(uploadId); } catch (error: any) { if (signal.aborted) { setUploadStatus('idle'); setUploadProgress(0); return; } setUploadStatus('error'); setErrorMessage(error.response?.data?.message || error.message || 'Failed to upload file chunks'); if (onError) onError(error.response?.data?.message || error.message || 'Failed to upload file chunks'); } }; const completeUpload = async (uploadId: number) => { setUploadStatus('completing'); try { const response = await axios.post( `/dashboard/uploads/chunked/${uploadId}/complete`, { storage: storage, }, { timeout: 120000, // 2 minute timeout for completion request }, ); if (response.data.success) { setUploadStatus('completed'); const fileData: UploadedFileData = { file_path: response.data.file_path, file_url: response.data.file_url, signed_url: response.data.signed_url, mime_type: response.data.mime_type, file_name: response.data.file_name, file_size: response.data.file_size, }; onFileUploaded?.(fileData); // Reset the uploader state for potential future uploads setTimeout(() => { if (fileInputRef.current) fileInputRef.current.value = ''; setFile(null); setUploadId(null); setUploadProgress(0); setUploadStatus('idle'); }, 3000); } else { throw new Error(response.data.message || 'Failed to complete upload'); } } catch (error: any) { setUploadStatus('error'); setErrorMessage(error.response?.data?.message || error.message || 'Failed to complete upload'); if (onError) onError(error.response?.data?.message || error.message || 'Failed to complete upload'); } }; const cancelUpload = async () => { if (uploadId && uploadStatus !== 'idle' && uploadStatus !== 'completed') { onCancelUpload?.(); // Abort any in-progress network requests if (abortControllerRef.current) { abortControllerRef.current.abort(); } try { // Inform the server to abort the multipart upload await axios.delete(`/dashboard/uploads/chunked/${uploadId}/abort`); } catch (error) { toast.error('Error aborting upload:' + error); } // Reset UI state setUploadStatus('idle'); setUploadProgress(0); if (fileInputRef.current) fileInputRef.current.value = ''; setFile(null); } }; const renderStatus = () => { switch (uploadStatus) { case 'initializing': return (
Initializing upload...
); case 'uploading': return (
Uploading file chunks...
); case 'completing': return (
Finalizing upload...
); case 'completed': return (
Completed upload
); // case 'error': // return ( //
// // Error: {errorMessage} //
// ); default: return null; } }; return (
{ handleFileChange(e); const file = e.target.files?.[0]; if (file) { setFile(file); onFileSelected?.(file); } }} /> {uploadStatus === 'uploading' && file && (
{uploadProgress}% {renderStatus()} Size: ({(file ? file.size / (1024 * 1024) : 0).toFixed(2)} MB)
)}
{errorMessage && }
); }; export default ChunkedUploaderInput;