1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = [ 5 "fileInput", "masterIdInput", "progressFill", 6 "completeForm", "uploadArea", "removeButton", "dropzone", "uploadTurboFrame" 7 ] 8 static values = { 9 createUrl: String, 10 masterId: String 11 } 12 13 connect() { 14 this.currentUploadRequests = {} 15 this.setupDragAndDrop() 16 } 17 18 setupDragAndDrop() { 19 if (!this.hasDropzoneTarget) return 20 21 // Add drag and drop event listeners 22 this.dropzoneTarget.addEventListener('dragover', this.handleDragOver.bind(this)) 23 this.dropzoneTarget.addEventListener('drop', this.handleDrop.bind(this)) 24 this.dropzoneTarget.addEventListener('dragenter', this.handleDragEnter.bind(this)) 25 this.dropzoneTarget.addEventListener('dragleave', this.handleDragLeave.bind(this)) 26 } 27 28 triggerFileSelect() { 29 this.fileInputTarget.click() 30 } 31 32 async handleFileSelect(event) { 33 const file = event.target.files[0] 34 if (!file) return 35 36 await this.processFile(file) 37 } 38 39 // Drag and drop handlers 40 handleDragOver(event) { 41 event.preventDefault() 42 event.dataTransfer.dropEffect = 'copy' 43 this.dropzoneTarget.classList.add('drag-over') 44 } 45 46 handleDragEnter(event) { 47 event.preventDefault() 48 this.dropzoneTarget.classList.add('drag-over') 49 } 50 51 handleDragLeave(event) { 52 event.preventDefault() 53 // Only remove drag-over if we're actually leaving the drop area 54 const rect = this.dropzoneTarget.getBoundingClientRect() 55 if (event.clientX < rect.left || event.clientX >= rect.right || 56 event.clientY < rect.top || event.clientY >= rect.bottom) { 57 this.dropzoneTarget.classList.remove('drag-over') 58 } 59 } 60 61 handleDrop(event) { 62 event.preventDefault() 63 this.dropzoneTarget.classList.remove('drag-over') 64 65 const file = event.dataTransfer.files[0] 66 if (file) { 67 this.processFile(file) 68 } 69 } 70 71 async processFile(file) { 72 this.currentFile = file 73 74 try { 75 const formData = new FormData() 76 formData.append('filename', file.name) 77 if (this.masterIdValue) { 78 formData.append('master_id', this.masterIdValue) 79 } 80 81 const authResponse = await fetch(this.createUrlValue, { 82 method: 'POST', 83 headers: { 84 'Accept': 'application/json', 85 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content 86 }, 87 body: formData 88 }) 89 90 if (!authResponse.ok) { 91 throw new Error('Failed to authorize upload') 92 } 93 94 const uploadData = await authResponse.json() 95 96 this.loadUpload(uploadData.upload_id); 97 await this.uploadToS3(file, uploadData) 98 await this.notifyUploadComplete(uploadData.upload_id, file) 99 100 } catch (error) { 101 console.error('Upload error:', error) 102 // Show empty state again if upload fails (for new masters) 103 if (this.hasDropzoneTarget) { 104 this.dropzoneTarget.style.display = 'block' 105 } 106 } 107 } 108 109 loadUpload(uploadId) { 110 // Hide empty state when upload starts (for new masters) 111 if (this.hasDropzoneTarget) { 112 this.dropzoneTarget.style.display = 'none' 113 } 114 if (this.hasUploadAreaTarget) { 115 this.uploadAreaTarget.style.display = 'block' 116 } 117 if (this.hasUploadTurboFrameTarget) { 118 this.uploadTurboFrameTarget.id = `upload_${uploadId}` 119 this.uploadTurboFrameTarget.src = `/uploads/${uploadId}` 120 } 121 } 122 123 async uploadToS3(file, uploadData) { 124 return new Promise((resolve, reject) => { 125 const formData = new FormData() 126 Object.keys(uploadData.fields).forEach(key => { 127 formData.append(key, uploadData.fields[key]) 128 }) 129 formData.append('Content-Type', file.type) 130 formData.append('file', file) 131 132 const uploadRequest = new XMLHttpRequest() 133 this.currentUploadRequests[uploadData.upload_id] = uploadRequest 134 135 uploadRequest.upload.addEventListener('progress', (e) => { 136 if (e.lengthComputable) { 137 const percentComplete = (e.loaded / e.total) * 100 138 this.updateProgress(uploadData.upload_id, percentComplete) 139 } 140 }) 141 142 uploadRequest.addEventListener('load', () => { 143 if (uploadRequest.status === 204) { 144 resolve() 145 } else { 146 reject(new Error('S3 upload failed')) 147 } 148 }) 149 150 uploadRequest.addEventListener('error', () => { 151 console.log("error") 152 reject(new Error('S3 upload failed')) 153 }) 154 155 uploadRequest.open('POST', uploadData.url) 156 uploadRequest.send(formData) 157 }) 158 } 159 160 async notifyUploadComplete(uploadId, file) { 161 try { 162 if (this.hasCompleteFormTarget) {
163 this.masterIdInputTarget.value = this.masterIdValue 164 this.completeFormTarget.requestSubmit() 165 } 166 } catch (error) { 167 console.error('Upload completion error:', error) 168 } 169 } 170 171 updateProgress(uploadId, percentComplete) { 172 if (this.hasProgressFillTarget) { 173 this.progressFillTargets.forEach(target => { 174 if (target.dataset.uploadId === uploadId) { 175 target.style.width = `${percentComplete}%` 176 } 177 }) 178 } 179 } 180 181 cancelUpload(event) { 182 const uploadId = event.target.dataset.uploadId 183 184 if (this.currentUploadRequests[uploadId]) { 185 // Cancel the ongoing S3 upload 186 this.currentUploadRequests[uploadId].abort() 187 delete this.currentUploadRequests[uploadId] 188 } 189 } 190 191}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.