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