diff --git a/app/Http/Controllers/Images/GalleryImageController.php b/app/Http/Controllers/Images/GalleryImageController.php index 3f2f56265..c01eccfee 100644 --- a/app/Http/Controllers/Images/GalleryImageController.php +++ b/app/Http/Controllers/Images/GalleryImageController.php @@ -42,9 +42,14 @@ class GalleryImageController extends Controller public function create(Request $request) { $this->checkPermission('image-create-all'); - $this->validate($request, [ - 'file' => $this->getImageValidationRules(), - ]); + + try { + $this->validate($request, [ + 'file' => $this->getImageValidationRules(), + ]); + } catch (ValidationException $exception) { + return $this->jsonError(implode("\n", $exception->errors()['file'])); + } try { $imageUpload = $request->file('file'); diff --git a/lang/en/components.php b/lang/en/components.php index 48a0a32fa..cd5dca251 100644 --- a/lang/en/components.php +++ b/lang/en/components.php @@ -6,6 +6,9 @@ return [ // Image Manager 'image_select' => 'Image Select', + 'image_upload' => 'Upload Image', + 'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.', + 'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.', 'image_all' => 'All', 'image_all_title' => 'View all images', 'image_book_title' => 'View images uploaded to this book', @@ -18,12 +21,12 @@ 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', 'image_update_success' => 'Image details successfully updated', 'image_delete_success' => 'Image successfully deleted', - 'image_upload_remove' => 'Remove', // Code Editor 'code_editor' => 'Edit Code', diff --git a/lang/en/entities.php b/lang/en/entities.php index 9b02f3111..9614f92fe 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -311,12 +311,12 @@ return [ 'attachments' => 'Attachments', 'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.', 'attachments_explain_instant_save' => 'Changes here are saved instantly.', - 'attachments_items' => 'Attached Items', 'attachments_upload' => 'Upload File', 'attachments_link' => 'Attach Link', + 'attachments_upload_drop' => 'Alternatively you can drag and drop a file here to upload it as an attachment.', 'attachments_set_link' => 'Set Link', 'attachments_delete' => 'Are you sure you want to delete this attachment?', - 'attachments_dropzone' => 'Drop files or click here to attach a file', + 'attachments_dropzone' => 'Drop files here to upload', 'attachments_no_files' => 'No files have been uploaded', 'attachments_explain_link' => 'You can attach a link if you\'d prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.', 'attachments_link_name' => 'Link Name', diff --git a/lang/en/errors.php b/lang/en/errors.php index 703d0edbe..6991f96e4 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -45,7 +45,6 @@ return [ 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', 'uploaded' => 'The server does not allow uploads of this size. Please try a smaller file size.', - 'file_upload_timeout' => 'The file upload has timed out.', // Drawing & Images 'image_upload_error' => 'An error occurred uploading the image', @@ -54,6 +53,7 @@ return [ // Attachments 'attachment_not_found' => 'Attachment not found', + 'attachment_upload_error' => 'An error occurred uploading the attachment file', // Pages 'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', diff --git a/package-lock.json b/package-lock.json index bb8b6049b..50987602f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", "codemirror": "^6.0.1", - "dropzone": "^5.9.3", "markdown-it": "^13.0.1", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", @@ -1234,11 +1233,6 @@ "node": ">=6.0.0" } }, - "node_modules/dropzone": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/dropzone/-/dropzone-5.9.3.tgz", - "integrity": "sha512-Azk8kD/2/nJIuVPK+zQ9sjKMRIpRvNyqn9XwbBHNq+iNuSccbJS6hwm1Woy0pMST0erSo0u4j+KJaodndDk4vA==" - }, "node_modules/emoji-regex": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", diff --git a/package.json b/package.json index 39f088234..499e32833 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "@ssddanbrown/codemirror-lang-smarty": "^1.0.0", "@ssddanbrown/codemirror-lang-twig": "^1.0.0", "codemirror": "^6.0.1", - "dropzone": "^5.9.3", "markdown-it": "^13.0.1", "markdown-it-task-lists": "^2.1.1", "snabbdom": "^3.5.1", diff --git a/readme.md b/readme.md index f56b2c2bd..722387fd4 100644 --- a/readme.md +++ b/readme.md @@ -133,7 +133,6 @@ Note: This is not an exhaustive list of all libraries and projects that would be * [CodeMirror](https://codemirror.net) - _[MIT](https://github.com/codemirror/CodeMirror/blob/master/LICENSE)_ * [Sortable](https://github.com/SortableJS/Sortable) - _[MIT](https://github.com/SortableJS/Sortable/blob/master/LICENSE)_ * [Google Material Icons](https://github.com/google/material-design-icons) - _[Apache-2.0](https://github.com/google/material-design-icons/blob/master/LICENSE)_ -* [Dropzone.js](http://www.dropzonejs.com/) - _[MIT](https://github.com/dropzone/dropzone/blob/main/LICENSE)_ * [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists) - _[MIT](https://github.com/markdown-it/markdown-it/blob/master/LICENSE) and [ISC](https://github.com/revin/markdown-it-task-lists/blob/master/LICENSE)_ * [Dompdf](https://github.com/dompdf/dompdf) - _[LGPL v2.1](https://github.com/dompdf/dompdf/blob/master/LICENSE.LGPL)_ * [BarryVD/Dompdf](https://github.com/barryvdh/laravel-dompdf) - _[MIT](https://github.com/barryvdh/laravel-dompdf/blob/master/LICENSE)_ diff --git a/resources/icons/file.svg b/resources/icons/file.svg index b6acb0988..2e87f2ca6 100644 --- a/resources/icons/file.svg +++ b/resources/icons/file.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/resources/icons/upload.svg b/resources/icons/upload.svg new file mode 100644 index 000000000..c9b0346a1 --- /dev/null +++ b/resources/icons/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js index 9555a59e8..f45b25e36 100644 --- a/resources/js/components/attachments.js +++ b/resources/js/components/attachments.js @@ -8,15 +8,16 @@ export class Attachments extends Component { this.pageId = this.$opts.pageId; this.editContainer = this.$refs.editContainer; this.listContainer = this.$refs.listContainer; - this.mainTabs = this.$refs.mainTabs; - this.list = this.$refs.list; + this.linksContainer = this.$refs.linksContainer; + this.listPanel = this.$refs.listPanel; + this.attachLinkButton = this.$refs.attachLinkButton; this.setupListeners(); } setupListeners() { const reloadListBound = this.reloadList.bind(this); - this.container.addEventListener('dropzone-success', reloadListBound); + this.container.addEventListener('dropzone-upload-success', reloadListBound); this.container.addEventListener('ajax-form-success', reloadListBound); this.container.addEventListener('sortable-list-sort', event => { @@ -39,16 +40,29 @@ export class Attachments extends Component { markdown: contentTypes['text/plain'], }); }); + + this.attachLinkButton.addEventListener('click', () => { + this.showSection('links'); + }); + } + + showSection(section) { + const sectionMap = { + links: this.linksContainer, + edit: this.editContainer, + list: this.listContainer, + }; + + for (const [name, elem] of Object.entries(sectionMap)) { + elem.toggleAttribute('hidden', name !== section); + } } reloadList() { this.stopEdit(); - /** @var {Tabs} */ - const tabs = window.$components.firstOnElement(this.mainTabs, 'tabs'); - tabs.show('attachment-panel-items'); window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => { - this.list.innerHTML = resp.data; - window.$components.init(this.list); + this.listPanel.innerHTML = resp.data; + window.$components.init(this.listPanel); }); } @@ -59,8 +73,7 @@ export class Attachments extends Component { } async startEdit(id) { - this.editContainer.classList.remove('hidden'); - this.listContainer.classList.add('hidden'); + this.showSection('edit'); showLoading(this.editContainer); const resp = await window.$http.get(`/attachments/edit/${id}`); @@ -69,8 +82,7 @@ export class Attachments extends Component { } stopEdit() { - this.editContainer.classList.add('hidden'); - this.listContainer.classList.remove('hidden'); + this.showSection('list'); } } diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index e7aae769e..1fdf824ae 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -1,74 +1,238 @@ -import DropZoneLib from 'dropzone'; -import {fadeOut} from '../services/animations'; import {Component} from './component'; +import {Clipboard} from '../services/clipboard'; +import { + elem, getLoading, onSelect, removeLoading, +} from '../services/dom'; export class Dropzone extends Component { setup() { this.container = this.$el; + this.statusArea = this.$refs.statusArea; + this.dropTarget = this.$refs.dropTarget; + this.selectButtons = this.$manyRefs.selectButton || []; + + this.isActive = true; + this.url = this.$opts.url; this.successMessage = this.$opts.successMessage; - this.removeMessage = this.$opts.removeMessage; - this.uploadLimit = Number(this.$opts.uploadLimit); + this.errorMessage = this.$opts.errorMessage; + this.uploadLimitMb = Number(this.$opts.uploadLimit); this.uploadLimitMessage = this.$opts.uploadLimitMessage; - this.timeoutMessage = this.$opts.timeoutMessage; + this.zoneText = this.$opts.zoneText; + this.fileAcceptTypes = this.$opts.fileAccept; + this.setupListeners(); + } + + /** + * Public method to allow external disabling/enabling of this drag+drop dropzone. + * @param {Boolean} active + */ + toggleActive(active) { + this.isActive = active; + } + + setupListeners() { + onSelect(this.selectButtons, this.manualSelectHandler.bind(this)); + this.setupDropTargetHandlers(); + } + + setupDropTargetHandlers() { + let depth = 0; + + const reset = () => { + this.hideOverlay(); + depth = 0; + }; + + this.dropTarget.addEventListener('dragenter', event => { + event.preventDefault(); + depth += 1; + + if (depth === 1 && this.isActive) { + this.showOverlay(); + } + }); + + this.dropTarget.addEventListener('dragover', event => { + event.preventDefault(); + }); + + this.dropTarget.addEventListener('dragend', reset); + this.dropTarget.addEventListener('dragleave', () => { + depth -= 1; + if (depth === 0) { + reset(); + } + }); + this.dropTarget.addEventListener('drop', event => { + event.preventDefault(); + reset(); + + if (!this.isActive) { + return; + } + + const clipboard = new Clipboard(event.dataTransfer); + const files = clipboard.getFiles(); + for (const file of files) { + this.createUploadFromFile(file); + } + }); + } + + manualSelectHandler() { + const input = elem('input', {type: 'file', style: 'left: -400px; visibility: hidden; position: fixed;', accept: this.fileAcceptTypes}); + this.container.append(input); + input.click(); + input.addEventListener('change', () => { + for (const file of input.files) { + this.createUploadFromFile(file); + } + input.remove(); + }); + } + + showOverlay() { + const overlay = this.dropTarget.querySelector('.dropzone-overlay'); + if (!overlay) { + const zoneElem = elem('div', {class: 'dropzone-overlay'}, [this.zoneText]); + this.dropTarget.append(zoneElem); + } + } + + hideOverlay() { + const overlay = this.dropTarget.querySelector('.dropzone-overlay'); + if (overlay) { + overlay.remove(); + } + } + + /** + * @param {File} file + * @return {Upload} + */ + createUploadFromFile(file) { + const { + dom, status, progress, dismiss, + } = this.createDomForFile(file); + this.statusArea.append(dom); const component = this; - this.dz = new DropZoneLib(this.container, { - addRemoveLinks: true, - dictRemoveFile: this.removeMessage, - timeout: Number(window.uploadTimeout) || 60000, - maxFilesize: this.uploadLimit, - url: this.url, - withCredentials: true, - init() { - this.dz = this; - this.dz.on('sending', component.onSending.bind(component)); - this.dz.on('success', component.onSuccess.bind(component)); - this.dz.on('error', component.onError.bind(component)); + + const upload = { + file, + dom, + updateProgress(percentComplete) { + progress.textContent = `${percentComplete}%`; + progress.style.width = `${percentComplete}%`; + }, + markError(message) { + status.setAttribute('data-status', 'error'); + status.textContent = message; + removeLoading(dom); + this.updateProgress(100); + }, + markSuccess(message) { + status.setAttribute('data-status', 'success'); + status.textContent = message; + removeLoading(dom); + setTimeout(dismiss, 2400); + component.$emit('upload-success', { + name: file.name, + }); + }, + }; + + // Enforce early upload filesize limit + if (file.size > (this.uploadLimitMb * 1000000)) { + upload.markError(this.uploadLimitMessage); + return upload; + } + + this.startXhrForUpload(upload); + + return upload; + } + + /** + * @param {Upload} upload + */ + startXhrForUpload(upload) { + const formData = new FormData(); + formData.append('file', upload.file, upload.file.name); + const component = this; + + const req = window.$http.createXMLHttpRequest('POST', this.url, { + error() { + upload.markError(component.errorMessage); + }, + readystatechange() { + if (this.readyState === XMLHttpRequest.DONE && this.status === 200) { + upload.markSuccess(component.successMessage); + } else if (this.readyState === XMLHttpRequest.DONE && this.status >= 400) { + const content = this.responseText; + const data = content.startsWith('{') ? JSON.parse(content) : {message: content}; + const message = data?.message || data?.error || content; + upload.markError(message); + } }, }); - } - onSending(file, xhr, data) { - const token = window.document.querySelector('meta[name=token]').getAttribute('content'); - data.append('_token', token); - - xhr.ontimeout = () => { - this.dz.emit('complete', file); - this.dz.emit('error', file, this.timeoutMessage); - }; - } - - onSuccess(file, data) { - this.$emit('success', {file, data}); - - if (this.successMessage) { - window.$events.emit('success', this.successMessage); - } - - fadeOut(file.previewElement, 800, () => { - this.dz.removeFile(file); + req.upload.addEventListener('progress', evt => { + const percent = Math.min(Math.ceil((evt.loaded / evt.total) * 100), 100); + upload.updateProgress(percent); }); + + req.setRequestHeader('Accept', 'application/json'); + req.send(formData); } - onError(file, errorMessage, xhr) { - this.$emit('error', {file, errorMessage, xhr}); + /** + * @param {File} file + * @return {{image: Element, dom: Element, progress: Element, status: Element, dismiss: function}} + */ + createDomForFile(file) { + const image = elem('img', {src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9.224 7.373a.924.924 0 0 0-.92.925l-.006 7.404c0 .509.412.925.921.925h5.557a.928.928 0 0 0 .926-.925v-5.553l-2.777-2.776Zm3.239 3.239V8.067l2.545 2.545z' style='fill:%23000;fill-opacity:.75'/%3E%3C/svg%3E"}); + 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]); - const setMessage = message => { - const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]'); - messsageEl.textContent = message; + 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); + } + + const dismiss = () => { + dom.classList.add('dismiss'); + dom.addEventListener('animationend', () => { + dom.remove(); + }); }; - if (xhr && xhr.status === 413) { - setMessage(this.uploadLimitMessage); - } else if (errorMessage.file) { - setMessage(errorMessage.file); - } - } + dom.addEventListener('click', dismiss); - removeAll() { - this.dz.removeAllFiles(true); + return { + dom, progress, status, dismiss, + }; } } + +/** + * @typedef Upload + * @property {File} file + * @property {Element} dom + * @property {function(Number)} updateProgress + * @property {function(String)} markError + * @property {function(String)} markSuccess + */ diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index 3cb99f0e2..b81782364 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -18,7 +18,10 @@ export class ImageManager extends Component { this.listContainer = this.$refs.listContainer; this.filterTabs = this.$manyRefs.filterTabs; this.selectButton = this.$refs.selectButton; + this.uploadButton = this.$refs.uploadButton; + this.uploadHint = this.$refs.uploadHint; this.formContainer = this.$refs.formContainer; + this.formContainerPlaceholder = this.$refs.formContainerPlaceholder; this.dropzoneContainer = this.$refs.dropzoneContainer; // Instance data @@ -54,18 +57,14 @@ export class ImageManager extends Component { this.resetListView(); this.resetSearchView(); this.loadGallery(); - this.cancelSearch.classList.remove('active'); }); - this.searchInput.addEventListener('input', () => { - this.cancelSearch.classList.toggle('active', this.searchInput.value.trim()); - }); - - onChildEvent(this.listContainer, '.load-more', 'click', async event => { - showLoading(event.target); + onChildEvent(this.listContainer, '.load-more button', 'click', async event => { + const wrapper = event.target.closest('.load-more'); + showLoading(wrapper); this.page += 1; await this.loadGallery(); - event.target.remove(); + wrapper.remove(); }); this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this)); @@ -87,8 +86,11 @@ export class ImageManager extends Component { } }); - this.formContainer.addEventListener('ajax-form-success', this.refreshGallery.bind(this)); - this.container.addEventListener('dropzone-success', this.refreshGallery.bind(this)); + this.formContainer.addEventListener('ajax-form-success', () => { + this.refreshGallery(); + this.resetEditForm(); + }); + this.container.addEventListener('dropzone-upload-success', this.refreshGallery.bind(this)); } show(callback, type = 'gallery') { @@ -97,7 +99,15 @@ export class ImageManager extends Component { this.callback = callback; this.type = type; this.getPopup().show(); - this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery'); + + const hideUploads = type !== 'gallery'; + this.dropzoneContainer.classList.toggle('hidden', hideUploads); + this.uploadButton.classList.toggle('hidden', hideUploads); + this.uploadHint.classList.toggle('hidden', hideUploads); + + /** @var {Dropzone} * */ + const dropzone = window.$components.firstOnElement(this.container, 'dropzone'); + dropzone.toggleActive(!hideUploads); if (!this.hasData) { this.loadGallery(); @@ -163,6 +173,7 @@ export class ImageManager extends Component { resetEditForm() { this.formContainer.innerHTML = ''; + this.formContainerPlaceholder.removeAttribute('hidden'); } resetListView() { @@ -209,6 +220,7 @@ export class ImageManager extends Component { const params = requestDelete ? {delete: true} : {}; const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params); this.formContainer.innerHTML = formHtml; + this.formContainerPlaceholder.setAttribute('hidden', ''); window.$components.init(this.formContainer); } diff --git a/resources/js/services/clipboard.js b/resources/js/services/clipboard.js index 02db29be0..5f73c3020 100644 --- a/resources/js/services/clipboard.js +++ b/resources/js/services/clipboard.js @@ -30,7 +30,6 @@ export class Clipboard { */ getImages() { const {types} = this.data; - const {files} = this.data; const images = []; for (const type of types) { @@ -40,15 +39,21 @@ export class Clipboard { } } - for (const file of files) { - if (file.type.includes('image')) { - images.push(file); - } - } + const imageFiles = this.getFiles().filter(f => f.type.includes('image')); + images.push(...imageFiles); return images; } + /** + * Get the files included in the clipboard data. + * @return {File[]} + */ + getFiles() { + const {files} = this.data; + return [...files]; + } + } export async function copyTextToClipboard(text) { 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/js/services/http.js b/resources/js/services/http.js index d0d33e317..49d5b6df4 100644 --- a/resources/js/services/http.js +++ b/resources/js/services/http.js @@ -45,6 +45,27 @@ export class HttpError extends Error { } +/** + * @param {String} method + * @param {String} url + * @param {Object} events + * @return {XMLHttpRequest} + */ +export function createXMLHttpRequest(method, url, events = {}) { + const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); + const req = new XMLHttpRequest(); + + for (const [eventName, callback] of Object.entries(events)) { + req.addEventListener(eventName, callback.bind(req)); + } + + req.open(method, url); + req.withCredentials = true; + req.setRequestHeader('X-CSRF-TOKEN', csrfToken); + + return req; +} + /** * Create a new HTTP request, setting the required CSRF information * to communicate with the back-end. Parses & formats the response. diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 4e6a8d731..5ba1286c0 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; @@ -144,6 +145,7 @@ box-shadow: none; color: #FFF; padding: $-xs $-m; + cursor: pointer; } .popup-header button:not(.popup-header-close) { @@ -202,10 +204,147 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { min-height: 70vh; } -.dropzone-container { - position: relative; - @include lightDark(background-color, #eee, #222); +.dropzone-overlay { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + font-size: 1.333rem; + width: 98%; + height: 98%; + left: 1%; + top: 1%; + border-radius: 4px; + border: 1px dashed var(--color-primary); + font-style: italic; + box-sizing: content-box; + background-clip: padding-box; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3E%3Cpath fill='%23a9a9a9' fill-opacity='0.52' d='M1 3h1v1H1V3zm2-2h1v1H3V1z'%3E%3C/path%3E%3C/svg%3E"); + background-color: var(--color-primary); + color: #FFF; + opacity: .8; + z-index: 9; + pointer-events: none; + animation: dzAnimIn 240ms ease-in-out; +} + +.dropzone-landing-area { + background-color: var(--color-primary-light); + padding: $-m $-l; + width: 100%; + border: 1px dashed var(--color-primary); + color: var(--color-primary); + border-radius: 4px; +} + +@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); + } +} +@keyframes dzFileItemOut { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: .5; + transform: translateY(28px); + } +} + +.dropzone-file-item { + width: 260px; + height: 80px; + position: relative; + display: flex; + margin: 1rem; + flex-direction: row; + @include lightDark(background, #FFF, #444); + box-shadow: $bs-large; + border-radius: 4px; + overflow: hidden; + padding-bottom: 3px; + animation: dzFileItemIn ease-in-out 240ms; + transition: transform ease-in-out 120ms, box-shadow ease-in-out 120ms; + cursor: pointer; + &:hover { + transform: translateY(-3px); + box-shadow: 0 3px 8px 1px rgba(22, 22, 22, 0.2); + } +} +.dropzone-file-item.dismiss { + animation: dzFileItemOut ease-in-out 240ms; +} +.dropzone-file-item .loading-container { + text-align: start !important; + margin: 0; +} +.dropzone-file-item-image-wrap { + width: 80px; + position: relative; + background-color: var(--color-primary-light); + 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: .8rem; + font-weight: 700; +} +.dropzone-file-item-status[data-status] { + display: flex; + font-size: .6rem; + 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; } .image-manager-list .image { @@ -256,13 +395,11 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .image-manager .load-more { display: block; text-align: center; - @include lightDark(background-color, #EEE, #444); padding: $-s $-m; - color: #AAA; clear: both; - font-size: 20px; - cursor: pointer; - font-style: italic; + .loading-container { + margin: 0; + } } .image-manager .loading-container { @@ -279,7 +416,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { min-height: auto; padding: $-m; } - img { + .image-manager-viewer img { max-width: 100%; max-height: 180px; display: block; @@ -295,10 +432,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: inline-block; } } - .dropzone-container { - border-bottom: 1px solid #DDD; - @include lightDark(border-color, #ddd, #000); - } } .image-manager-list { @@ -318,295 +451,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } -// Dropzone -/* - * The MIT License - * Copyright (c) 2012 Matias Meno - */ -.dz-message { - font-size: 1em; - line-height: 2.85; - font-style: italic; - color: #888; - text-align: center; - cursor: pointer; - padding: $-l $-m; - transition: all ease-in-out 120ms; -} - -.dz-drag-hover .dz-message { - background-color: rgb(16, 126, 210); - color: #EEE; -} - -@keyframes passing-through { - 0% { - opacity: 0; - transform: translateY(40px); - } - 30%, 70% { - opacity: 1; - transform: translateY(0px); - } - 100% { - opacity: 0; - transform: translateY(-40px); +.image-manager [role="tablist"] button[role="tab"] { + border-right: 1px solid #DDD; + @include lightDark(border-color, #DDD, #000); + &:last-child { + border-right: none; } } -@keyframes slide-in { - 0% { - opacity: 0; - transform: translateY(40px); - } - 30% { - opacity: 1; - transform: translateY(0px); - } +.image-manager-header { + z-index: 4; } -@keyframes pulse { - 0% { - transform: scale(1); - } - 10% { - transform: scale(1.1); - } - 20% { - transform: scale(1); - } -} - -.dropzone, .dropzone * { - box-sizing: border-box; -} - -.dz-preview { - position: relative; - display: inline-block; - vertical-align: top; - margin: 12px; - min-height: 80px; -} - -.dz-preview:hover { - z-index: 1000; -} - -.dz-preview:hover .dz-details { - opacity: 1; -} - -.dz-preview.dz-file-preview .dz-image { - border-radius: 4px; - background: #e9e9e9; -} - -.dz-preview.dz-file-preview .dz-details { - opacity: 1; -} - -.dz-preview.dz-image-preview { - background: white; -} - -.dz-preview.dz-image-preview .dz-details { - transition: opacity 0.2s linear; -} - -.dz-preview .dz-remove { - font-size: 13px; - text-align: center; - display: block; - cursor: pointer; - border: none; - margin-top: 3px; -} - -.dz-preview .dz-remove:hover { - text-decoration: underline; -} - -.dz-preview:hover .dz-details { - opacity: 1; -} - -.dz-preview .dz-details { - z-index: 20; - position: absolute; - top: 0; - left: 0; - opacity: 0; - font-size: 10px; - min-width: 100%; - max-width: 100%; - padding: 6px 3px; - text-align: center; - color: rgba(0, 0, 0, 0.9); - line-height: 150%; -} - -.dz-preview .dz-details .dz-size { - margin-bottom: 0.5em; - font-size: 12px; -} - -.dz-preview .dz-details .dz-filename { - white-space: nowrap; -} - -.dz-preview .dz-details .dz-filename:hover span { - border: 1px solid rgba(200, 200, 200, 0.8); - background-color: rgba(255, 255, 255, 0.8); -} - -.dz-preview .dz-details .dz-filename:not(:hover) { - overflow: hidden; - text-overflow: ellipsis; -} - -.dz-preview .dz-details .dz-filename:not(:hover) span { - border: 1px solid transparent; -} - -.dz-preview .dz-details .dz-filename span { - background-color: rgba(255, 255, 255, 0.4); - padding: 0 0.4em; - border-radius: 3px; -} - -.dz-preview:hover .dz-image img { - filter: blur(8px); -} - -.dz-preview .dz-image { - border-radius: 4px; - overflow: hidden; - width: 80px; - height: 80px; - position: relative; - display: block; - z-index: 10; -} - -.dz-preview .dz-image img { - display: block; -} - -.dz-preview.dz-success .dz-success-mark { - animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); -} - -.dz-preview.dz-error .dz-error-mark { - opacity: 1; - animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); -} - -.dz-preview .dz-success-mark, .dz-preview .dz-error-mark { - pointer-events: none; - opacity: 0; - z-index: 1001; - position: absolute; - display: block; - top: 50%; - left: 50%; - margin-inline-start: -27px; - margin-top: -35px; -} - -.dz-preview .dz-success-mark svg, .dz-preview .dz-error-mark svg { - display: block; - width: 54px; - height: 54px; -} - -.dz-preview.dz-processing .dz-progress { - opacity: 1; - transition: all 0.2s linear; -} - -.dz-preview.dz-complete .dz-progress { - opacity: 0; - transition: opacity 0.4s ease-in; -} - -.dz-preview:not(.dz-processing) .dz-progress { - animation: pulse 6s ease infinite; -} - -.dz-preview .dz-progress { - opacity: 1; - z-index: 1000; - pointer-events: none; - position: absolute; - height: 16px; - left: 50%; - top: 50%; - margin-top: -8px; - width: 80px; - margin-inline-start: -40px; - background: rgba(255, 255, 255, 0.9); - transform: scale(1); - border-radius: 8px; - overflow: hidden; -} - -.dz-preview .dz-progress .dz-upload { - background: #333; - background: linear-gradient(to bottom, #666, #444); - position: absolute; - top: 0; - left: 0; - bottom: 0; - width: 0; - transition: width 300ms ease-in-out; -} - -.dz-preview.dz-error .dz-error-message { - display: block; -} - -.dz-preview.dz-error { - .dz-image, .dz-details { - &:hover ~ .dz-error-message { - opacity: 1; - pointer-events: auto; - } - } -} - -.dz-preview .dz-error-message { - pointer-events: none; - z-index: 1000; - position: absolute; - display: block; - display: none; - opacity: 0; - transition: opacity 0.3s ease; - border-radius: 4px; - font-size: 12px; - line-height: 1.2; - top: 88px; - left: -12px; - width: 160px; - background: $negative; - padding: $-xs; - color: white; -} - -.dz-preview .dz-error-message:after { - content: ''; - position: absolute; - top: -6px; - left: 44px; - width: 0; - height: 0; - border-inline-start: 6px solid transparent; - border-inline-end: 6px solid transparent; - border-bottom: 6px solid $negative; -} - - .tab-container [role="tablist"] { display: flex; align-items: end; diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 19333faf7..541978a65 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -253,6 +253,15 @@ body.flexbox { position: relative; } +.fixed { + position: fixed; + z-index: 20; + &.top-right { + top: 0; + right: 0; + } +} + .hidden { display: none !important; } diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 0f4ec7041..2ed680646 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -120,29 +120,36 @@ $loadingSize: 10px; .contained-search-box { display: flex; height: 38px; + z-index: -1; input, button { + height: 100%; border-radius: 0; border: 1px solid #ddd; @include lightDark(border-color, #ddd, #000); margin-inline-start: -1px; + &:last-child { + border-inline-end: 0; + } } input { flex: 5; padding: $-xs $-s; &:focus, &:active { - outline: 0; + outline: 1px dotted var(--color-primary); + outline-offset: -2px; + border: 1px solid #ddd; + @include lightDark(border-color, #ddd, #000); } } button { width: 60px; } + button.primary-background { + border-color: var(--color-primary); + } button i { padding: 0; } - button.cancel.active { - background-color: $negative; - color: #EEE; - } svg { margin: 0; } diff --git a/resources/views/attachments/manager-edit-form.blade.php b/resources/views/attachments/manager-edit-form.blade.php index 15837448a..cfb514df0 100644 --- a/resources/views/attachments/manager-edit-form.blade.php +++ b/resources/views/attachments/manager-edit-form.blade.php @@ -1,7 +1,7 @@
{{ trans('entities.attachments_edit_file') }}
@@ -17,18 +17,35 @@
-