diff --git a/lang/en/components.php b/lang/en/components.php index 48a0a32fa..07a3ba3a7 100644 --- a/lang/en/components.php +++ b/lang/en/components.php @@ -18,6 +18,7 @@ return [ 'image_delete_confirm_text' => 'Are you sure you want to delete this image?', 'image_select_image' => 'Select Image', 'image_dropzone' => 'Drop images or click here to upload', + 'image_dropzone_drop' => 'Drop images here to upload', 'images_deleted' => 'Images Deleted', 'image_preview' => 'Image Preview', 'image_upload_success' => 'Image uploaded successfully', diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index 87c6b4d4f..b94d5d1f4 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -1,16 +1,22 @@ import {Component} from './component'; import {Clipboard} from '../services/clipboard'; +import { + elem, getLoading, removeLoading, +} from '../services/dom'; export class Dropzone extends Component { setup() { this.container = this.$el; + this.statusArea = this.$refs.statusArea; + this.url = this.$opts.url; this.successMessage = this.$opts.successMessage; this.removeMessage = this.$opts.removeMessage; this.uploadLimit = Number(this.$opts.uploadLimit); // TODO - Use this.uploadLimitMessage = this.$opts.uploadLimitMessage; // TODO - Use this.timeoutMessage = this.$opts.timeoutMessage; // TODO - Use + this.zoneText = this.$opts.zoneText; // window.uploadTimeout // TODO - Use // TODO - Click-to-upload buttons/areas // TODO - Drop zone highlighting of existing element @@ -20,9 +26,15 @@ export class Dropzone extends Component { } setupListeners() { + let depth = 0; + this.container.addEventListener('dragenter', event => { - this.container.style.border = '1px dotted tomato'; event.preventDefault(); + depth += 1; + + if (depth === 1) { + this.showOverlay(); + } }); this.container.addEventListener('dragover', event => { @@ -30,7 +42,8 @@ export class Dropzone extends Component { }); const reset = () => { - this.container.style.border = null; + this.hideOverlay(); + depth = 0; }; this.container.addEventListener('dragend', event => { @@ -38,11 +51,15 @@ export class Dropzone extends Component { }); this.container.addEventListener('dragleave', event => { - reset(); + depth -= 1; + if (depth === 0) { + reset(); + } }); this.container.addEventListener('drop', event => { event.preventDefault(); + reset(); const clipboard = new Clipboard(event.dataTransfer); const files = clipboard.getFiles(); for (const file of files) { @@ -51,6 +68,21 @@ export class Dropzone extends Component { }); } + showOverlay() { + const overlay = this.container.querySelector('.dropzone-overlay'); + if (!overlay) { + const zoneElem = elem('div', {class: 'dropzone-overlay'}, [this.zoneText]); + this.container.append(zoneElem); + } + } + + hideOverlay() { + const overlay = this.container.querySelector('.dropzone-overlay'); + if (overlay) { + overlay.remove(); + } + } + /** * @param {File} file * @return {Upload} @@ -70,10 +102,12 @@ export class Dropzone extends Component { markError(message) { status.setAttribute('data-status', 'error'); status.textContent = message; + removeLoading(dom); }, markSuccess(message) { status.setAttribute('data-status', 'success'); status.textContent = message; + removeLoading(dom); }, }; @@ -119,26 +153,27 @@ export class Dropzone extends Component { * @return {{image: Element, dom: Element, progress: Element, label: Element, status: Element}} */ createDomForFile(file) { - const dom = document.createElement('div'); - const label = document.createElement('div'); - const status = document.createElement('div'); - const progress = document.createElement('div'); - const image = document.createElement('img'); + const image = elem('img', {src: ''}); + const status = elem('div', {class: 'dropzone-file-item-status'}, []); + const progress = elem('div', {class: 'dropzone-file-item-progress'}); + const imageWrap = elem('div', {class: 'dropzone-file-item-image-wrap'}, [image]); - dom.classList.add('dropzone-file-item'); - status.classList.add('dropzone-file-item-status'); - progress.classList.add('dropzone-file-item-progress'); - - image.src = ''; // TODO - file icon - label.innerText = file.name; + const dom = elem('div', {class: 'dropzone-file-item'}, [ + imageWrap, + elem('div', {class: 'dropzone-file-item-text-wrap'}, [ + elem('div', {class: 'dropzone-file-item-label'}, [file.name]), + getLoading(), + status, + ]), + progress, + ]); if (file.type.startsWith('image/')) { image.src = URL.createObjectURL(file); } - dom.append(image, label, progress, status); return { - dom, label, image, progress, status, + dom, progress, status, }; } diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js index 17f5a803a..786855748 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.js @@ -1,3 +1,29 @@ +/** + * Create a new element with the given attrs and children. + * Children can be a string for text nodes or other elements. + * @param {String} tagName + * @param {Object} attrs + * @param {Element[]|String[]}children + * @return {*} + */ +export function elem(tagName, attrs = {}, children = []) { + const el = document.createElement(tagName); + + for (const [key, val] of Object.entries(attrs)) { + el.setAttribute(key, val); + } + + for (const child of children) { + if (typeof child === 'string') { + el.append(document.createTextNode(child)); + } else { + el.append(child); + } + } + + return el; +} + /** * Run the given callback against each element that matches the given selector. * @param {String} selector @@ -108,6 +134,17 @@ export function showLoading(element) { element.innerHTML = '
'; } +/** + * Get a loading element indicator element. + * @returns {Element} + */ +export function getLoading() { + const wrap = document.createElement('div'); + wrap.classList.add('loading-container'); + wrap.innerHTML = '
'; + return wrap; +} + /** * Remove any loading indicators within the given element. * @param {Element} element diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 4e6a8d731..0f66bd74a 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -116,6 +116,7 @@ z-index: 999; display: flex; flex-direction: column; + position: relative; &.small { margin: 2% auto; width: 800px; @@ -202,6 +203,117 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { min-height: 70vh; } +.dropzone-overlay { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; + width: 98%; + height: 98%; + left: 1%; + top: 1%; + background-color: var(--color-primary); + border: 4px dashed rgba(0, 0, 0, 0.5); + border-radius: 4px; + color: #FFF; + opacity: .8; + z-index: 9; + box-sizing: border-box; + pointer-events: none; + animation: dzAnimIn 240ms ease-in-out; +} + +@keyframes dzAnimIn { + 0% { + opacity: 0; + transform: scale(.7); + } + 60% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + opacity: .8; + } +} + +@keyframes dzFileItemIn { + 0% { + opacity: .5; + transform: translateY(28px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.dropzone-file-item { + width: 260px; + height: 80px; + position: relative; + display: flex; + margin: 1rem; + flex-direction: row; + background-color: #FFF; + box-shadow: $bs-large; + border-radius: 4px; + overflow: hidden; + padding-bottom: 3px; + animation: dzFileItemIn ease-in-out 240ms; +} +.dropzone-file-item .loading-container { + text-align: start !important; + margin: 0; +} +.dropzone-file-item-image-wrap { + width: 80px; + position: relative; + img { + object-fit: cover; + width: 100%; + height: 100%; + opacity: .8; + } +} +.dropzone-file-item-text-wrap { + flex: 1; + display: block; + padding: 1rem; + overflow: auto; +} +.dropzone-file-item-progress { + position: absolute; + bottom: 0; + left: 0; + font-size: 0; + height: 3px; + background-color: var(--color-primary); + transition: width ease-in-out 240ms; +} +.dropzone-file-item-label, +.dropzone-file-item-status { + align-items: center; + font-size: .9rem; + font-weight: 700; +} +.dropzone-file-item-status[data-status] { + display: flex; + font-size: .8rem; + font-weight: 500; + line-height: 1.2; +} +.dropzone-file-item-status[data-status="success"] { + color: $positive; +} +.dropzone-file-item-status[data-status="error"] { + color: $negative; +} +.dropzone-file-item-status[data-status] + .dropzone-file-item-label { + display: none; +} + .dropzone-container { position: relative; @include lightDark(background-color, #eee, #222); diff --git a/resources/views/form/dropzone.blade.php b/resources/views/form/dropzone.blade.php index 118761d4c..22378ff74 100644 --- a/resources/views/form/dropzone.blade.php +++ b/resources/views/form/dropzone.blade.php @@ -3,14 +3,6 @@ @placeholder - Placeholder text @successMessage --}} -
+
\ No newline at end of file diff --git a/resources/views/pages/parts/image-manager.blade.php b/resources/views/pages/parts/image-manager.blade.php index 5832c0954..d546eb787 100644 --- a/resources/views/pages/parts/image-manager.blade.php +++ b/resources/views/pages/parts/image-manager.blade.php @@ -12,7 +12,15 @@
-
+
@@ -50,11 +58,9 @@
-
+
@include('form.dropzone', [ 'placeholder' => trans('components.image_dropzone'), - 'successMessage' => trans('components.image_upload_success'), - 'url' => url('/images/gallery?' . http_build_query(['uploaded_to' => $uploaded_to ?? 0])) ])