From eb8bbb532beebeaa8d3fad45591f96f031220094 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Joshua=20R=C3=BCsweg?= Date: Mon, 7 Jan 2019 22:50:44 +0100 Subject: [PATCH] Add UploadHandler frontend See #2825 --- .../templates/uploadFIeldComponent.tpl | 67 +++ .../js/WoltLabSuite/Core/Ui/File/Delete.js | 159 ++++++ .../js/WoltLabSuite/Core/Ui/File/Upload.js | 512 ++++++++++++++++++ .../lib/action/AJAXFileDeleteAction.class.php | 112 ++++ .../lib/action/AJAXFileUploadAction.class.php | 145 +++++ .../install/files/style/ui/uploadHandler.scss | 55 ++ 6 files changed, 1050 insertions(+) create mode 100644 com.woltlab.wcf/templates/uploadFIeldComponent.tpl create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Delete.js create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Upload.js create mode 100644 wcfsetup/install/files/lib/action/AJAXFileDeleteAction.class.php create mode 100644 wcfsetup/install/files/lib/action/AJAXFileUploadAction.class.php create mode 100644 wcfsetup/install/files/style/ui/uploadHandler.scss diff --git a/com.woltlab.wcf/templates/uploadFIeldComponent.tpl b/com.woltlab.wcf/templates/uploadFIeldComponent.tpl new file mode 100644 index 0000000000..f9a9d53e52 --- /dev/null +++ b/com.woltlab.wcf/templates/uploadFIeldComponent.tpl @@ -0,0 +1,67 @@ + +
+
+ {if !$field->supportMultipleFiles() && $field->isImageOnly()} +
{* + *}{if !$files|empty}{* + *}{assign var="file" value=$files|reset}{* + *}{* + *} +
    + {/if}{* + *}
    + {else} +
    +
      + {foreach from=$files item=file} +
    • + + +
      +
      +

      {$file->getFilename()}

      + {@$file->filesize|filesize} +
      + +
        + + {if $errorField == $file->getUniqueFileId()} + {lang __optional="true"}{$errorType}{/lang} + {/if} +
        +
      • + {/foreach} +
      +
      + {/if} + +
      + + {if $errorField == $fieldId} + + {if $errorType == 'empty'} + {lang}wcf.global.form.error.empty{/lang} + {else} + {lang __optional="true"}{$errorType}{/lang} + {/if} + + {/if} + + +
      + + + \ 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 index 0000000000..d49d58e9c6 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Delete.js @@ -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 + * @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 index 0000000000..0ba8593df9 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/File/Upload.js @@ -0,0 +1,512 @@ +/** + * Uploads file via AJAX. + * + * @author Joshua Ruesweg, Matthias Schmidt + * @copyright 2001-2018 WoltLab GmbH + * @license GNU Lesser General Public License + * @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.)} 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} data response data + * @param {string} responseText response + * @param {XMLHttpRequest} xhr request object + * @param {object} 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} 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} data response data + * @param {string} responseText response + * @param {XMLHttpRequest} xhr request object + * @param {object} 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.|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.)} 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 index 0000000000..9a063456b9 --- /dev/null +++ b/wcfsetup/install/files/lib/action/AJAXFileDeleteAction.class.php @@ -0,0 +1,112 @@ + + * @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 index 0000000000..4dd278eceb --- /dev/null +++ b/wcfsetup/install/files/lib/action/AJAXFileUploadAction.class.php @@ -0,0 +1,145 @@ + + * @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 index 0000000000..0079372d4b --- /dev/null +++ b/wcfsetup/install/files/style/ui/uploadHandler.scss @@ -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 -- 2.20.1