1// TODO: may be able to remove the uid requirement on GeccoUploadField.php if 2// we return the silverstripe file ID after upload and track it in javascript 3 4class FileHandler { 5 constructor(container) { 6 this.container = container; 7 this.input = container.querySelector('input'); 8 this.maxFiles = this.input.getAttribute('data-max-files'); 9 this.files = []; 10 this.currentFilesContainer = container.querySelector('.GeccoUploadCurrent'); 11 this.init(); 12 } 13 14 init() { 15 this.updateUploadedFiles(); 16 17 this.container.addEventListener('click', this.handleDelete.bind(this)); 18 this.container.addEventListener('drop', this.handleDrop.bind(this)); 19 this.container.addEventListener('dragenter', (e) => { this.container.classList.add('dragover'); }); 20 this.container.addEventListener('dragover', (e) => { e.preventDefault(); }); 21 this.container.addEventListener('dragleave', (e) => { 22 // Only remove .dragover if the drag is moving to an element outside the container 23 if (!e.relatedTarget || !this.container.contains(e.relatedTarget)) { 24 this.container.classList.remove('dragover'); 25 } 26 }); 27 this.input.addEventListener('change', this.handleChange.bind(this)); 28 29 const form = this.getForm(); 30 if (form) form.addEventListener('submit', this.handleSubmit.bind(this)); 31 } 32 33 getForm() { 34 let form = this.input.closest('form') || this.input.closest('.htmx_form_container')?.querySelector('form'); 35 return form; 36 } 37 38 getInputURL(action) { 39 const formAction = this.getForm().getAttribute('action'); 40 let fieldName = this.input.getAttribute('name').replace('[]', ''); 41 const baseURL = new URL(formAction, window.location.href); 42 baseURL.pathname = `${baseURL.pathname.replace(/\/$/, '')}/field/${fieldName}/${action}`; 43 return baseURL.toString(); 44 } 45 46 renderFile(file) { 47 let html = `<li class='d-flex justify-content-between my-1 border-bottom'>`; 48 if (file.status === 'failed') { 49 html += ` 50 <div class='d-flex flex-grow-1 align-items-center text-break'>${file.name}</div> 51 <div class='d-flex flex-grow-1 align-items-center ml-2 text-danger'>Upload failed: ${file.errorMessage}</div> 52 <div class='text-danger btn d-flex align-items-center' data-delete-file-type='uploading' data-delete-file-name='${file.name}' data-toggle='tooltip' title='Remove file'> 53 <i class='fas fa-times'></i> 54 </div>`; 55 } else if (file.status === 'uploading') { 56 html += ` 57 <div class='d-flex flex-grow-1 align-items-center text-break'>${file.name}</div> 58 <div class='d-flex flex-grow-1 align-items-center ml-2 text-success'>Uploading: ${file.uploadPercent.toFixed(2)}%</div>`; 59 } else if (file.status === 'uploaded') { 60 html += ` 61 <div class='gecco-upload-thumbnail-container'><img class='img-fluid gecco-upload-thumbnail' src='${file.thumbnailURL}' alt='${file.extension}'></div> 62 <div class='file-uploaded d-flex align-items-center flex-grow-1 pl-2 text-break'> 63 <a href='${file.downloadLink}' target='_blank'>${file.name}</a> 64 <small class='text-muted ml-2'>${file.sizeNice}</small> 65 </div> 66 <div class='text-danger btn d-flex align-items-center' data-delete-file-type='uploaded' data-delete-file-name='${file.name}' data-toggle='tooltip' title='Remove file'> 67 <i class='fas fa-times'></i> 68 </div>`; 69 } 70 html += `</li>`; 71 return html; 72 } 73 74 updateDisplay() { 75 let fileListHTML = ''; 76 if (this.files.length) { 77 this.files.forEach(f => { 78 fileListHTML += this.renderFile(f); 79 }); 80 } else { 81 fileListHTML += 'Nothing uploaded.'; 82 } 83 84 this.container.querySelector('ul.GeccoUploadList').innerHTML = fileListHTML; 85 } 86 87 async updateUploadedFiles() { 88 this.currentFilesContainer.classList.add('loading'); 89 const formData = new FormData(this.getForm()); 90 try { 91 const response = await fetch(this.getInputURL('currentFiles'), { 92 method: 'POST', 93 headers: { 'Gecco-Upload': 'Gecco-Upload' }, 94 body: formData 95 }); 96 if (response.ok) { 97 const data = await response.json(); 98 this.files = this.files.filter(f => f.status !== 'uploaded'); 99 data.forEach(fileData => { 100 this.files.push({ 101 status: 'uploaded', 102 id: fileData.fileID, 103 name: fileData.name, 104 sizeMB: fileData.sizeMB, 105 thumbnailURL: fileData.thumbnail || '', 106 extension: fileData.extension, 107 sizeNice: fileData.sizeNice, 108 downloadLink: fileData.downloadLink 109 }); 110 }); 111 this.currentFilesContainer.classList.toggle('has-values', data.length > 0); 112 } 113 } catch (e) { 114 console.log('Error fetching uploaded files:', e); 115 } 116 this.currentFilesContainer.classList.remove('loading'); 117 this.updateDisplay(); 118 } 119 120 async uploadFiles(fileList) { 121 fileList.forEach(file => { 122 if (!this.files.some(f => f.name === file.name)) { 123 this.files.push({ status: 'pending', name: file.name, uploadPercent: 0, file }); 124 } 125 }); 126 this.currentFilesContainer.classList.add('loading', 'has-values'); 127 for (const fileObj of this.files.filter(f => f.status === 'pending')) { 128 fileObj.status = 'uploading'; 129 const formData = new FormData(this.getForm()); 130 formData.append('AJAXFile', fileObj.file); 131 const xhr = new XMLHttpRequest(); 132 xhr.upload.onprogress = (event) => { 133 if (event.lengthComputable) { 134 fileObj.uploadPercent = (event.loaded / event.total) * 100; 135 this.updateDisplay(); 136 } 137 }; 138 xhr.onloadend = () => { 139 if (xhr.status === 200) { 140 fileObj.status = 'uploaded'; 141 this.updateUploadedFiles(); 142 } else { 143 fileObj.status = 'failed'; 144 fileObj.errorMessage = xhr.responseText; 145 this.updateDisplay(); 146 } 147 }; 148 xhr.open('POST', this.getInputURL('upload'), true); 149 xhr.setRequestHeader('Gecco-Upload', 'Gecco-Upload'); 150 xhr.send(formData); 151 } 152 this.currentFilesContainer.classList.remove('loading'); 153 } 154 155 handleDrop(event) { 156 event.preventDefault(); 157 this.container.classList.remove('dragover'); 158 if (event.dataTransfer.files) { 159 this.uploadFiles([...event.dataTransfer.files]); 160 } 161 } 162 163 handleChange(event) { 164 if (this.input.files) { 165 this.uploadFiles([...this.input.files]); 166 this.input.value = null; 167 } 168 } 169 170 handleDelete(event) { 171 const button = event.target.closest('[data-delete-file-type]'); 172 if (!button) return; 173 const type = button.getAttribute('data-delete-file-type'); 174 const name = button.getAttribute('data-delete-file-name'); 175 if (type === 'uploading') { 176 this.files = this.files.filter(f => f.name !== name); 177 this.updateDisplay(); 178 } else if (type === 'uploaded') { 179 const index = this.files.findIndex(f => f.name === name); 180 if (index !== -1) { 181 const formData = new FormData(this.getForm()); 182 formData.append('fileIndex', index); 183 fetch(this.getInputURL('remove'), { 184 method: 'POST', 185 headers: { 'Gecco-Upload': 'Gecco-Upload' }, 186 body: formData 187 }).then(() => this.updateUploadedFiles()); 188 } 189 } 190 } 191 192 handleSubmit() { 193 const uploadedIDs = this.files.filter(f => f.status === 'uploaded').map(f => f.id); 194 const textInput = document.createElement('input'); 195 textInput.type = 'text'; 196 textInput.name = this.input.getAttribute('name'); 197 textInput.className = 'text form-control'; 198 textInput.value = JSON.stringify(uploadedIDs); 199 this.input.parentNode.replaceChild(textInput, this.input); 200 } 201} 202 203document.addEventListener('DOMContentLoaded', () => { 204 document.querySelectorAll('.GeccoUploadContainer').forEach(el => { 205 el.fileHandler = new FileHandler(el); 206 }); 207}); 208 209new MutationObserver(mutations => { 210 mutations.forEach(mutation => {
211 mutation.addedNodes.forEach(node => { 212 if (node.querySelectorAll) { 213 node.querySelectorAll(':scope .GeccoUploadContainer').forEach(el => { 214 if (!el.fileHandler) el.fileHandler = new FileHandler(el); 215 }); 216 } 217 }); 218 }); 219}).observe(document.body, { subtree: true, childList: true });
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.