UNPKG

@health-ecosystem/file-upload

Version:

File upload library for Health Ecosystem applications with improved Next.js 15+ compatibility

1,244 lines (1,196 loc) 217 kB
'use strict'; var React = require('react'); var axios = require('axios'); var jsxRuntime = require('react/jsx-runtime'); var lucideReact = require('lucide-react'); var ReactDOM = require('react-dom'); var clsx = require('clsx'); var tailwindMerge = require('tailwind-merge'); var classVarianceAuthority = require('class-variance-authority'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var React__namespace = /*#__PURE__*/_interopNamespaceDefault(React); var ReactDOM__namespace = /*#__PURE__*/_interopNamespaceDefault(ReactDOM); /** * React Compatibility Layer * Handles differences between React versions (18, 19+) */ // Check React version const REACT_VERSION = React.version; const IS_REACT_19_PLUS = parseInt(React.version.split('.')[0]) >= 19; const IS_REACT_18 = React.version.startsWith('18.'); // Export compatibility info const REACT_COMPAT_INFO = { version: REACT_VERSION, isReact19Plus: IS_REACT_19_PLUS, isReact18: IS_REACT_18, supportsNewJSXTransform: true, // Both 18 and 19+ support this supportsConcurrentFeatures: true }; /** * File Upload Client * Production-ready client for Health Ecosystem file uploads */ class FileUploadClient { constructor(config) { this.cancelTokens = new Map(); const defaultAllowedTypes = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'text/plain', 'text/csv' ]; this.config = { maxFileSize: config.maxFileSize || 50 * 1024 * 1024, // 50MB allowedTypes: config.allowedTypes || defaultAllowedTypes, timeout: config.timeout || 30000, // 30 seconds enableRetry: config.enableRetry ?? true, maxRetries: config.maxRetries || 3, baseUrl: config.baseUrl, authToken: config.authToken }; this.client = axios.create({ baseURL: this.config.baseUrl, timeout: this.config.timeout, headers: { 'Authorization': `Bearer ${this.config.authToken}` } }); // Add response interceptor for error handling this.client.interceptors.response.use((response) => response, (error) => this.handleError(error)); } /** * Upload a single file */ async uploadFile(file, options = { category: 'other' }, callbacks) { const uploadId = this.generateUploadId(); try { // Validate file this.validateFile(file); // Create cancel token const cancelToken = axios.CancelToken.source(); this.cancelTokens.set(uploadId, cancelToken); // Prepare form data const formData = new FormData(); formData.append('file', file); formData.append('file_category', options.category || 'other'); if (options.entityType) { formData.append('associated_entity_type', options.entityType); } if (options.entityId) { formData.append('associated_entity_id', options.entityId); } formData.append('is_public', String(options.isPublic || false)); if (options.metadata) { formData.append('metadata', options.metadata); } // Call onStart callback options.onStart?.(); // Upload with progress tracking const response = await this.client.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data' }, cancelToken: cancelToken.token, onUploadProgress: (progressEvent) => { if (progressEvent.total) { const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total); options.onProgress?.(progress); callbacks?.onProgress?.(progress); } } }); // Clean up cancel token this.cancelTokens.delete(uploadId); // Parse response const result = this.parseUploadResponse(response.data); // Call success callback options.onSuccess?.(result); return result; } catch (error) { // Clean up cancel token this.cancelTokens.delete(uploadId); const uploadError = this.createUploadError(error); // Call error callback options.onError?.(uploadError); throw uploadError; } } /** * Upload multiple files */ async uploadFiles(files, options) { const results = []; const errors = []; for (let i = 0; i < files.length; i++) { try { const fileOptions = { ...options, onProgress: (progress) => { const overallProgress = ((i * 100) + progress) / files.length; options.onProgress?.(Math.round(overallProgress)); } }; const result = await this.uploadFile(files[i], fileOptions); results.push(result); } catch (error) { errors.push(error); } } if (errors.length > 0 && results.length === 0) { throw new Error(`All uploads failed: ${errors.map(e => e.message).join(', ')}`); } return results; } /** * Upload file from URL * Downloads the file from the provided URL and uploads it to the server */ async uploadFromUrl(url, options) { const uploadId = this.generateUploadId(); try { // Validate URL if (!this.isValidUrl(url)) { throw new Error('Invalid URL provided'); } // Call start callback options.onStart?.(); // Create cancel token const cancelToken = axios.CancelToken.source(); this.cancelTokens.set(uploadId, cancelToken); // Step 1: Download file from URL with progress tracking options.onProgress?.(10); // Initial progress const fileResponse = await axios.get(url, { responseType: 'blob', cancelToken: cancelToken.token, onDownloadProgress: (progressEvent) => { if (progressEvent.total) { // Use 10-60% for download progress const downloadProgress = Math.round((progressEvent.loaded * 50) / progressEvent.total) + 10; options.onProgress?.(downloadProgress); } } }); // Step 2: Extract filename and content type const filename = options.filename || this.extractFilenameFromUrl(url) || 'downloaded-file'; const contentType = fileResponse.headers['content-type'] || 'application/octet-stream'; // Create File object from blob const file = new File([fileResponse.data], filename, { type: contentType }); // Validate file size if (file.size > this.config.maxFileSize) { throw new Error(`File size (${file.size} bytes) exceeds maximum allowed size (${this.config.maxFileSize} bytes)`); } // Validate file type if (!this.isAllowedFileType(file.type)) { throw new Error(`File type ${file.type} is not allowed`); } options.onProgress?.(70); // Download complete, starting upload // Step 3: Upload the file const formData = new FormData(); formData.append('file', file); // Add metadata if (options.category) formData.append('file_category', options.category); if (options.entityType) formData.append('entity_type', options.entityType); if (options.entityId) formData.append('entity_id', options.entityId); if (options.isPublic !== undefined) formData.append('is_public', options.isPublic.toString()); if (options.metadata) formData.append('metadata', options.metadata); // Add source URL as metadata const sourceMetadata = { source_url: url, upload_method: 'url' }; const existingMetadata = options.metadata ? JSON.parse(options.metadata) : {}; formData.append('metadata', JSON.stringify({ ...existingMetadata, ...sourceMetadata })); const response = await this.client.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, cancelToken: cancelToken.token, onUploadProgress: (progressEvent) => { if (progressEvent.total) { // Use 70-100% for upload progress const uploadProgress = Math.round((progressEvent.loaded * 30) / progressEvent.total) + 70; options.onProgress?.(uploadProgress); } } }); // Clean up cancel token this.cancelTokens.delete(uploadId); // Parse response const result = this.parseUploadResponse(response.data); // Call success callback options.onSuccess?.(result); return result; } catch (error) { // Clean up cancel token this.cancelTokens.delete(uploadId); if (axios.isCancel(error)) { const cancelError = { code: 'UPLOAD_CANCELLED', message: 'URL upload was cancelled', status: 0 }; options.onError?.(cancelError); throw cancelError; } const uploadError = this.createUploadError(error); // Call error callback options.onError?.(uploadError); throw uploadError; } } /** * Get file metadata */ async getFileMetadata(fileId) { try { const response = await this.client.get(`/files/${fileId}`); return this.parseFileMetadata(response.data.data); } catch (error) { throw this.createUploadError(error); } } /** * Get download URL for a file */ getDownloadUrl(fileId) { return `${this.config.baseUrl}/files/${fileId}/download`; } /** * Get thumbnail URL for an image */ getThumbnailUrl(fileId) { return `${this.config.baseUrl}/files/${fileId}/thumbnail`; } /** * Get preview URL for a file with optional transformations * @param previewUrl The preview URL from the file response * @param options Optional transformation parameters * @returns Full URL to the preview */ getPreviewUrl(previewUrl, options) { if (!previewUrl) return ''; const baseUrl = `${this.config.baseUrl}${previewUrl}`; const params = new URLSearchParams(); if (options?.thumbnail) params.append('thumbnail', 'true'); if (options?.width) params.append('width', options.width.toString()); if (options?.height) params.append('height', options.height.toString()); if (options?.quality) params.append('quality', options.quality.toString()); if (options?.format) params.append('format', options.format); const queryString = params.toString(); return queryString ? `${baseUrl}?${queryString}` : baseUrl; } /** * Delete a file */ async deleteFile(fileId) { try { await this.client.delete(`/files/${fileId}`); } catch (error) { throw this.createUploadError(error); } } /** * List files with optional filters - Updated to match backend API structure */ async listFiles(options) { try { const params = new URLSearchParams(); // Set default pagination params.append('skip', (options?.skip || 0).toString()); params.append('limit', (options?.limit || 100).toString()); if (options?.category) params.append('file_category', options.category); if (options?.entityType) params.append('associated_entity_type', options.entityType); if (options?.entityId) params.append('associated_entity_id', options.entityId); if (options?.isPublic !== undefined) params.append('is_public', options.isPublic.toString()); // Use the correct endpoint: /files (since baseUrl already includes /api/files) const response = await this.client.get(`/files?${params.toString()}`); // Backend returns array directly, not wrapped in data object const files = Array.isArray(response.data) ? response.data : (response.data.data || []); return files.map((file) => this.parseFileMetadata(file)); } catch (error) { throw this.createUploadError(error); } } /** * Cancel an ongoing upload */ cancelUpload(uploadId) { if (uploadId && this.cancelTokens.has(uploadId)) { this.cancelTokens.get(uploadId)?.cancel('Upload cancelled by user'); this.cancelTokens.delete(uploadId); } else { // Cancel all uploads this.cancelTokens.forEach((cancelToken) => { cancelToken.cancel('Upload cancelled by user'); }); this.cancelTokens.clear(); } } /** * Update authentication token */ updateAuthToken(token) { this.config.authToken = token; this.client.defaults.headers['Authorization'] = `Bearer ${token}`; } /** * Validate file before upload */ validateFile(file) { // Check file size if (file.size > this.config.maxFileSize) { throw new Error(`File size (${this.formatFileSize(file.size)}) exceeds maximum allowed size (${this.formatFileSize(this.config.maxFileSize)})`); } // Check file type - ensure allowedTypes is an array and has items if (this.config.allowedTypes && Array.isArray(this.config.allowedTypes) && this.config.allowedTypes.length > 0) { const isAllowed = this.config.allowedTypes.some(allowedType => { // Handle wildcard patterns like "image/*" if (allowedType.includes('*')) { const baseType = allowedType.split('/')[0]; return file.type.startsWith(baseType + '/'); } // Handle exact matches return allowedType === file.type; }); if (!isAllowed) { throw new Error(`File type '${file.type}' is not allowed. Allowed types: ${this.config.allowedTypes.join(', ')}`); } } } /** * Parse upload response */ parseUploadResponse(data) { const responseData = data.data || data; return { id: responseData.file_id || responseData.id || `file-${Date.now()}`, fileId: responseData.file_id || responseData.id || `file-${Date.now()}`, originalFilename: responseData.original_filename || responseData.originalFilename, storedFilename: responseData.stored_filename || responseData.storedFilename, filename: responseData.stored_filename || responseData.storedFilename || responseData.filename, fileSize: responseData.file_size || responseData.fileSize || 0, mimeType: responseData.mime_type || responseData.mimeType || 'application/octet-stream', category: responseData.file_category || responseData.category || 'other', downloadUrl: responseData.download_url || responseData.downloadUrl || this.getDownloadUrl(responseData.file_id || responseData.id), thumbnailUrl: responseData.thumbnail_url || responseData.thumbnailUrl || ((responseData.mime_type || responseData.mimeType)?.startsWith('image/') ? this.getThumbnailUrl(responseData.file_id || responseData.id) : undefined), previewUrl: responseData.preview_url || responseData.previewUrl, uploadedAt: responseData.uploaded_at || responseData.uploadedAt || new Date().toISOString(), isPublic: responseData.is_public || responseData.isPublic || false }; } /** * Parse file metadata response - Updated to match backend structure */ parseFileMetadata(data) { return { id: data.id, originalFilename: data.original_filename, fileSize: data.file_size, mimeType: data.mime_type, category: data.file_category, uploadedBy: data.uploaded_by, entityType: data.associated_entity_type, entityId: data.associated_entity_id, isPublic: data.is_public, createdAt: data.created_at, downloadUrl: data.download_url, thumbnailUrl: data.thumbnail_url, previewUrl: data.preview_url }; } /** * Create standardized upload error */ createUploadError(error) { if (axios.isCancel(error)) { return { code: 'UPLOAD_CANCELLED', message: 'Upload was cancelled', status: 0 }; } if (error.response) { return { code: error.response.data?.code || 'HTTP_ERROR', message: error.response.data?.detail || error.response.data?.message || error.message, status: error.response.status, details: error.response.data }; } if (error.request) { return { code: 'NETWORK_ERROR', message: 'Network error occurred during upload', details: error.request }; } return { code: 'UNKNOWN_ERROR', message: error.message || 'An unknown error occurred', details: error }; } /** * Handle axios errors with retry logic */ async handleError(error) { if (this.config.enableRetry && this.shouldRetry(error)) ; return Promise.reject(error); } /** * Determine if error should be retried */ shouldRetry(error) { if (!error.response) return true; // Network errors const status = error.response.status; return status >= 500 || status === 429; // Server errors or rate limiting } /** * Generate unique upload ID */ generateUploadId() { return `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Format file size for display */ formatFileSize(bytes) { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } /** * Validate URL format */ isValidUrl(url) { try { const urlObj = new URL(url); return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'; } catch { return false; } } /** * Extract filename from URL */ extractFilenameFromUrl(url) { try { const urlObj = new URL(url); const pathname = urlObj.pathname; const filename = pathname.split('/').pop() || ''; // If no filename or just extension, generate a default name if (!filename || filename.startsWith('.')) { const extension = this.getFileExtensionFromUrl(url); return `downloaded-file${extension ? '.' + extension : ''}`; } return filename; } catch { return 'downloaded-file'; } } /** * Get file extension from URL or content type */ getFileExtensionFromUrl(url) { try { const urlObj = new URL(url); const pathname = urlObj.pathname; const lastDot = pathname.lastIndexOf('.'); if (lastDot > 0) { return pathname.substring(lastDot + 1).toLowerCase(); } } catch { // Ignore URL parsing errors } return ''; } /** * Check if file type is allowed */ isAllowedFileType(mimeType) { if (!this.config.allowedTypes || this.config.allowedTypes.length === 0) { return true; // No restrictions } return this.config.allowedTypes.some(allowedType => { if (allowedType === '*/*') return true; // Handle wildcard patterns like "image/*" if (allowedType.includes('*')) { const baseType = allowedType.split('/')[0]; return mimeType.startsWith(baseType + '/'); } // Handle exact matches return allowedType === mimeType; }); } } const FileUploadLibrary = ({ config, options = {}, onFileSelect, multiple = false, accept = "*/*", className = "", style = {} }) => { const [client] = React.useState(() => new FileUploadClient(config)); const [files, setFiles] = React.useState([]); const [loading, setLoading] = React.useState(true); const [uploadState, setUploadState] = React.useState({ selectedFile: null, isUploading: false, uploadProgress: 0, error: null }); // Load files from the library const loadFiles = React.useCallback(async () => { try { setLoading(true); const libraryFiles = await client.listFiles({ skip: 0, limit: 100, ...options }); setFiles(libraryFiles); } catch (error) { console.error('Failed to load files:', error); setUploadState(prev => ({ ...prev, error: 'Failed to load files' })); } finally { setLoading(false); } }, [client, options]); // Load files on component mount React.useEffect(() => { loadFiles(); }, [loadFiles]); // Handle file selection for upload const handleFileSelect = (event) => { const file = event.target.files?.[0]; if (file) { setUploadState(prev => ({ ...prev, selectedFile: file, error: null })); } }; // Handle file upload const handleUpload = async () => { if (!uploadState.selectedFile) return; try { setUploadState(prev => ({ ...prev, isUploading: true, uploadProgress: 0, error: null })); const uploadOptions = { ...options, onProgress: (progress) => { setUploadState(prev => ({ ...prev, uploadProgress: progress })); } }; await client.uploadFile(uploadState.selectedFile, uploadOptions); // Reset upload state and reload files setUploadState({ selectedFile: null, isUploading: false, uploadProgress: 0, error: null }); // Reload the library to show the new file await loadFiles(); } catch (error) { setUploadState(prev => ({ ...prev, isUploading: false, error: error.message || 'Upload failed' })); } }; // Handle file deletion const handleDelete = async (fileId) => { if (!confirm('Are you sure you want to delete this file?')) return; try { await client.deleteFile(fileId); await loadFiles(); // Reload files after deletion } catch (error) { alert('Failed to delete file: ' + (error.message || 'Unknown error')); } }; // Handle file selection from library const handleLibraryFileSelect = (file) => { onFileSelect?.(file); }; // Handle preview const handlePreview = (file) => { if (file.previewUrl) { const fullPreviewUrl = client.getPreviewUrl(file.previewUrl); window.open(fullPreviewUrl, '_blank'); } else if (file.downloadUrl) { window.open(`${config.baseUrl}${file.downloadUrl}`, '_blank'); } }; // Cancel file selection const cancelSelection = () => { setUploadState(prev => ({ ...prev, selectedFile: null, error: null })); }; if (loading) { return (jsxRuntime.jsx("div", { className: `file-upload-library ${className}`, style: style, children: jsxRuntime.jsxs("div", { className: "loading-state", children: [jsxRuntime.jsx("div", { className: "spinner" }), jsxRuntime.jsx("p", { children: "Loading files..." })] }) })); } return (jsxRuntime.jsxs("div", { className: `file-upload-library ${className}`, style: style, children: [jsxRuntime.jsxs("div", { className: "upload-section", children: [!uploadState.selectedFile ? ( // File selection area jsxRuntime.jsxs("div", { className: "file-select-area", children: [jsxRuntime.jsx("input", { type: "file", id: "file-input", accept: accept, multiple: multiple, onChange: handleFileSelect, style: { display: 'none' } }), jsxRuntime.jsxs("label", { htmlFor: "file-input", className: "file-select-button", children: [jsxRuntime.jsx("div", { className: "upload-icon", children: "\uD83D\uDCC1" }), jsxRuntime.jsx("p", { children: "Click to select a file to upload" }), jsxRuntime.jsx("small", { children: "or drag and drop files here" })] })] })) : ( // Upload confirmation area jsxRuntime.jsxs("div", { className: "upload-confirmation", children: [jsxRuntime.jsxs("div", { className: "selected-file-info", children: [jsxRuntime.jsx("div", { className: "file-icon", children: "\uD83D\uDCC4" }), jsxRuntime.jsxs("div", { className: "file-details", children: [jsxRuntime.jsx("h4", { children: uploadState.selectedFile.name }), jsxRuntime.jsxs("p", { children: [(uploadState.selectedFile.size / 1024 / 1024).toFixed(2), " MB"] })] })] }), uploadState.isUploading ? (jsxRuntime.jsxs("div", { className: "upload-progress", children: [jsxRuntime.jsx("div", { className: "progress-bar", children: jsxRuntime.jsx("div", { className: "progress-fill", style: { width: `${uploadState.uploadProgress}%` } }) }), jsxRuntime.jsxs("p", { children: [uploadState.uploadProgress, "% uploaded"] })] })) : (jsxRuntime.jsxs("div", { className: "upload-actions", children: [jsxRuntime.jsx("button", { className: "upload-button primary", onClick: handleUpload, children: "Upload File" }), jsxRuntime.jsx("button", { className: "cancel-button secondary", onClick: cancelSelection, children: "Cancel" })] }))] })), uploadState.error && (jsxRuntime.jsx("div", { className: "error-message", children: uploadState.error }))] }), jsxRuntime.jsxs("div", { className: "file-library", children: [jsxRuntime.jsx("h3", { children: "File Library" }), files.length === 0 ? (jsxRuntime.jsx("div", { className: "empty-state", children: jsxRuntime.jsx("p", { children: "No files uploaded yet" }) })) : (jsxRuntime.jsx("div", { className: "file-grid", children: files.map((file) => (jsxRuntime.jsxs("div", { className: "file-item", children: [jsxRuntime.jsxs("div", { className: "file-thumbnail", children: [file.thumbnailUrl ? (jsxRuntime.jsx("img", { src: `${config.baseUrl}${file.thumbnailUrl}`, alt: file.originalFilename, onError: (e) => { // Fallback to file type icon if thumbnail fails const target = e.target; target.style.display = 'none'; target.nextElementSibling?.classList.remove('hidden'); } })) : null, jsxRuntime.jsx("div", { className: `file-type-icon ${file.thumbnailUrl ? 'hidden' : ''}`, children: getFileTypeIcon(file.mimeType) })] }), jsxRuntime.jsxs("div", { className: "file-info", children: [jsxRuntime.jsx("h4", { className: "file-name", title: file.originalFilename, children: file.originalFilename }), jsxRuntime.jsxs("p", { className: "file-size", children: [(file.fileSize / 1024 / 1024).toFixed(2), " MB"] }), jsxRuntime.jsx("p", { className: "file-date", children: new Date(file.createdAt).toLocaleDateString() })] }), jsxRuntime.jsxs("div", { className: "file-actions", children: [jsxRuntime.jsx("button", { className: "action-button select", onClick: () => handleLibraryFileSelect(file), title: "Select this file", children: "\u2713" }), jsxRuntime.jsx("button", { className: "action-button preview", onClick: () => handlePreview(file), title: "Preview file", children: "\uD83D\uDC41" }), jsxRuntime.jsx("button", { className: "action-button delete", onClick: () => handleDelete(file.id), title: "Delete file", children: "\uD83D\uDDD1" })] })] }, file.id))) }))] }), jsxRuntime.jsx("style", { dangerouslySetInnerHTML: { __html: ` .file-upload-library { max-width: 800px; margin: 0 auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .loading-state { text-align: center; padding: 40px; } .spinner { width: 40px; height: 40px; border: 4px solid #f3f3f3; border-top: 4px solid #007bff; border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 20px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .upload-section { margin-bottom: 30px; padding: 20px; border: 2px dashed #ddd; border-radius: 8px; background: #fafafa; } .file-select-area { text-align: center; } .file-select-button { display: block; padding: 40px; cursor: pointer; transition: background-color 0.2s; } .file-select-button:hover { background-color: #f0f0f0; } .upload-icon { font-size: 48px; margin-bottom: 16px; } .upload-confirmation { display: flex; align-items: center; gap: 20px; } .selected-file-info { display: flex; align-items: center; gap: 12px; flex: 1; } .file-icon { font-size: 32px; } .file-details h4 { margin: 0 0 4px 0; font-size: 16px; } .file-details p { margin: 0; color: #666; font-size: 14px; } .upload-progress { flex: 1; } .progress-bar { width: 100%; height: 8px; background-color: #e0e0e0; border-radius: 4px; overflow: hidden; margin-bottom: 8px; } .progress-fill { height: 100%; background-color: #007bff; transition: width 0.3s ease; } .upload-actions { display: flex; gap: 12px; } .upload-button, .cancel-button { padding: 10px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background-color 0.2s; } .upload-button.primary { background-color: #007bff; color: white; } .upload-button.primary:hover { background-color: #0056b3; } .cancel-button.secondary { background-color: #6c757d; color: white; } .cancel-button.secondary:hover { background-color: #545b62; } .error-message { margin-top: 12px; padding: 12px; background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; border-radius: 4px; } .file-library h3 { margin-bottom: 20px; color: #333; } .empty-state { text-align: center; padding: 40px; color: #666; } .file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 20px; } .file-item { border: 1px solid #ddd; border-radius: 8px; padding: 16px; background: white; transition: box-shadow 0.2s; } .file-item:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .file-thumbnail { width: 100%; height: 120px; display: flex; align-items: center; justify-content: center; background: #f8f9fa; border-radius: 4px; margin-bottom: 12px; overflow: hidden; } .file-thumbnail img { max-width: 100%; max-height: 100%; object-fit: cover; } .file-type-icon { font-size: 48px; opacity: 0.5; } .hidden { display: none; } .file-info { margin-bottom: 12px; } .file-name { margin: 0 0 4px 0; font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-size, .file-date { margin: 0; font-size: 12px; color: #666; } .file-actions { display: flex; gap: 8px; justify-content: center; } .action-button { width: 32px; height: 32px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; transition: all 0.2s; } .action-button:hover { background-color: #f8f9fa; } .action-button.select:hover { background-color: #d4edda; border-color: #28a745; } .action-button.preview:hover { background-color: #d1ecf1; border-color: #17a2b8; } .action-button.delete:hover { background-color: #f8d7da; border-color: #dc3545; } ` } })] })); }; // Helper function to get file type icon function getFileTypeIcon(mimeType) { if (mimeType.startsWith('image/')) return '🖼️'; if (mimeType.startsWith('video/')) return '🎥'; if (mimeType.startsWith('audio/')) return '🎵'; if (mimeType.includes('pdf')) return '📄'; if (mimeType.includes('word') || mimeType.includes('document')) return '📝'; if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊'; if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return '📽️'; if (mimeType.includes('zip') || mimeType.includes('archive')) return '📦'; return '📄'; } const UPLOAD_SOURCES$1 = [ { id: "device", name: "My Device", icon: lucideReact.Folder, description: "Upload from computer", }, { id: "camera", name: "Camera", icon: lucideReact.Camera, description: "Take photo or video", }, { id: "googledrive", name: "Google Drive", icon: lucideReact.Cloud, description: "Import from Google Drive", }, { id: "dropbox", name: "Dropbox", icon: lucideReact.Cloud, description: "Import from Dropbox", }, { id: "onedrive", name: "OneDrive", icon: lucideReact.Cloud, description: "Import from OneDrive", }, { id: "url", name: "From URL", icon: lucideReact.Link, description: "Import from web URL", }, { id: "instagram", name: "Instagram", icon: lucideReact.Image, description: "Import from Instagram", }, { id: "facebook", name: "Facebook", icon: lucideReact.Globe, description: "Import from Facebook", }, ]; const FileUploadWidget = ({ config, options = {}, isOpen, onClose, onFileSelect, onSuccess, onError, multiple = true, accept = "*/*", maxFileSize, title = "Upload Files", showLibrary = true, className = "", }) => { const [activeSource, setActiveSource] = React.useState("device"); const [uploadingFiles, setUploadingFiles] = React.useState([]); const [libraryFiles, setLibraryFiles] = React.useState([]); const [selectedFiles, setSelectedFiles] = React.useState([]); const [isDragActive, setIsDragActive] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const [urlInput, setUrlInput] = React.useState(""); const fileInputRef = React.useRef(null); const cameraInputRef = React.useRef(null); const clientRef = React.useRef(); const dragCounter = React.useRef(0); // Initialize client React.useEffect(() => { if (!clientRef.current) { clientRef.current = new FileUploadClient(config); } }, [config]); // Load library files when widget opens React.useEffect(() => { if (isOpen && showLibrary && clientRef.current) { loadLibraryFiles(); } }, [isOpen, showLibrary]); const loadLibraryFiles = async () => { if (!clientRef.current) return; setIsLoading(true); try { const files = await clientRef.current.listFiles({ limit: 50, isPublic: true, }); // Convert FileMetadata to UploadResult format const uploadResults = files.map((file) => ({ id: file.id, fileId: file.id, originalFilename: file.originalFilename, storedFilename: file.id, // Use ID as stored filename filename: file.id, fileSize: file.fileSize, mimeType: file.mimeType, category: file.category, downloadUrl: file.downloadUrl, thumbnailUrl: file.thumbnailUrl, uploadedAt: file.createdAt, })); setLibraryFiles(uploadResults); } catch (error) { console.error("Failed to load library files:", error); } finally { setIsLoading(false); } }; const handleFileSelect = React.useCallback((event) => { const files = event.target.files; if (files && files.length > 0) { const fileArray = Array.from(files); uploadFiles(fileArray); } // Reset input if (event.target) { event.target.value = ""; } }, []); const uploadFiles = async (files) => { if (!clientRef.current) return; const uploadingFilesList = files.map((file) => ({ id: `${Date.now()}-${Math.random()}`, file, progress: 0, status: "uploading", preview: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined, })); setUploadingFiles((prev) => [...prev, ...uploadingFilesList]); const uploadPromises = uploadingFilesList.map(async (uploadingFile) => { try { const uploadOptions = { category: "document", isPublic: true, ...options, onProgress: (progress) => { setUploadingFiles((prev) => prev.map((f) => f.id === uploadingFile.id ? { ...f, progress } : f)); }, }; const result = await clientRef.current.uploadFile(uploadingFile.file, uploadOptions); setUploadingFiles((prev) => prev.map((f) => f.id === uploadingFile.id ? { ...f, status: "completed", result, progress: 100 } : f)); return result; } catch (error) { setUploadingFiles((prev) => prev.map((f) => f.id === uploadingFile.id ? { ...f, status: "error", error: error instanceof Error ? error.message : "Upload failed", } : f)); throw error; } }); try { const results = await Promise.all(uploadPromises); onSuccess?.(results); // Add to library setLibraryFiles((prev) => [...results, ...prev]); // Clear uploading files after delay setTimeout(() => { setUploadingFiles((prev) => prev.filter((f) => f.status === "uploading")); }, 2000); } catch (error) { onError?.(error instanceof Error ? error : new Error("Upload failed")); } }; const handleUrlUpload = async () => { if (!urlInput.trim() || !clientRef.current) return; try { const response = await fetch(urlInput); const blob = await response.blob(); const filename = urlInput.split("/").pop() || "url-file"; const file = new File([blob], filename, { type: blob.type }); await uploadFiles([file]); setUrlInput(""); } catch (error) { onError?.(error instanceof Error ? error : new Error("URL upload failed")); } }; const handleDragEnter = React.useCallback((e) => { e.preventDefault(); e.stopPropagation(); dragCounter.current++; if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { setIsDragActive(true); } }, []); const handleDragLeave = React.useCallback((e) => { e.preventDefault(); e.stopPropagation(); dragCounter.current--; if (dragCounter.current === 0) { setIsDragActive(false); } }, []); const handleDragOver = React.useCallback((e) => { e.preventDefault(); e.stopPropagation(); }, []); const handleDrop = React.useCallback((e) => { e.preventDefault(); e.stopPropagation(); setIsDragActive(false); dragCounter.current = 0; const files = Array.from(e.dataTransfer.files); if (files.length > 0) { uploadFiles(files); } }, []); const handleLibraryFileSelect = (file) => { if (multiple) { setSelectedFiles((prev) => { const exists = prev.find((f) => f.fileId === file.fileId); if (exists) { return prev.filter((f) => f.fileId !== file.fileId); } return [...prev, file]; }); } else { setSelectedFiles([file]); } }; const handleConfirmSelection = () => { if (selectedFiles.length > 0) { onFileSelect?.(selectedFiles); onClose(); } }; const formatFileSize = (bytes) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; }; if (!isOpen) return null; return (jsxRuntime.jsx("div", { className: `fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-4 ${className}`, children: jsxRuntime.jsxs("div", { className: "bg-white rounded-xl shadow-2xl w-full max-w-6xl h-full max-h-[90vh] flex flex-col overflow-hidden", children: [jsxRuntime.jsxs("div", { className: "flex items-center justify-between p-6 border-b border-gray-200", children: [jsxRuntime.jsx("h2", { className: "text-xl font-semibold text-gray-900", children: title }), jsxRuntime.jsx("button", { onClick: onClose, className: "p-2 hover:bg-gray-100 rounded-lg transition-colors", children: jsxRuntime.jsx(lucideReact.X, { className: "w-5 h-5 text-gray-500" }) })] }), jsxRuntime.jsxs("div", { className: "flex flex-1 overflow-hidden", children: [jsxRuntime.jsxs("div", { className: "w-64 bg-gray-50 border-r border-gray-200 flex flex-col", children: [jsxRuntime.jsxs("div", { className: "p-4", children: [jsxRuntime.jsx("h3", { className: "text-sm font-medium text-gray-900 mb-3", children: "Upload from" }), jsxRuntime.jsx("nav", { className: "space-y-1", children: UPLOAD_SOURCES$1.map((source) => { const Icon = source.icon; const isActive = activeSource === source.id; const isDisabled = !["device", "camera", "url"].includes(source.id); return (jsxRuntime.jsxs("button", { onClick: () => !isDisabled && setActiveSource(source.id), disabled: isDisabled, className: `w-full flex items-center px-3 py-2 text-sm rounded-lg transition-colors ${isActive ? "bg-blue-100 text-blue-700 border border-blue-200" : isDisabled ? "text-gray-400 cursor-not-allowed" : "text-gray-700 hover:bg-gray-100"}`, children: [jsxRuntime.jsx(Icon, { className: "w-4 h-4 mr-3" }), jsxRuntime.jsxs("div", { className: "text-left", children: [jsxRuntime.jsx("div", { className: "font-medium", children: source.name }), isDisabled && (jsxRuntime.jsx("div", { className: "text-xs text-gray-400", children: "Coming soon" }))] })] }, source.id)); }) })] }), showLibrary && (jsxRuntime.jsxs("div", { className: "flex-1 border-t border-gray-200 p-4", children: [jsxRuntime.jsxs("h3", { className: "text-sm font-medium text-gray-900 mb-3", children: ["File Library (", selectedFiles.length, " selected)"] }), selectedFiles.length > 0 && (jsxRuntime.jsx("button", { onClick: handleConfirmSelection, className: "w-full mb-3 px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors", children: "Use Selected Files" }))] }))] }), jsxRuntime.jsx("div", { className: "flex-1 flex flex-col overflow-hidden", children: jsxRuntime.jsxs("div", { className: "flex-1 p-6 overflow-auto", children: [activeSource === "device" && (jsxRuntime.jsx(DeviceUploadContent, { onDragEnter: handleDragEnter, onDragLeave: handleDragLeave, onDragOver: handleDragOver, onDrop: handleDrop, isDragActive: isDragActive, fileInputRef: fileInputRef, onFileSelect: handleFileSelect, accept: accept, multiple: multiple, uploadingFiles: uploadingFiles, formatFileSize: formatFileSize })), activeSource === "camera" && (jsxRuntime.jsx(CameraUploadContent, { cameraInputRef: cameraInputRef, onFileSelect: handleFileSelect })), activeSource === "url" && (jsxRuntime.jsx(UrlUploadContent, { urlInput: urlInput, setUrlInput: setUrlInput, onUpload: handleUrlUpload })), showLibrary && (jsxRuntime.jsx(FileLibraryContent, { files: libraryFiles, selectedFiles: selectedFiles, onFileSelect: handleLibraryFileSelect, isLoading: isLoading, formatFileSize: formatFileSize }))] }) })] })] }) })); }; // Device Upload Content Component const DeviceUploadContent = ({ onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive, fileInputRef, onFileSelect, accept, multiple, uploadingFiles, formatFileSize, }) => (jsxRuntime.jsxs("div", { className: "space-y-6", children: [jsxRuntime.jsx("input", { ref: fileInputRef, type: "file", accept: accept, multiple: multiple, onChange: onFileSelect, className: "hidden" }), jsxRuntime.jsx("div", { onDragEnter: onDragEnter, onDragLeave: onDragLeave, onDragOver: onDragOver, onDrop