Spaces:
Running
Running
| /** | |
| * DocumentUploadComponent.jsx | |
| * | |
| * React component for uploading documents to the SCM document storage service. | |
| * Handles complete workflow: initialize → upload → complete → download | |
| */ | |
| import React, { useState, useRef } from 'react'; | |
| const DocumentUploadComponent = ({ token, domain, entityId, category }) => { | |
| const [uploadStatus, setUploadStatus] = useState('idle'); // idle, uploading, complete, error | |
| const [uploadProgress, setUploadProgress] = useState(0); | |
| const [objectId, setObjectId] = useState(null); | |
| const [error, setError] = useState(null); | |
| const [metadata, setMetadata] = useState(null); | |
| const fileInputRef = useRef(null); | |
| const API_BASE = 'http://localhost:8000'; | |
| /** | |
| * Calculate SHA-256 checksum of file | |
| */ | |
| const calculateChecksum = async (file) => { | |
| const buffer = await file.arrayBuffer(); | |
| const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); | |
| const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); | |
| }; | |
| /** | |
| * Step 1: Initialize upload | |
| */ | |
| const initializeUpload = async (file) => { | |
| try { | |
| const response = await fetch(`${API_BASE}/scm/storage/upload/init`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ | |
| domain, | |
| entity_id: entityId, | |
| category, | |
| file_name: file.name, | |
| mime_type: file.type, | |
| file_size: file.size, | |
| visibility: 'private', | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error( | |
| `Upload init failed: ${response.status} - ${errorData.detail || response.statusText}` | |
| ); | |
| } | |
| return await response.json(); | |
| } catch (err) { | |
| throw new Error(`Initialize upload failed: ${err.message}`); | |
| } | |
| }; | |
| /** | |
| * Step 2: Upload file to MinIO presigned URL | |
| */ | |
| const uploadToPresignedUrl = async (file, presignedUrl) => { | |
| return new Promise((resolve, reject) => { | |
| const xhr = new XMLHttpRequest(); | |
| // Track upload progress | |
| xhr.upload.addEventListener('progress', (e) => { | |
| if (e.lengthComputable) { | |
| const percentComplete = (e.loaded / e.total) * 100; | |
| setUploadProgress(Math.round(percentComplete)); | |
| } | |
| }); | |
| xhr.addEventListener('load', () => { | |
| if (xhr.status === 200) { | |
| resolve(); | |
| } else { | |
| reject( | |
| new Error( | |
| `Upload to MinIO failed: ${xhr.status} - ${xhr.statusText}` | |
| ) | |
| ); | |
| } | |
| }); | |
| xhr.addEventListener('error', () => { | |
| reject(new Error('Upload to MinIO failed: Network error')); | |
| }); | |
| xhr.addEventListener('abort', () => { | |
| reject(new Error('Upload to MinIO cancelled')); | |
| }); | |
| xhr.open('PUT', presignedUrl); | |
| xhr.setRequestHeader('Content-Type', file.type); | |
| xhr.send(file); | |
| }); | |
| }; | |
| /** | |
| * Step 3: Complete upload | |
| */ | |
| const completeUpload = async (uploadId, checksumSha256) => { | |
| try { | |
| const response = await fetch(`${API_BASE}/scm/storage/upload/complete`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ | |
| upload_id: uploadId, | |
| checksum_sha256: checksumSha256, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error( | |
| `Upload complete failed: ${response.status} - ${errorData.detail || response.statusText}` | |
| ); | |
| } | |
| return await response.json(); | |
| } catch (err) { | |
| throw new Error(`Complete upload failed: ${err.message}`); | |
| } | |
| }; | |
| /** | |
| * Main upload handler | |
| */ | |
| const handleUpload = async (file) => { | |
| try { | |
| setUploadStatus('uploading'); | |
| setError(null); | |
| setUploadProgress(0); | |
| // Step 1: Initialize | |
| console.log('1. Initializing upload...'); | |
| const initResponse = await initializeUpload(file); | |
| console.log(' Upload ID:', initResponse.upload_id); | |
| // Step 2: Calculate checksum | |
| console.log('2. Calculating checksum...'); | |
| const checksum = await calculateChecksum(file); | |
| console.log(' Checksum:', checksum); | |
| // Step 3: Upload to MinIO | |
| console.log('3. Uploading to MinIO...'); | |
| await uploadToPresignedUrl(file, initResponse.presigned_urls[0]); | |
| setUploadProgress(100); | |
| console.log(' File uploaded successfully'); | |
| // Step 4: Complete upload | |
| console.log('4. Completing upload...'); | |
| const completeResponse = await completeUpload( | |
| initResponse.upload_id, | |
| checksum | |
| ); | |
| const finalObjectId = completeResponse.id; | |
| setObjectId(finalObjectId); | |
| if (completeResponse.deduplicated) { | |
| console.log( | |
| ' File was deduplicated (already exists)', | |
| completeResponse.id | |
| ); | |
| } | |
| // Fetch metadata | |
| await fetchMetadata(finalObjectId); | |
| setUploadStatus('complete'); | |
| } catch (err) { | |
| console.error('Upload error:', err); | |
| setError(err.message); | |
| setUploadStatus('error'); | |
| } | |
| }; | |
| /** | |
| * Fetch object metadata | |
| */ | |
| const fetchMetadata = async (id) => { | |
| try { | |
| const response = await fetch(`${API_BASE}/scm/storage/${id}`, { | |
| headers: { | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Fetch failed: ${response.statusText}`); | |
| } | |
| setMetadata(await response.json()); | |
| } catch (err) { | |
| console.error('Fetch metadata error:', err); | |
| } | |
| }; | |
| /** | |
| * Generate download URL | |
| */ | |
| const handleDownload = async () => { | |
| try { | |
| const response = await fetch(`${API_BASE}/scm/storage/download-url`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| body: JSON.stringify({ object_id: objectId }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Download URL generation failed: ${response.statusText}`); | |
| } | |
| const { url } = await response.json(); | |
| window.open(url, '_blank'); | |
| } catch (err) { | |
| setError(`Download failed: ${err.message}`); | |
| } | |
| }; | |
| /** | |
| * Delete object (soft delete) | |
| */ | |
| const handleDelete = async () => { | |
| if (!window.confirm('Are you sure you want to delete this file?')) { | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/scm/storage/${objectId}`, { | |
| method: 'DELETE', | |
| headers: { | |
| Authorization: `Bearer ${token}`, | |
| }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Delete failed: ${response.statusText}`); | |
| } | |
| setObjectId(null); | |
| setMetadata(null); | |
| setUploadStatus('idle'); | |
| } catch (err) { | |
| setError(`Delete failed: ${err.message}`); | |
| } | |
| }; | |
| /** | |
| * Handle file input change | |
| */ | |
| const handleFileChange = async (e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) { | |
| await handleUpload(file); | |
| } | |
| }; | |
| return ( | |
| <div style={styles.container}> | |
| <h2>Document Upload</h2> | |
| {/* File Input */} | |
| <div style={styles.section}> | |
| <label htmlFor="file-input" style={styles.label}> | |
| Select File: | |
| </label> | |
| <input | |
| id="file-input" | |
| ref={fileInputRef} | |
| type="file" | |
| onChange={handleFileChange} | |
| disabled={uploadStatus === 'uploading'} | |
| style={styles.input} | |
| /> | |
| </div> | |
| {/* Upload Progress */} | |
| {uploadStatus === 'uploading' && ( | |
| <div style={styles.section}> | |
| <p>Uploading: {uploadProgress}%</p> | |
| <div style={styles.progressBar}> | |
| <div | |
| style={{ | |
| ...styles.progressFill, | |
| width: `${uploadProgress}%`, | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Error Message */} | |
| {error && ( | |
| <div style={{ ...styles.section, ...styles.error }}> | |
| <strong>Error:</strong> {error} | |
| </div> | |
| )} | |
| {/* Success Message */} | |
| {uploadStatus === 'complete' && objectId && ( | |
| <div style={{ ...styles.section, ...styles.success }}> | |
| <strong>Upload Successful!</strong> | |
| <p>Object ID: {objectId}</p> | |
| </div> | |
| )} | |
| {/* Metadata Display */} | |
| {metadata && ( | |
| <div style={styles.section}> | |
| <h3>File Information</h3> | |
| <dl style={styles.metadata}> | |
| <dt>File Name:</dt> | |
| <dd>{metadata.file_name}</dd> | |
| <dt>MIME Type:</dt> | |
| <dd>{metadata.mime_type}</dd> | |
| <dt>Size:</dt> | |
| <dd>{(metadata.file_size / 1024 / 1024).toFixed(2)} MB</dd> | |
| <dt>Domain:</dt> | |
| <dd>{metadata.domain}</dd> | |
| <dt>Category:</dt> | |
| <dd>{metadata.category}</dd> | |
| <dt>Visibility:</dt> | |
| <dd>{metadata.visibility}</dd> | |
| <dt>Created:</dt> | |
| <dd>{new Date(metadata.created_at).toLocaleString()}</dd> | |
| <dt>Checksum:</dt> | |
| <dd style={styles.checksum}>{metadata.checksum_sha256}</dd> | |
| </dl> | |
| </div> | |
| )} | |
| {/* Action Buttons */} | |
| {objectId && ( | |
| <div style={styles.section}> | |
| <button | |
| onClick={handleDownload} | |
| style={{ ...styles.button, ...styles.buttonPrimary }} | |
| > | |
| Download | |
| </button> | |
| <button | |
| onClick={handleDelete} | |
| style={{ ...styles.button, ...styles.buttonDanger }} | |
| > | |
| Delete | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| // Styling | |
| const styles = { | |
| container: { | |
| maxWidth: '600px', | |
| margin: '20px auto', | |
| padding: '20px', | |
| border: '1px solid #ddd', | |
| borderRadius: '8px', | |
| fontFamily: 'Arial, sans-serif', | |
| }, | |
| section: { | |
| marginBottom: '20px', | |
| }, | |
| label: { | |
| display: 'block', | |
| marginBottom: '8px', | |
| fontWeight: 'bold', | |
| }, | |
| input: { | |
| width: '100%', | |
| padding: '8px', | |
| borderRadius: '4px', | |
| border: '1px solid #ccc', | |
| }, | |
| progressBar: { | |
| width: '100%', | |
| height: '20px', | |
| backgroundColor: '#f0f0f0', | |
| borderRadius: '4px', | |
| overflow: 'hidden', | |
| }, | |
| progressFill: { | |
| height: '100%', | |
| backgroundColor: '#4CAF50', | |
| transition: 'width 0.3s', | |
| }, | |
| error: { | |
| padding: '12px', | |
| backgroundColor: '#ffebee', | |
| color: '#c62828', | |
| borderRadius: '4px', | |
| border: '1px solid #ef5350', | |
| }, | |
| success: { | |
| padding: '12px', | |
| backgroundColor: '#e8f5e9', | |
| color: '#2e7d32', | |
| borderRadius: '4px', | |
| border: '1px solid #66bb6a', | |
| }, | |
| metadata: { | |
| display: 'grid', | |
| gridTemplateColumns: '120px 1fr', | |
| gap: '8px 16px', | |
| marginTop: '12px', | |
| }, | |
| checksum: { | |
| fontFamily: 'monospace', | |
| fontSize: '12px', | |
| wordBreak: 'break-all', | |
| }, | |
| button: { | |
| padding: '10px 20px', | |
| marginRight: '10px', | |
| border: 'none', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| fontSize: '14px', | |
| fontWeight: 'bold', | |
| }, | |
| buttonPrimary: { | |
| backgroundColor: '#2196F3', | |
| color: 'white', | |
| }, | |
| buttonDanger: { | |
| backgroundColor: '#f44336', | |
| color: 'white', | |
| }, | |
| }; | |
| export default DocumentUploadComponent; | |