github.com/resonatecoop/id@v1.1.0-43/frontend/src/components/image-upload/index.js (about) 1 /* global XMLHttpRequest, fetch, FileReader, Image, Blob, FormData */ 2 3 const Component = require('choo/component') 4 5 const html = require('choo/html') 6 const nanostate = require('nanostate') 7 const validateFormdata = require('validate-formdata') 8 const ProgressBar = require('../progress-bar') 9 const input = require('@resonate/input-element') 10 const imagePlaceholder = require('../../lib/image-placeholder') 11 12 /** 13 * @function uploadFile 14 * @description Upload file util function 15 * @param {String} url Upload path (method POST by default) 16 * @param {Object} opts xhr opts (method, headers, body) 17 * @param {Function} onProgress optional onProgress callback function 18 * @param {Function} onLoadEnd optional onLoadEnd callback function 19 * @returns {Promise} Upload data response 20 */ 21 const uploadFile = (url = '/upload', opts = {}, onProgress = () => {}, loadend = () => {}) => { 22 return new Promise((resolve, reject) => { 23 const { 24 headers = {}, 25 method = 'POST' 26 } = opts 27 28 const xhr = new XMLHttpRequest() 29 30 xhr.upload.addEventListener('progress', onProgress) 31 xhr.upload.addEventListener('loadend', loadend) 32 33 xhr.open(method, url, true) 34 xhr.withCredentials = true 35 36 for (const k in headers) { 37 xhr.setRequestHeader(k, headers[k]) 38 } 39 40 xhr.onload = e => { 41 resolve(JSON.parse(e.target.response)) 42 } 43 44 xhr.onerror = reject 45 46 xhr.send(opts.body) 47 }) 48 } 49 50 const uploadStatus = async (id, opts = {}) => { 51 return await (await fetch(`/upload/${id}`, { 52 method: 'GET', 53 headers: { 54 Pragma: 'no-cache', 55 'Cache-Control': 'no-cache', 56 Authorization: 'Bearer ' + opts.token 57 } 58 })).json() 59 } 60 61 const MAX_FILE_SIZE_IMAGE = 1024 * 1024 * 10 62 63 // ImageUpload component class 64 class ImageUpload extends Component { 65 constructor (id, state, emit) { 66 super(id) 67 68 this.local = state.components[id] = {} 69 this.state = state 70 this.emit = emit 71 72 this.local.progress = 0 73 74 this.onDragOver = this.onDragOver.bind(this) 75 this.onDragleave = this.onDragleave.bind(this) 76 this.onChange = this.onChange.bind(this) 77 this.getUploadStatus = this.getUploadStatus.bind(this) 78 79 this.machine = nanostate('idle', { 80 idle: { drag: 'dragging', resolve: 'data' }, 81 dragging: { resolve: 'data', drag: 'idle' }, 82 data: { drag: 'dragging', resolve: 'data', reject: 'error' }, 83 error: { drag: 'idle', resolve: 'data' } 84 }) 85 86 this.local.checks = 0 87 88 this.validator = validateFormdata() 89 this.form = this.validator.state 90 } 91 92 onFileUploaded () {} 93 94 createElement (props) { 95 this.local.name = props.name || 'cover' // name ref for uploaded file 96 this.local.config = props.config || 'avatar' 97 this.local.archive = props.archive 98 99 this.validator = props.validator || this.validator 100 this.form = props.form || this.form || { 101 changed: false, 102 valid: true, 103 pristine: {}, 104 required: {}, 105 values: {}, 106 errors: {} 107 } 108 this.local.src = props.src 109 110 this.onFileUploaded = props.onFileUploaded || this.onFileUploaded 111 112 const errors = this.form.errors 113 const values = this.form.values 114 115 this.local.multiple = props.multiple || false 116 this.local.format = props.format 117 this.local.accept = props.accept || 'image/jpeg,image/jpg,image/png' 118 this.local.direction = props.direction || 'row' 119 this.local.ratio = props.ratio || '1200x1200px' 120 121 const dropInfo = { 122 idle: 'Drop an audio file', 123 dragging: 'Drop now!', 124 error: 'File not supported', 125 data: 'Fetch Again?' 126 }[this.machine.state] 127 128 const image = this.local.objectURL || this.local.src || this.local.archive 129 130 const fileInput = (options) => { 131 const attrs = Object.assign({ 132 multiple: this.local.multiple, 133 class: `w-100 h-100 o-0 absolute z-1 ${image ? 'loaded' : 'empty'}`, 134 name: `inputFile-${this._name}`, 135 required: false, 136 onchange: this.onChange, 137 title: dropInfo, 138 accept: this.local.accept, 139 type: 'file' 140 }, options) 141 142 return html`<input ${attrs}>` 143 } 144 145 return html` 146 <div class="flex flex-column"> 147 <div class="flex flex-${this.local.direction} ${this.machine.state === 'dragging' ? 'dragging' : ''}" unresolved> 148 <div class="w-100"> 149 <div class="bg-image-placeholder flex relative" style="padding-top:calc(${props.format.height / props.format.width} * 100%);"> 150 <div style="background: url(${image || imagePlaceholder(400, 400)}) center center / cover no-repeat;" class="absolute top-0 w-100 h-100 flex-auto z-1"> 151 <div class="relative w-100 h-100" ondragover=${this.onDragOver} ondrop=${this.onDrop} ondragleave=${this.onDragleave}> 152 ${fileInput({ id: `inputFile-${this._name}` })} 153 <label class="absolute o-0 w-100 h-100 top-0 left-0 right-0 bottom-0 z-1" style="cursor:pointer" for="inputFile-${this._name}"> 154 Upload 155 </label> 156 </div> 157 </div> 158 </div> 159 </div> 160 <div ondragover=${this.onDragOver} ondrop=${this.onDrop} ondragleave=${this.onDragleave} class="flex ${this.local.direction === 'row' ? 'ml3' : 'mt3'} flex-${this.local.direction === 'column' ? 'row' : 'column'}"> 161 <div class="relative grow mr2"> 162 ${fileInput({ id: `inputFile-${this._name}-button` })} 163 <label class="dib pv2 ph4 mb1 ba bw b--black-80 ${this.direction === 'column' ? 'mr2' : ''}" for="inputFile-${this._name}-button">Upload</label> 164 </div> 165 ${errors[`inputFile-${this._name}`] || errors[`inputFile-${this._name}-button`] 166 ? html` 167 <p class="lh-copy f5 red"> 168 ${errors[`inputFile-${this._name}`].message || errors[`inputFile-${this._name}-button`].message} 169 </p> 170 ` 171 : '' 172 } 173 ${errors[this.local.name] ? html`<p class="lh-copy f5 red">${errors[this.local.name].message}</p>` : ''} 174 <div class="flex flex-column"> 175 <p class="lh-copy ma0 pa0 f6 grey">For best results, upload a JPG or PNG at ${this.local.ratio}</p> 176 <div class="flex flex-column mt2"> 177 ${this.state.cache(ProgressBar, this._name + '-image-upload-progress').render({ 178 progress: this.local.progress 179 })} 180 </div> 181 </div> 182 ${input({ 183 type: 'hidden', 184 id: this.local.name, 185 name: this.local.name, 186 value: values[this.local.name] 187 })} 188 </div> 189 </div> 190 </div> 191 ` 192 } 193 194 onDragOver (e) { 195 e.preventDefault() 196 e.stopPropagation() 197 if (this.machine.state === 'dragging') return false 198 this.machine.emit('drag') 199 200 this.rerender() 201 } 202 203 onDragleave (e) { 204 e.preventDefault() 205 e.stopPropagation() 206 this.machine.emit('drag') 207 this.rerender() 208 } 209 210 onDrop (e) { 211 } 212 213 onChange (e) { 214 e.preventDefault() 215 e.stopPropagation() 216 217 this.local.uploading = true 218 219 window.addEventListener('beforeunload', e => { 220 if (!this.local.uploading) return 221 222 e.preventDefault() 223 e.returnValue = 'Upload in progress' 224 }) 225 226 this.machine.emit('resolve') 227 228 const files = e.target.files 229 230 for (const file of files) { 231 const reader = new FileReader() 232 const size = file.size 233 234 const image = ((/(image\/jpg|image\/jpeg|image\/png)/).test(file.type)) 235 236 if (!image) { 237 this.machine.emit('reject') 238 return this.rerender() 239 } 240 241 if (image) { 242 if (size > MAX_FILE_SIZE_IMAGE) { 243 this.machine.emit('reject') 244 return this.rerender() 245 } 246 247 // Load some artwork 248 const blob = new Blob([file], { 249 type: file.type 250 }) 251 252 this.local.objectURL = URL.createObjectURL(blob) 253 254 reader.onload = async e => { 255 try { 256 const image = new Image() 257 258 image.src = this.local.objectURL 259 image.onload = () => { 260 this.width = image.width 261 this.height = image.height 262 this.validator.validate(`inputFile-${this._name}`, { width: this.width, height: this.height }) 263 this.rerender() 264 } 265 266 const formData = new FormData() 267 268 formData.append('uploads', file) 269 formData.append('config', this.local.config) 270 271 // upload file using upload tool (expect path /upload to be proxied to upload tool API) 272 const response = await uploadFile('/upload', { 273 method: 'POST', 274 headers: { 275 Authorization: 'Bearer ' + this.state.token 276 }, 277 body: formData 278 }, event => { 279 if (event.lengthComputable) { 280 // current progress by precentage 281 const progress = event.loaded / event.total * 100 282 283 this.local.progress = progress 284 const componentID = this._name + '-image-upload-progress' 285 // get slider component by reference 286 const component = this.state.components[componentID] 287 // update slider progress 288 component.slider.update({ 289 value: this.local.progress 290 }) 291 } 292 }) 293 294 this.local.filename = response.data.filename 295 296 this.validator.validate(this.local.name, this.local.filename) 297 298 this.rerender() 299 300 this.getUploadStatus(this.local.filename) 301 302 this.onFileUploaded(this.local.filename) 303 } catch (err) { 304 this.emit('error', err) 305 } 306 } 307 308 reader.readAsDataURL(blob) 309 } 310 } 311 } 312 313 async getUploadStatus () { 314 this.local.checks = this.local.checks + 1 315 316 try { 317 const response = await uploadStatus(this.local.filename, { token: this.state.token }) 318 319 if (response.status === 'ok') { 320 this.local.uploading = false 321 this.local.checks = 0 322 } else if (this.local.checks <= 10) { 323 setTimeout(() => { 324 return this.getUploadStatus() 325 }, 1000) 326 } 327 } catch (err) { 328 this.emit('error', err) 329 } 330 } 331 332 beforerender (el) { 333 el.removeAttribute('unresolved') 334 } 335 336 afterupdate (el) { 337 el.removeAttribute('unresolved') 338 } 339 340 load (el) { 341 if (this.local.multiple) { 342 const input = el.querySelector('input[type="file"]') 343 input.attr('multiple', 'true') 344 } 345 this.validator.field(`inputFile-${this._name}`, { required: false }, (data) => { 346 if (typeof data === 'object') { 347 const { width, height } = data 348 if (!width || !height) return new Error('Image is required') 349 if (width < this.local.format.width || height < this.local.format.height) { 350 return new Error('Image size is too small') 351 } 352 } 353 }) 354 this.validator.field(`inputFile-${this._name}-button`, { required: false }, (data) => { 355 if (typeof data === 'object') { 356 const { width, height } = data 357 if (!width || !height) return new Error('Image is required') 358 if (width < this.local.format.width || height < this.local.format.height) { 359 return new Error('Image size is too small') 360 } 361 } 362 }) 363 } 364 365 update (props) { 366 return props.src !== this.local.src || 367 props.config !== this.local.config || 368 props.archive !== this.local.archive 369 } 370 } 371 372 module.exports = ImageUpload