Add UploadHandler frontend
authorJoshua Rüsweg <josh@bastelstu.be>
Mon, 7 Jan 2019 21:50:44 +0000 (22:50 +0100)
committerJoshua Rüsweg <josh@bastelstu.be>
Mon, 7 Jan 2019 22:13:12 +0000 (23:13 +0100)
See #2825

com.woltlab.wcf/templates/uploadFIeldComponent.tpl [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Delete.js [new file with mode: 0644]
wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Upload.js [new file with mode: 0644]
wcfsetup/install/files/lib/action/AJAXFileDeleteAction.class.php [new file with mode: 0644]
wcfsetup/install/files/lib/action/AJAXFileUploadAction.class.php [new file with mode: 0644]
wcfsetup/install/files/style/ui/uploadHandler.scss [new file with mode: 0644]

diff --git a/com.woltlab.wcf/templates/uploadFIeldComponent.tpl b/com.woltlab.wcf/templates/uploadFIeldComponent.tpl
new file mode 100644 (file)
index 0000000..f9a9d53
--- /dev/null
@@ -0,0 +1,67 @@
+<dl{if $errorField == $fieldId} class="formError"{/if}>
+       <dt><label for="{$fieldId}">{$field->getName()}</label></dt>
+       <dd>
+               {if !$field->supportMultipleFiles() && $field->isImageOnly()}
+                               <div class="selectedImagePreview uploadedFile" id="{$fieldId}uploadFileList" data-internal-id="{$field->getInternalId()}">{*
+                                       *}{if !$files|empty}{*
+                                               *}{assign var="file" value=$files|reset}{*
+                                               *}<img src="{$file->getImage()}" alt="" class="previewImage" id="{$fieldId}Image" style="max-width: 100%" data-unique-file-id="{$file->getUniqueFileId()}">{*
+                                       *}
+                                               <ul class="buttonGroup"></ul>
+                                       {/if}{*
+                               *}</div>
+               {else}
+                       <div class="formUploadHandlerContent">
+                               <ul class="formUploadHandlerList" id="{$fieldId}uploadFileList" data-internal-id="{$field->getInternalId()}">
+                                       {foreach from=$files item=file}
+                                               <li class="box64 uploadedFile" data-unique-file-id="{$file->getUniqueFileId()}">
+                                                       <span class="icon icon64 fa-{$file->getIconName()}"></span>
+                                                       
+                                                       <div>
+                                                               <div>
+                                                                       <p>{$file->getFilename()}</p>
+                                                                       <small>{@$file->filesize|filesize}</small>
+                                                               </div>
+                                                               
+                                                               <ul class="buttonGroup"></ul>
+                                                               
+                                                               {if $errorField == $file->getUniqueFileId()}
+                                                                       <small class="innerError innerFileError">{lang __optional="true"}{$errorType}{/lang}</small>
+                                                               {/if}
+                                                       </div>
+                                               </li>
+                                       {/foreach}
+                               </ul>
+                       </div>
+               {/if}
+               
+               <div id="{$fieldId}UploadButtonDiv" class="uploadButtonDiv"></div>
+               
+               {if $errorField == $fieldId}
+                       <small class="innerError">
+                               {if $errorType == 'empty'}
+                                       {lang}wcf.global.form.error.empty{/lang}
+                               {else}
+                                       {lang __optional="true"}{$errorType}{/lang}
+                               {/if}
+                       </small>
+               {/if}
+               
+               <input type="hidden" name="{$fieldId}" value="{$field->getInternalId()}">
+       </dd>
+</dl>
+
+<script data-relocate="true">
+       require(['WoltLabSuite/Core/Ui/File/Upload', 'Language'], function(Upload, Language) {
+               new Upload("{$fieldId}UploadButtonDiv", "{$fieldId}uploadFileList", {
+                       internalId: '{$field->getInternalId()}',
+                       maxFiles: {$field->getMaxFiles()},
+                       imagePreview: {if !$field->supportMultipleFiles() && $field->isImageOnly()}true{else}false{/if}
+               });
+               
+               Language.addObject({
+                       'wcf.upload.error.reachedRemainingLimit': '{lang}wcf.upload.error.reachedRemainingLimit{/lang}',
+                       'wcf.upload.error.noImage': '{lang}wcf.upload.error.noImage{/lang}'
+               });
+       });
+</script>
\ No newline at end of file
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Delete.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Delete.js
new file mode 100644 (file)
index 0000000..d49d58e
--- /dev/null
@@ -0,0 +1,159 @@
+/**
+ * Delete files which are uploaded via AJAX.
+ *
+ * @author     Joshua Ruesweg
+ * @copyright  2001-2018 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module     WoltLabSuite/Core/Ui/File/Delete
+ * @since      5.2
+ */
+define(['Ajax', 'Core', 'Dom/ChangeListener', 'Language', 'Dom/Util', 'Dom/Traverse', 'Dictionary'], function(Ajax, Core, DomChangeListener, Language, DomUtil, DomTraverse, Dictionary) {
+       "use strict";
+       
+       /**
+        * @constructor
+        */
+       function Delete(buttonContainerId, targetId, isSingleImagePreview, uploadHandler) {
+               this._isSingleImagePreview = isSingleImagePreview;
+               this._uploadHandler = uploadHandler;
+               
+               this._buttonContainer = elById(buttonContainerId);
+               if (this._buttonContainer === null) {
+                       throw new Error("Element id '" + buttonContainerId + "' is unknown.");
+               }
+               
+               this._target = elById(targetId);
+               if (targetId === null) {
+                       throw new Error("Element id '" + targetId + "' is unknown.");
+               }
+               this._containers = new Dictionary();
+               
+               this._internalId = elData(this._target, 'internal-id');
+               
+               if (!this._internalId) {
+                       throw new Error("InternalId is unknown.");
+               }
+               
+               this.rebuild();
+       }
+       
+       Delete.prototype = {
+               /**
+                * Creates the upload button.
+                */
+               _createButtons: function() {
+                       var element, elements = elBySelAll('li.uploadedFile', this._target), elementData, triggerChange = false, uniqueFileId;
+                       for (var i = 0, length = elements.length; i < length; i++) {
+                               element = elements[i];
+                               uniqueFileId = elData(element, 'unique-file-id');
+                               if (this._containers.has(uniqueFileId)) {
+                                       continue;
+                               }
+                               
+                               elementData = {
+                                       uniqueFileId: uniqueFileId,
+                                       element: element
+                               };
+                               
+                               this._containers.set(uniqueFileId, elementData);
+                               this._initDeleteButton(element, elementData);
+                               
+                               triggerChange = true;
+                       }
+                       
+                       if (triggerChange) {
+                               DomChangeListener.trigger();
+                       }
+               },
+               
+               /**
+                * Init the delete button for a specific element.
+                * 
+                * @param       {HTMLElement}   element
+                * @param       {string}        elementData
+                */
+               _initDeleteButton: function(element, elementData) {
+                       var buttonGroup = elBySel('.buttonGroup', element);
+                       
+                       if (buttonGroup === null) {
+                               throw new Error("Button group in '" + targetId + "' is unknown.");
+                       }
+                       
+                       var li = elCreate('li');
+                       var span = elCreate('span');
+                       span.classList = "button jsDeleteButton small";
+                       span.textContent = Language.get('wcf.global.button.delete');
+                       li.appendChild(span);
+                       buttonGroup.appendChild(li);
+                       
+                       li.addEventListener(WCF_CLICK_EVENT, this._delete.bind(this, elementData.uniqueFileId));
+               },
+               
+               /**
+                * Delete a specific file with the given uniqueFileId.
+                * 
+                * @param       {string}        uniqueFileId
+                */
+               _delete: function(uniqueFileId) {
+                       Ajax.api(this, {
+                               uniqueFileId: uniqueFileId,
+                               internalId: this._internalId
+                       });
+               },
+               
+               /**
+                * Rebuilds the delete buttons for unknown files. 
+                */
+               rebuild: function() {
+                       if (this._isSingleImagePreview) {
+                               var img = elBySel('img', this._target);
+                               
+                               if (img !== null) {
+                                       var uniqueFileId = elData(img, 'unique-file-id');
+                                       
+                                       if (!this._containers.has(uniqueFileId)) {
+                                               var elementData = {
+                                                       uniqueFileId: uniqueFileId,
+                                                       element: img
+                                               };
+                                               
+                                               this._containers.set(uniqueFileId, elementData);
+                                               
+                                               this._deleteButton = elCreate('p');
+                                               this._deleteButton.className = 'button deleteButton';
+                                               
+                                               var span = elCreate('span');
+                                               span.textContent = Language.get('wcf.global.button.delete');
+                                               this._deleteButton.appendChild(span);
+                                               
+                                               this._buttonContainer.appendChild(this._deleteButton);
+                                               
+                                               this._deleteButton.addEventListener(WCF_CLICK_EVENT, this._delete.bind(this, elementData.uniqueFileId));
+                                       }
+                               }
+                       }
+                       else {
+                               this._createButtons();
+                       }
+               },
+               
+               _ajaxSuccess: function(data) {
+                       elRemove(this._containers.get(data.uniqueFileId).element);
+                       
+                       if (this._isSingleImagePreview) {
+                               elRemove(this._deleteButton);
+                               this._deleteButton = null;
+                       }
+                       
+                       this._uploadHandler.checkMaxFiles();
+               },
+               
+               _ajaxSetup: function () {
+                       return {
+                               url: 'index.php?ajax-file-delete/&t=' + SECURITY_TOKEN
+                       };
+               }
+       };
+       
+       return Delete;
+});
diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Upload.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Upload.js
new file mode 100644 (file)
index 0000000..0ba8593
--- /dev/null
@@ -0,0 +1,512 @@
+/**
+ * Uploads file via AJAX.
+ *
+ * @author     Joshua Ruesweg, Matthias Schmidt
+ * @copyright  2001-2018 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @module     WoltLabSuite/Core/Ui/File/Upload
+ * @since      3.2
+ */
+define(['AjaxRequest', 'Core', 'Dom/ChangeListener', 'Language', 'Dom/Util', 'Dom/Traverse', 'WoltLabSuite/Core/Ui/File/Delete'], function(AjaxRequest, Core, DomChangeListener, Language, DomUtil, DomTraverse, DeleteHandler) {
+       "use strict";
+       
+       /**
+        * @constructor
+        */
+       function Upload(buttonContainerId, targetId, options) {
+               options = options || {};
+               
+               if (options.internalId === undefined) {
+                       throw new Error("Missing internal id.");
+               }
+               
+               // set default options
+               this._options = Core.extend({
+                       // is true if multiple files can be uploaded at once
+                       multiple: options.maxFiles > 1,
+                       // name if the upload field
+                       name: '__files[]',
+                       // is true if every file from a multi-file selection is uploaded in its own request
+                       singleFileRequests: false,
+                       // url for uploading file
+                       url: 'index.php?ajax-file-upload/&t=' + SECURITY_TOKEN,
+                       // image preview
+                       imagePreview: false
+               }, options);
+               
+               this._options.url = Core.convertLegacyUrl(this._options.url);
+               if (this._options.url.indexOf('index.php') === 0) {
+                       this._options.url = WSC_API_URL + this._options.url;
+               }
+               
+               this._buttonContainer = elById(buttonContainerId);
+               if (this._buttonContainer === null) {
+                       throw new Error("Element id '" + buttonContainerId + "' is unknown.");
+               }
+               
+               this._target = elById(targetId);
+               if (targetId === null) {
+                       throw new Error("Element id '" + targetId + "' is unknown.");
+               }
+               
+               if (options.multiple && this._target.nodeName !== 'UL' && this._target.nodeName !== 'OL') {
+                       throw new Error("Target element has to be list or table body if uploading multiple files is supported.");
+               }
+               
+               this._fileElements = [];
+               this._internalFileId = 0;
+               
+               // upload ids that belong to an upload of multiple files at once
+               this._multiFileUploadIds = [];
+               
+               this._createButton();
+               this.checkMaxFiles();
+               
+               this._deleteHandler = new DeleteHandler(buttonContainerId, targetId, this._options.imagePreview, this);
+       }
+       
+       Upload.prototype = {
+               /**
+                * Creates the upload button.
+                */
+               _createButton: function() {
+                       this._fileUpload = elCreate('input');
+                       elAttr(this._fileUpload, 'type', 'file');
+                       elAttr(this._fileUpload, 'name', this._options.name);
+                       if (this._options.multiple) {
+                               elAttr(this._fileUpload, 'multiple', 'true');
+                       }
+                       this._fileUpload.addEventListener('change', this._upload.bind(this));
+                       
+                       this._button = elCreate('p');
+                       this._button.className = 'button uploadButton';
+                       
+                       var span = elCreate('span');
+                       span.textContent = Language.get('wcf.global.button.upload');
+                       this._button.appendChild(span);
+                       
+                       DomUtil.prepend(this._fileUpload, this._button);
+                       
+                       this._insertButton();
+                       
+                       DomChangeListener.trigger();
+               },
+               
+               /**
+                * Creates the document element for an uploaded file.
+                *
+                * @param       {File}          file            uploaded file
+                * @return      {HTMLElement}
+                */
+               _createFileElement: function(file) {
+                       var li = elCreate('li');
+                       li.classList = 'box64 uploadedFile';
+                       
+                       var span = elCreate('span');
+                       span.classList = 'icon icon64 fa-spinner';
+                       li.appendChild(span);
+                       
+                       var div = elCreate('div');
+                       var innerDiv = elCreate('div');
+                       var p = elCreate('p');
+                       p.textContent = file.name;
+                       
+                       var small = elCreate('small');
+                       var progress = elCreate('progress');
+                       elAttr(progress, 'max', 100);
+                       small.appendChild(progress);
+                       
+                       innerDiv.appendChild(p);
+                       innerDiv.appendChild(small);
+                       
+                       var ul = elCreate('ul');
+                       ul.classList = 'buttonGroup';
+                       
+                       div.appendChild(innerDiv);
+                       div.appendChild(ul);
+                       li.appendChild(div);
+                       
+                       this._target.appendChild(li);
+                       
+                       return li;
+               },
+               
+               /**
+                * Creates the document elements for uploaded files.
+                *
+                * @param       {(FileList|Array.<File>)}       files           uploaded files
+                */
+               _createFileElements: function(files) {
+                       if (files.length) {
+                               var uploadId = this._fileElements.length;
+                               this._fileElements[uploadId] = [];
+                               
+                               for (var i = 0, length = files.length; i < length; i++) {
+                                       var file = files[i];
+                                       var fileElement = this._createFileElement(file);
+                                       
+                                       if (!fileElement.classList.contains('uploadFailed')) {
+                                               elData(fileElement, 'filename', file.name);
+                                               elData(fileElement, 'internal-file-id', this._internalFileId++);
+                                               this._fileElements[uploadId][i] = fileElement;
+                                       }
+                               }
+                               
+                               DomChangeListener.trigger();
+                               
+                               return uploadId;
+                       }
+                       
+                       return null;
+               },
+               
+               /**
+                * Handles a failed file upload.
+                *
+                * @param       {int}                   uploadId        identifier of a file upload
+                * @param       {object<string, *>}     data            response data
+                * @param       {string}                responseText    response
+                * @param       {XMLHttpRequest}        xhr             request object
+                * @param       {object<string, *>}     requestOptions  options used to send AJAX request
+                * @return      {boolean}       true if the error message should be shown
+                */
+               _failure: function(uploadId, data, responseText, xhr, requestOptions) {
+                       for (var i in this._fileElements[uploadId]) {
+                               this._fileElements[uploadId][i].classList.add('uploadFailed');
+                               
+                               elBySel('small', this._fileElements[uploadId][i]).innerHTML = '';
+                               elBySel('.icon', this._fileElements[uploadId][i]).classList.remove('fa-spinner');
+                               elBySel('.icon', this._fileElements[uploadId][i]).classList.add('fa-ban');
+                               
+                               var innerError = elCreate('span');
+                               innerError.classList = 'innerError';
+                               innerError.textContent = Language.get('wcf.upload.error.uploadFailed');
+                               DomUtil.insertAfter(innerError, elBySel('small', this._fileElements[uploadId][i]));
+                       }
+                       
+                       throw new Error("Upload failed: "+ data.message);
+                       
+                       return false;
+               },
+               
+               /**
+                * Return additional parameters for upload requests.
+                *
+                * @return      {object<string, *>}     additional parameters
+                */
+               _getParameters: function() {
+                       return {};
+               },
+               
+               /**
+                * Inserts the created button to upload files into the button container.
+                */
+               _insertButton: function() {
+                       DomUtil.prepend(this._button, this._buttonContainer);
+               },
+               
+               /**
+                * Updates the progress of an upload.
+                *
+                * @param       {int}                           uploadId        internal upload identifier
+                * @param       {XMLHttpRequestProgressEvent}   event           progress event object
+                */
+               _progress: function(uploadId, event) {
+                       var percentComplete = Math.round(event.loaded / event.total * 100);
+                       
+                       for (var i in this._fileElements[uploadId]) {
+                               var progress = elByTag('PROGRESS', this._fileElements[uploadId][i]);
+                               if (progress.length === 1) {
+                                       elAttr(progress[0], 'value', percentComplete);
+                               }
+                       }
+               },
+               
+               /**
+                * Removes the button to upload files.
+                */
+               _removeButton: function() {
+                       elRemove(this._button);
+                       
+                       DomChangeListener.trigger();
+               },
+               
+               /**
+                * Handles a successful file upload.
+                *
+                * @param       {int}                   uploadId        identifier of a file upload
+                * @param       {object<string, *>}     data            response data
+                * @param       {string}                responseText    response
+                * @param       {XMLHttpRequest}        xhr             request object
+                * @param       {object<string, *>}     requestOptions  options used to send AJAX request
+                */
+               _success: function(uploadId, data, responseText, xhr, requestOptions) {
+                       for (var i in this._fileElements[uploadId]) {
+                               if (typeof data['files'][i] !== 'undefined') {
+                                       if (this._options.imagePreview) {
+                                               if (data['files'][i].image === null) {
+                                                       throw new Error("Excpect image for uploaded file. None given.");
+                                               }
+                                               
+                                               elRemove(this._fileElements[uploadId][i]);
+                                               
+                                               if (elBySel('img.previewImage', this._target) !== null) {
+                                                       elBySel('img.previewImage', this._target).setAttribute('src', data['files'][i].image);
+                                               }
+                                               else {
+                                                       var image = elCreate('img');
+                                                       image.classList.add('previewImage');
+                                                       image.setAttribute('src', data['files'][i].image);
+                                                       image.setAttribute('style', "width: 100%;");
+                                                       elData(image, 'unique-file-id', data['files'][i].uniqueFileId);
+                                                       this._target.appendChild(image);
+                                               }
+                                       }
+                                       else {
+                                               elData(this._fileElements[uploadId][i], 'unique-file-id', data['files'][i].uniqueFileId);
+                                               elBySel('small', this._fileElements[uploadId][i]).innerHTML = '';
+                                               elBySel('small', this._fileElements[uploadId][i]).textContent = data['files'][i].filesize;
+                                               elBySel('.icon', this._fileElements[uploadId][i]).classList.remove('fa-spinner');
+                                               elBySel('.icon', this._fileElements[uploadId][i]).classList.add('fa-' + data['files'][i].icon);
+                                       }
+                               }
+                               else if (typeof data['error'][i] !== 'undefined') {
+                                       this._fileElements[uploadId][i].classList.add('uploadFailed');
+                                       
+                                       elBySel('small', this._fileElements[uploadId][i]).innerHTML = '';
+                                       elBySel('.icon', this._fileElements[uploadId][i]).classList.remove('fa-spinner');
+                                       elBySel('.icon', this._fileElements[uploadId][i]).classList.add('fa-ban');
+                                       
+                                       if (elBySel('.innerError', this._fileElements[uploadId][i]) === null) {
+                                               var innerError = elCreate('span');
+                                               innerError.classList = 'innerError';
+                                               innerError.textContent = data['error'][i].errorMessage;
+                                               DomUtil.insertAfter(innerError, elBySel('small', this._fileElements[uploadId][i]));
+                                       }
+                                       else {
+                                               elBySel('.innerError', this._fileElements[uploadId][i]).textContent = data['error'][i].errorMessage;
+                                       }
+                               }
+                               else {
+                                       throw new Error('Unknown uploaded file for uploadId '+ uploadId +'.');
+                               }
+                       }
+                       
+                       // create delete buttons
+                       this._deleteHandler.rebuild();
+                       this.checkMaxFiles();
+               },
+               
+               /**
+                * File input change callback to upload files.
+                *
+                * @param       {Event}         event           input change event object
+                * @param       {File}          file            uploaded file
+                * @param       {Blob}          blob            file blob
+                * @return      {(int|Array.<int>|null)}        identifier(s) for the uploaded files
+                */
+               _upload: function(event, file, blob) {
+                       // remove failed upload elements first
+                       var failedUploads = DomTraverse.childrenByClass(this._target, 'uploadFailed');
+                       for (var i = 0, length = failedUploads.length; i < length; i++) {
+                               elRemove(failedUploads[i]);
+                       }
+                       
+                       var uploadId = null;
+                       
+                       var files = [];
+                       if (file) {
+                               files.push(file);
+                       }
+                       else if (blob) {
+                               var fileExtension = '';
+                               switch (blob.type) {
+                                       case 'image/jpeg':
+                                               fileExtension = '.jpg';
+                                               break;
+                                       
+                                       case 'image/gif':
+                                               fileExtension = '.gif';
+                                               break;
+                                       
+                                       case 'image/png':
+                                               fileExtension = '.png';
+                                               break;
+                               }
+                               
+                               files.push({
+                                       name: 'pasted-from-clipboard' + fileExtension
+                               });
+                       }
+                       else {
+                               files = this._fileUpload.files;
+                       }
+                       
+                       if (files.length && files.length + this.countFiles() <= this._options.maxFiles) {
+                               if (this._options.singleFileRequests) {
+                                       uploadId = [];
+                                       for (var i = 0, length = files.length; i < length; i++) {
+                                               var localUploadId = this._uploadFiles([ files[i] ], blob);
+                                               
+                                               if (files.length !== 1) {
+                                                       this._multiFileUploadIds.push(localUploadId)
+                                               }
+                                               uploadId.push(localUploadId);
+                                       }
+                               }
+                               else {
+                                       uploadId = this._uploadFiles(files, blob);
+                               }
+                       }
+                       else {
+                               var innerError = elBySel('small.innerError:not(.innerFileError)', this._buttonContainer.parentNode);
+                               
+                               if (innerError === null) {
+                                       innerError = elCreate('small');
+                                       innerError.classList = 'innerError';
+                                       DomUtil.insertAfter(innerError, this._buttonContainer);
+                               }
+                               
+                               innerError.textContent= WCF.Language.get('wcf.upload.error.reachedRemainingLimit').replace(/#remaining#/, this._options.maxFiles);
+                       }
+                       
+                       // re-create upload button to effectively reset the 'files'
+                       // property of the input element
+                       this._removeButton();
+                       this._createButton();
+                       
+                       return uploadId;
+                       
+               },
+               
+               /**
+                * Sends the request to upload files.
+                *
+                * @param       {(FileList|Array.<File>)}       files           uploaded files
+                * @param       {Blob}                          blob            file blob
+                * @return      {(int|null)}    identifier for the uploaded files
+                */
+               _uploadFiles: function(files, blob) {
+                       var uploadId = this._createFileElements(files);
+                       
+                       // no more files left, abort
+                       if (!this._fileElements[uploadId].length) {
+                               return null;
+                       }
+                       
+                       var formData = new FormData();
+                       for (var i = 0, length = files.length; i < length; i++) {
+                               if (this._fileElements[uploadId][i]) {
+                                       var internalFileId = elData(this._fileElements[uploadId][i], 'internal-file-id');
+                                       
+                                       if (blob) {
+                                               formData.append('__files[' + internalFileId + ']', blob, files[i].name);
+                                       }
+                                       else {
+                                               formData.append('__files[' + internalFileId + ']', files[i]);
+                                       }
+                               }
+                       }
+                       
+                       formData.append('internalId', this._options.internalId);
+                       
+                       // recursively append additional parameters to form data
+                       var appendFormData = function(parameters, prefix) {
+                               prefix = prefix || '';
+                               
+                               for (var name in parameters) {
+                                       if (typeof parameters[name] === 'object') {
+                                               appendFormData(parameters[name], prefix + '[' + name + ']');
+                                       }
+                                       else {
+                                               formData.append('parameters' + prefix + '[' + name + ']', parameters[name]);
+                                       }
+                               }
+                       };
+                       
+                       appendFormData(this._getParameters());
+                       
+                       var request = new AjaxRequest({
+                               data: formData,
+                               contentType: false,
+                               failure: this._failure.bind(this, uploadId),
+                               silent: true,
+                               success: this._success.bind(this, uploadId),
+                               uploadProgress: this._progress.bind(this, uploadId),
+                               url: this._options.url,
+                               withCredentials: true
+                       });
+                       request.sendRequest();
+                       
+                       return uploadId;
+               },
+               
+               /**
+                * Returns true if there are any pending uploads handled by this
+                * upload manager.
+                *
+                * @return      {boolean}
+                * @since       3.2
+                */
+               hasPendingUploads: function() {
+                       for (var uploadId in this._fileElements) {
+                               for (var i in this._fileElements[uploadId]) {
+                                       var progress = elByTag('PROGRESS', this._fileElements[uploadId][i]);
+                                       if (progress.length === 1) {
+                                               return true;
+                                       }
+                               }
+                       }
+                       
+                       return false;
+               },
+               
+               /**
+                * Uploads the given file blob.
+                *
+                * @param       {Blob}          blob            file blob
+                * @return      {int}           identifier for the uploaded file
+                */
+               uploadBlob: function(blob) {
+                       return this._upload(null, null, blob);
+               },
+               
+               /**
+                * Uploads the given file.
+                *
+                * @param       {File}          file            uploaded file
+                * @return      {int}           identifier(s) for the uploaded file
+                */
+               uploadFile: function(file) {
+                       return this._upload(null, file);
+               },
+               
+               /**
+                * Returns the count of the uploaded images.
+                * 
+                * @return {int}
+                */
+               countFiles: function() {
+                       if (this._options.imagePreview) {
+                               return elBySel('img', this._target) !== null ? 1 : 0;
+                       }
+                       else {
+                               return this._target.childElementCount;
+                       }
+               },
+               
+               /**
+                * Checks the maximum number of files and enables or disables the upload button.
+                */
+               checkMaxFiles: function() {
+                       if (this.countFiles() >= this._options.maxFiles) {
+                               elHide(this._button);
+                       }
+                       else {
+                               elShow(this._button);
+                       }
+               }
+       };
+       
+       return Upload;
+});
diff --git a/wcfsetup/install/files/lib/action/AJAXFileDeleteAction.class.php b/wcfsetup/install/files/lib/action/AJAXFileDeleteAction.class.php
new file mode 100644 (file)
index 0000000..9a06345
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+namespace wcf\action;
+use wcf\system\exception\AJAXException;
+use wcf\system\exception\UserInputException;
+use wcf\system\file\upload\UploadFile;
+use wcf\system\file\upload\UploadHandler;
+use wcf\system\WCF;
+use wcf\util\FileUtil;
+use wcf\util\JSON;
+
+/**
+ * Copy of the default implementation for file uploads using the AJAX-API.
+ *
+ * @author     Joshua Ruesweg
+ * @copyright  2001-2018 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\Acp\Action
+ * @since      3.2
+ */
+class AJAXFileDeleteAction extends AbstractSecureAction {
+       use TAJAXException;
+       
+       /**
+        * The internal upload id.
+        * @var String 
+        */
+       public $internalId;
+       
+       /**
+        * The unique file id.
+        * @var String 
+        */
+       public $uniqueFileId;
+       
+       /**
+        * The adressed file.
+        * @var UploadFile
+        */
+       private $file; 
+       
+       /**
+        * @var UploadFile[]
+        */
+       public $uploadedFiles = [];
+       
+       /**
+        * @inheritDoc
+        */
+       public function __run() {
+               try {
+                       parent::__run();
+               }
+               catch (\Throwable $e) {
+                       if ($e instanceof AJAXException) {
+                               throw $e;
+                       }
+                       else {
+                               $this->throwException($e);
+                       }
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (isset($_POST['internalId'])) {
+                       $this->internalId = $_POST['internalId'];
+               }
+               
+               if (!UploadHandler::getInstance()->isValidInternalId($this->internalId)) {
+                       throw new UserInputException('internalId', 'invalid');
+               }
+               
+               if (isset($_POST['uniqueFileId'])) {
+                       $this->uniqueFileId = $_POST['uniqueFileId'];
+               }
+               
+               if (!UploadHandler::getInstance()->isValidUniqueFileId($this->internalId, $this->uniqueFileId)) {
+                       throw new UserInputException('uniqueFileId', 'invalid');
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function execute() {
+               parent::execute();
+               
+               UploadHandler::getInstance()->removeFile($this->internalId, $this->uniqueFileId);
+               
+               $this->sendJsonResponse([
+                       'uniqueFileId' => $this->uniqueFileId
+               ]);
+       }
+       
+       /**
+        * Sends a JSON-encoded response.
+        *
+        * @param       array           $data
+        */
+       protected function sendJsonResponse(array $data) {
+               $json = JSON::encode($data);
+               
+               // send JSON response
+               header('Content-type: application/json');
+               echo $json;
+               exit;
+       }
+}
diff --git a/wcfsetup/install/files/lib/action/AJAXFileUploadAction.class.php b/wcfsetup/install/files/lib/action/AJAXFileUploadAction.class.php
new file mode 100644 (file)
index 0000000..4dd278e
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+namespace wcf\action;
+use wcf\system\exception\AJAXException;
+use wcf\system\exception\UserInputException;
+use wcf\system\file\upload\UploadFile;
+use wcf\system\file\upload\UploadHandler;
+use wcf\system\WCF;
+use wcf\util\FileUtil;
+use wcf\util\JSON;
+
+/**
+ * Copy of the default implementation for file uploads using the AJAX-API.
+ *
+ * @author     Joshua Ruesweg
+ * @copyright  2001-2018 WoltLab GmbH
+ * @license    GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
+ * @package    WoltLabSuite\Core\Acp\Action
+ * @since      3.2
+ */
+class AJAXFileUploadAction extends AbstractSecureAction {
+       use TAJAXException;
+       
+       /**
+        * The internal upload id.
+        * @var String 
+        */
+       public $internalId;
+       
+       /**
+        * @var UploadFile[]
+        */
+       public $uploadedFiles = [];
+       
+       /**
+        * @inheritDoc
+        */
+       public function __run() {
+               try {
+                       parent::__run();
+               }
+               catch (\Throwable $e) {
+                       if ($e instanceof AJAXException) {
+                               throw $e;
+                       }
+                       else {
+                               $this->throwException($e);
+                       }
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function readParameters() {
+               parent::readParameters();
+               
+               if (isset($_POST['internalId'])) {
+                       $this->internalId = $_POST['internalId'];
+               }
+               
+               if (!UploadHandler::getInstance()->isValidInternalId($this->internalId)) {
+                       throw new UserInputException('internalId', 'invalid');
+               }
+               
+               if (!isset($_FILES['__files']) || !is_array($_FILES['__files']) || !isset($_FILES['__files']['tmp_name']) || !is_array($_FILES['__files']['tmp_name'])) {
+                       throw new UserInputException('files', 'failed');
+               }
+               
+               if (UploadHandler::getInstance()->getFieldForInternalId($this->internalId)->getMaxFiles() < UploadHandler::getInstance()->getFilesCountForInternalId($this->internalId) + count($_FILES['__files']['tmp_name'])) {
+                       throw new UserInputException('files', 'reachedRemainingLimit');
+               }
+       }
+       
+       /**
+        * @inheritDoc
+        */
+       public function execute() {
+               parent::execute();
+               
+               $response = [
+                       'files' => [],
+                       'error' => []
+               ];
+               
+               $i = 0;
+               
+               $field = UploadHandler::getInstance()->getFieldForInternalId($this->internalId);
+               
+               foreach ($_FILES['__files']['tmp_name'] as $id => $tmpName) {
+                       if ($field->isImageOnly()) {
+                               if (@getimagesize($tmpName) === false) {
+                                       $response['error'][$i++] = [
+                                               'filename' => $_FILES['__files']['name'][$id],
+                                               'errorMessage' => WCF::getLanguage()->get('wcf.upload.error.noImage')
+                                       ];
+                                       continue;
+                               }
+                       }
+                       
+                       $tmpFile = FileUtil::getTemporaryFilename('fileUpload_');
+                       
+                       if (!@move_uploaded_file($tmpName, $tmpFile)) {
+                               $response['error'][$i++] = [
+                                       'filename' => $_FILES['__files']['name'][$id],
+                                       'errorMessage' => WCF::getLanguage()->get('wcf.upload.error.uploadFailed')
+                               ];
+                               continue;
+                       }
+                       
+                       $uploadFile = new UploadFile($tmpFile, $_FILES['__files']['name'][$id]);
+                       
+                       UploadHandler::getInstance()->addFileForInternalId($this->internalId, $uploadFile);
+                       
+                       $this->uploadedFiles[$i++] = $uploadFile;
+               }
+               
+               $this->executed();
+               
+               foreach ($this->uploadedFiles as $id => $file) {
+                       $response['files'][$id] = [
+                               'filename' => $file->getFilename(),
+                               'icon' => $file->getIconName(),
+                               'filesize' => FileUtil::formatFilesize($file->filesize),
+                               'image' => ($file->viewableImage) ? $file->getImage() : null, 
+                               'uniqueFileId' => $file->getUniqueFileId()
+                       ];
+               }
+               
+               $this->sendJsonResponse($response);
+       }
+       
+       /**
+        * Sends a JSON-encoded response.
+        *
+        * @param       array           $data
+        */
+       protected function sendJsonResponse(array $data) {
+               $json = JSON::encode($data);
+               
+               // send JSON response
+               header('Content-type: application/json');
+               echo $json;
+               exit;
+       }
+}
diff --git a/wcfsetup/install/files/style/ui/uploadHandler.scss b/wcfsetup/install/files/style/ui/uploadHandler.scss
new file mode 100644 (file)
index 0000000..0079372
--- /dev/null
@@ -0,0 +1,55 @@
+.formUploadHandlerContent {
+       > .formUploadHandlerList {
+               display: flex;
+               flex-wrap: wrap;
+               margin-left: 0 !important;
+               
+               > li {
+                       display: flex;
+                       flex: 0 0 100%;
+                       margin-bottom: 20px;
+               }
+       }
+       
+       @include screen-md-up {
+               > .formUploadHandlerList {
+                       margin-right: -20px;
+                       
+                       > li {
+                               /* Safari sometimes trips over fractional values, causing two
+                                  items to be exactly 1 pixel wider than the available space.
+                                  Reserving 21px covers all sort of rounding errors, without
+                                  being visually noticeable */
+                               flex: 0 0 calc(50% - 21px);
+                               max-width: calc(50% - 21px); /* IE fix */
+                               margin-right: 20px;
+                       }
+               }
+       }
+}
+
+.selectedImagePreview {
+       > img {
+               margin-bottom: 5px;
+               border: 1px solid #ccc;
+               background-color: #fff;
+               background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAABlBMVEX////MzMw46qqDAAAAD0lEQVQI12P4z4Ad4ZAAAH6/D/Hgw85/AAAAAElFTkSuQmCC);
+       }
+}
+
+.uploadButtonDiv {
+       .button {
+               overflow: hidden;
+               position: relative;
+       }
+       
+       .uploadButton {
+               > input {
+                       bottom: 0;
+                       left: 0;
+                       opacity: 0;
+                       position: absolute;
+                       top: 0;
+               }
+       }
+}
\ No newline at end of file