diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 8f5da49ed..f209d6a94 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -152,7 +152,9 @@ class AttachmentController extends Controller { $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-view', $page); - return response()->json($page->attachments); + return view('pages.attachment-list', [ + 'attachments' => $page->attachments->all(), + ]); } /** @@ -163,14 +165,13 @@ class AttachmentController extends Controller public function sortForPage(Request $request, int $pageId) { $this->validate($request, [ - 'files' => 'required|array', - 'files.*.id' => 'required|integer', + 'order' => 'required|array', ]); $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-update', $page); - $attachments = $request->get('files'); - $this->attachmentService->updateFileOrderWithinPage($attachments, $pageId); + $attachmentOrder = $request->get('order'); + $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId); return response()->json(['message' => trans('entities.attachments_order_updated')]); } diff --git a/app/Uploads/Attachment.php b/app/Uploads/Attachment.php index 3f0b447df..6e55003a9 100644 --- a/app/Uploads/Attachment.php +++ b/app/Uploads/Attachment.php @@ -30,9 +30,8 @@ class Attachment extends Ownable /** * Get the url of this file. - * @return string */ - public function getUrl() + public function getUrl(): string { if ($this->external && strpos($this->path, 'http') !== 0) { return $this->path; diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index ae4fb6e96..02220771a 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -109,14 +109,14 @@ class AttachmentService extends UploadService } /** - * Updates the file ordering for a listing of attached files. - * @param array $attachmentList - * @param $pageId + * Updates the ordering for a listing of attached files. */ - public function updateFileOrderWithinPage($attachmentList, $pageId) + public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId) { - foreach ($attachmentList as $index => $attachment) { - Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]); + foreach ($attachmentOrder as $index => $attachmentId) { + Attachment::query()->where('uploaded_to', '=', $pageId) + ->where('id', '=', $attachmentId) + ->update(['order' => $index]); } } diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js new file mode 100644 index 000000000..2feb3d5ac --- /dev/null +++ b/resources/js/components/ajax-delete-row.js @@ -0,0 +1,32 @@ +/** + * AjaxDelete + * @extends {Component} + */ +import {onSelect} from "../services/dom"; + +class AjaxDeleteRow { + setup() { + this.row = this.$el; + this.url = this.$opts.url; + this.deleteButtons = this.$manyRefs.delete; + + onSelect(this.deleteButtons, this.runDelete.bind(this)); + } + + runDelete() { + this.row.style.opacity = '0.7'; + this.row.style.pointerEvents = 'none'; + + window.$http.delete(this.url).then(resp => { + if (typeof resp.data === 'object' && resp.data.message) { + window.$events.emit('success', resp.data.message); + } + this.row.remove(); + }).catch(err => { + this.row.style.opacity = null; + this.row.style.pointerEvents = null; + }); + } +} + +export default AjaxDeleteRow; \ No newline at end of file diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js new file mode 100644 index 000000000..49ba8f388 --- /dev/null +++ b/resources/js/components/attachments.js @@ -0,0 +1,46 @@ + +/** + * Attachments + * @extends {Component} + */ +class Attachments { + + setup() { + this.container = this.$el; + this.pageId = this.$opts.pageId; + this.editContainer = this.$refs.editContainer; + this.mainTabs = this.$refs.mainTabs; + this.list = this.$refs.list; + + this.setupListeners(); + } + + setupListeners() { + this.container.addEventListener('dropzone-success', event => { + this.mainTabs.components.tabs.show('items'); + window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => { + this.list.innerHTML = resp.data; + window.components.init(this.list); + }) + }); + + this.container.addEventListener('sortable-list-sort', event => { + this.updateOrder(event.detail.ids); + }); + + this.editContainer.addEventListener('keypress', event => { + if (event.key === 'Enter') { + // TODO - Update editing file + } + }) + } + + updateOrder(idOrder) { + window.$http.put(`/attachments/sort/page/${this.pageId}`, {order: idOrder}).then(resp => { + window.$events.emit('success', resp.data.message); + }); + } + +} + +export default Attachments; \ No newline at end of file diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js new file mode 100644 index 000000000..4b12867aa --- /dev/null +++ b/resources/js/components/dropzone.js @@ -0,0 +1,69 @@ +import DropZoneLib from "dropzone"; +import {fadeOut} from "../services/animations"; + +/** + * Dropzone + * @extends {Component} + */ +class Dropzone { + setup() { + this.container = this.$el; + this.url = this.$opts.url; + + const _this = this; + this.dz = new DropZoneLib(this.container, { + addRemoveLinks: true, + dictRemoveFile: window.trans('components.image_upload_remove'), + timeout: Number(window.uploadTimeout) || 60000, + maxFilesize: Number(window.uploadLimit) || 256, + url: this.url, + withCredentials: true, + init() { + this.dz = this; + this.dz.on('sending', _this.onSending.bind(_this)); + this.dz.on('success', _this.onSuccess.bind(_this)); + this.dz.on('error', _this.onError.bind(_this)); + } + }); + } + + onSending(file, xhr, data) { + + const token = window.document.querySelector('meta[name=token]').getAttribute('content'); + data.append('_token', token); + + xhr.ontimeout = function (e) { + this.dz.emit('complete', file); + this.dz.emit('error', file, window.trans('errors.file_upload_timeout')); + } + } + + onSuccess(file, data) { + this.container.dispatchEvent(new Event('dropzone')) + this.$emit('success', {file, data}); + fadeOut(file.previewElement, 800, () => { + this.dz.removeFile(file); + }); + } + + onError(file, errorMessage, xhr) { + this.$emit('error', {file, errorMessage, xhr}); + + const setMessage = (message) => { + const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]'); + messsageEl.textContent = message; + } + + if (xhr && xhr.status === 413) { + setMessage(window.trans('errors.server_upload_limit')) + } else if (errorMessage.file) { + setMessage(errorMessage.file); + } + } + + removeAll() { + this.dz.removeAllFiles(true); + } +} + +export default Dropzone; \ No newline at end of file diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 68f97b280..4908dcd73 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -40,6 +40,14 @@ function initComponent(name, element) { instance.$refs = allRefs.refs; instance.$manyRefs = allRefs.manyRefs; instance.$opts = parseOpts(name, element); + instance.$emit = (eventName, data = {}) => { + data.from = instance; + const event = new CustomEvent(`${name}-${eventName}`, { + bubbles: true, + detail: data + }); + instance.$el.dispatchEvent(event); + }; if (typeof instance.setup === 'function') { instance.setup(); } @@ -158,4 +166,5 @@ export default initAll; * @property {Object} $refs * @property {Object} $manyRefs * @property {Object} $opts + * @property {function(string, Object)} $emit */ \ No newline at end of file diff --git a/resources/js/components/sortable-list.js b/resources/js/components/sortable-list.js index 6efcb4e84..d2b39ff95 100644 --- a/resources/js/components/sortable-list.js +++ b/resources/js/components/sortable-list.js @@ -9,9 +9,12 @@ class SortableList { this.container = this.$el; this.handleSelector = this.$opts.handleSelector; - new Sortable(this.container, { + const sortable = new Sortable(this.container, { handle: this.handleSelector, animation: 150, + onSort: () => { + this.$emit('sort', {ids: sortable.toArray()}); + } }); } } diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.js new file mode 100644 index 000000000..7121d7044 --- /dev/null +++ b/resources/js/components/tabs.js @@ -0,0 +1,51 @@ +/** + * Tabs + * Works by matching 'tabToggle' with 'tabContent' sections. + * @extends {Component} + */ +import {onSelect} from "../services/dom"; + +class Tabs { + + setup() { + this.tabContentsByName = {}; + this.tabButtonsByName = {}; + this.allContents = []; + this.allButtons = []; + + for (const [key, elems] of Object.entries(this.$manyRefs || {})) { + if (key.startsWith('toggle')) { + const cleanKey = key.replace('toggle', '').toLowerCase(); + onSelect(elems, e => this.show(cleanKey)); + this.allButtons.push(...elems); + this.tabButtonsByName[cleanKey] = elems; + } + if (key.startsWith('content')) { + const cleanKey = key.replace('content', '').toLowerCase(); + this.tabContentsByName[cleanKey] = elems; + this.allContents.push(...elems); + } + } + } + + show(key) { + this.allContents.forEach(c => { + c.classList.add('hidden'); + c.classList.remove('selected'); + }); + this.allButtons.forEach(b => b.classList.remove('selected')); + + const contents = this.tabContentsByName[key] || []; + const buttons = this.tabButtonsByName[key] || []; + if (contents.length > 0) { + contents.forEach(c => { + c.classList.remove('hidden') + c.classList.add('selected') + }); + buttons.forEach(b => b.classList.add('selected')); + } + } + +} + +export default Tabs; \ No newline at end of file diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index bb5c0078d..ac17a10d4 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -256,7 +256,7 @@ return [ 'attachments_upload' => 'Upload File', 'attachments_link' => 'Attach Link', 'attachments_set_link' => 'Set Link', - 'attachments_delete_confirm' => 'Click delete again to confirm you want to delete this attachment.', + 'attachments_delete' => 'Are you sure you want to delete this attachment?', 'attachments_dropzone' => 'Drop files or click here to attach a file', '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.', diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 226f5ccdb..b6968afc6 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -126,6 +126,10 @@ body.flexbox { flex: 1; } +.justify-flex-end { + justify-content: flex-end; +} + /** * Display and float utilities diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php index 2631f1a57..2464b019b 100644 --- a/resources/views/common/home.blade.php +++ b/resources/views/common/home.blade.php @@ -66,4 +66,6 @@ + @include('pages.attachment-manager', ['page' => \BookStack\Entities\Page::first()]) + @stop diff --git a/resources/views/components/dropzone.blade.php b/resources/views/components/dropzone.blade.php new file mode 100644 index 000000000..22bf8aff4 --- /dev/null +++ b/resources/views/components/dropzone.blade.php @@ -0,0 +1,9 @@ +{{-- +@url - URL to upload to. +@placeholder - Placeholder text +--}} +
+ +
\ No newline at end of file diff --git a/resources/views/pages/attachment-list.blade.php b/resources/views/pages/attachment-list.blade.php new file mode 100644 index 000000000..a9801870c --- /dev/null +++ b/resources/views/pages/attachment-list.blade.php @@ -0,0 +1,28 @@ +
+ @foreach($attachments as $attachment) +
+
@icon('grip')
+ +
+ +
+ + +
+
+
+ @endforeach + @if (count($attachments) === 0) +

+ {{ trans('entities.attachments_no_files') }} +

+ @endif +
\ No newline at end of file diff --git a/resources/views/pages/attachment-manager.blade.php b/resources/views/pages/attachment-manager.blade.php index dd0067804..a86cd70da 100644 --- a/resources/views/pages/attachment-manager.blade.php +++ b/resources/views/pages/attachment-manager.blade.php @@ -1,101 +1,91 @@ -
+
@exposeTranslations([ - 'entities.attachments_file_uploaded', - 'entities.attachments_file_updated', - 'entities.attachments_link_attached', - 'entities.attachments_updated_success', - 'errors.server_upload_limit', - 'components.image_upload_remove', - 'components.file_upload_timeout', + 'entities.attachments_file_uploaded', + 'entities.attachments_file_updated', + 'entities.attachments_link_attached', + 'entities.attachments_updated_success', + 'errors.server_upload_limit', + 'components.image_upload_remove', + 'components.file_upload_timeout', ])

{{ trans('entities.attachments') }}

-
+

{{ trans('entities.attachments_explain') }} {{ trans('entities.attachments_explain_instant_save') }}

-
+
-
- -
-
@icon('grip')
-
- -
- {{ trans('entities.attachments_delete_confirm') }} -
- -
-
- - -
-
-

- {{ trans('entities.attachments_no_files') }} -

+
+ @include('pages.attachment-list', ['attachments' => $page->attachments->all()])
-
- +
+ @include('components.dropzone', [ + 'placeholder' => trans('entities.attachments_dropzone'), + 'url' => url('/attachments/upload?uploaded_to=' . $page->id) + ])
-
+
-
+