diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index f209d6a94..0830693bc 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -8,6 +8,7 @@ use BookStack\Uploads\AttachmentService; use Exception; use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Http\Request; +use Illuminate\Support\MessageBag; use Illuminate\Validation\ValidationException; class AttachmentController extends Controller @@ -60,25 +61,17 @@ class AttachmentController extends Controller /** * Update an uploaded attachment. * @throws ValidationException - * @throws NotFoundException */ public function uploadUpdate(Request $request, $attachmentId) { $this->validate($request, [ - 'uploaded_to' => 'required|integer|exists:pages,id', 'file' => 'required|file' ]); - $pageId = $request->get('uploaded_to'); - $page = $this->pageRepo->getById($pageId); - $attachment = $this->attachment->findOrFail($attachmentId); - - $this->checkOwnablePermission('page-update', $page); + $attachment = $this->attachment->newQuery()->findOrFail($attachmentId); + $this->checkOwnablePermission('view', $attachment->page); + $this->checkOwnablePermission('page-update', $attachment->page); $this->checkOwnablePermission('attachment-create', $attachment); - - if (intval($pageId) !== intval($attachment->uploaded_to)) { - return $this->jsonError(trans('errors.attachment_page_mismatch')); - } $uploadedFile = $request->file('file'); @@ -92,57 +85,87 @@ class AttachmentController extends Controller } /** - * Update the details of an existing file. - * @throws ValidationException - * @throws NotFoundException + * Get the update form for an attachment. + * @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View */ - public function update(Request $request, $attachmentId) + public function getUpdateForm(string $attachmentId) { - $this->validate($request, [ - 'uploaded_to' => 'required|integer|exists:pages,id', - 'name' => 'required|string|min:1|max:255', - 'link' => 'string|min:1|max:255' - ]); - - $pageId = $request->get('uploaded_to'); - $page = $this->pageRepo->getById($pageId); $attachment = $this->attachment->findOrFail($attachmentId); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission('page-update', $attachment->page); $this->checkOwnablePermission('attachment-create', $attachment); - if (intval($pageId) !== intval($attachment->uploaded_to)) { - return $this->jsonError(trans('errors.attachment_page_mismatch')); + return view('attachments.manager-edit-form', [ + 'attachment' => $attachment, + ]); + } + + /** + * Update the details of an existing file. + */ + public function update(Request $request, string $attachmentId) + { + $attachment = $this->attachment->newQuery()->findOrFail($attachmentId); + + try { + $this->validate($request, [ + 'attachment_edit_name' => 'required|string|min:1|max:255', + 'attachment_edit_url' => 'string|min:1|max:255' + ]); + } catch (ValidationException $exception) { + return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [ + 'attachment' => $attachment, + 'errors' => new MessageBag($exception->errors()), + ]), 422); } - $attachment = $this->attachmentService->updateFile($attachment, $request->all()); - return response()->json($attachment); + $this->checkOwnablePermission('view', $attachment->page); + $this->checkOwnablePermission('page-update', $attachment->page); + $this->checkOwnablePermission('attachment-create', $attachment); + + $attachment = $this->attachmentService->updateFile($attachment, [ + 'name' => $request->get('attachment_edit_name'), + 'link' => $request->get('attachment_edit_url'), + ]); + + return view('attachments.manager-edit-form', [ + 'attachment' => $attachment, + ]); } /** * Attach a link to a page. - * @throws ValidationException * @throws NotFoundException */ public function attachLink(Request $request) { - $this->validate($request, [ - 'uploaded_to' => 'required|integer|exists:pages,id', - 'name' => 'required|string|min:1|max:255', - 'link' => 'required|string|min:1|max:255' - ]); + $pageId = $request->get('attachment_link_uploaded_to'); + + try { + $this->validate($request, [ + 'attachment_link_uploaded_to' => 'required|integer|exists:pages,id', + 'attachment_link_name' => 'required|string|min:1|max:255', + 'attachment_link_url' => 'required|string|min:1|max:255' + ]); + } catch (ValidationException $exception) { + return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [ + 'pageId' => $pageId, + 'errors' => new MessageBag($exception->errors()), + ]), 422); + } - $pageId = $request->get('uploaded_to'); $page = $this->pageRepo->getById($pageId); $this->checkPermission('attachment-create-all'); $this->checkOwnablePermission('page-update', $page); - $attachmentName = $request->get('name'); - $link = $request->get('link'); + $attachmentName = $request->get('attachment_link_name'); + $link = $request->get('attachment_link_url'); $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId); - return response()->json($attachment); + return view('attachments.manager-link-form', [ + 'pageId' => $pageId, + ]); } /** @@ -152,7 +175,7 @@ class AttachmentController extends Controller { $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-view', $page); - return view('pages.attachment-list', [ + return view('attachments.manager-list', [ 'attachments' => $page->attachments->all(), ]); } @@ -180,7 +203,7 @@ class AttachmentController extends Controller * @throws FileNotFoundException * @throws NotFoundException */ - public function get(int $attachmentId) + public function get(string $attachmentId) { $attachment = $this->attachment->findOrFail($attachmentId); try { @@ -201,11 +224,9 @@ class AttachmentController extends Controller /** * Delete a specific attachment in the system. - * @param $attachmentId - * @return mixed * @throws Exception */ - public function delete(int $attachmentId) + public function delete(string $attachmentId) { $attachment = $this->attachment->findOrFail($attachmentId); $this->checkOwnablePermission('attachment-delete', $attachment); diff --git a/package-lock.json b/package-lock.json index b60173683..3687566ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3801,14 +3801,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz", "integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ==" }, - "vuedraggable": { - "version": "2.23.2", - "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.23.2.tgz", - "integrity": "sha512-PgHCjUpxEAEZJq36ys49HfQmXglattf/7ofOzUrW2/rRdG7tu6fK84ir14t1jYv4kdXewTEa2ieKEAhhEMdwkQ==", - "requires": { - "sortablejs": "^1.10.1" - } - }, "watchpack": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.7.2.tgz", diff --git a/package.json b/package.json index 7462dab92..0f1855cd9 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "markdown-it": "^11.0.0", "markdown-it-task-lists": "^2.1.1", "sortablejs": "^1.10.2", - "vue": "^2.6.11", - "vuedraggable": "^2.23.2" + "vue": "^2.6.11" }, "browser": { "vue": "vue/dist/vue.common.js" diff --git a/readme.md b/readme.md index 2c68d094c..c6db7a8e4 100644 --- a/readme.md +++ b/readme.md @@ -158,7 +158,7 @@ These are the great open-source projects used to help build BookStack: * [TinyMCE](https://www.tinymce.com/) * [CodeMirror](https://codemirror.net) * [Vue.js](http://vuejs.org/) -* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable) +* [Sortable](https://github.com/SortableJS/Sortable) * [Google Material Icons](https://material.io/icons/) * [Dropzone.js](http://www.dropzonejs.com/) * [clipboard.js](https://clipboardjs.com/) diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js new file mode 100644 index 000000000..92b19dcff --- /dev/null +++ b/resources/js/components/ajax-form.js @@ -0,0 +1,58 @@ +import {onEnterPress, onSelect} from "../services/dom"; + +/** + * Ajax Form + * Will handle button clicks or input enter press events and submit + * the data over ajax. Will always expect a partial HTML view to be returned. + * Fires an 'ajax-form-success' event when submitted successfully. + * @extends {Component} + */ +class AjaxForm { + setup() { + this.container = this.$el; + this.url = this.$opts.url; + this.method = this.$opts.method || 'post'; + this.successMessage = this.$opts.successMessage; + this.submitButtons = this.$manyRefs.submit || []; + + this.setupListeners(); + } + + setupListeners() { + onEnterPress(this.container, event => { + this.submit(); + event.preventDefault(); + }); + + this.submitButtons.forEach(button => onSelect(button, this.submit.bind(this))); + } + + async submit() { + const fd = new FormData(); + const inputs = this.container.querySelectorAll(`[name]`); + console.log(inputs); + for (const input of inputs) { + fd.append(input.getAttribute('name'), input.value); + } + + this.container.style.opacity = '0.7'; + this.container.style.pointerEvents = 'none'; + try { + const resp = await window.$http[this.method.toLowerCase()](this.url, fd); + this.container.innerHTML = resp.data; + this.$emit('success', {formData: fd}); + if (this.successMessage) { + window.$events.emit('success', this.successMessage); + } + } catch (err) { + this.container.innerHTML = err.data; + } + + window.components.init(this.container); + this.container.style.opacity = null; + this.container.style.pointerEvents = null; + } + +} + +export default AjaxForm; \ No newline at end of file diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js index 49ba8f388..51e54054e 100644 --- a/resources/js/components/attachments.js +++ b/resources/js/components/attachments.js @@ -1,14 +1,16 @@ - /** * Attachments * @extends {Component} */ +import {showLoading} from "../services/dom"; + class Attachments { setup() { this.container = this.$el; 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; @@ -16,23 +18,30 @@ class Attachments { } 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); - }) - }); + const reloadListBound = this.reloadList.bind(this); + this.container.addEventListener('dropzone-success', reloadListBound); + this.container.addEventListener('ajax-form-success', reloadListBound); 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 - } - }) + this.container.addEventListener('event-emit-select-edit', event => { + this.startEdit(event.detail.id); + }); + + this.container.addEventListener('event-emit-select-edit-back', event => { + this.stopEdit(); + }); + } + + reloadList() { + this.stopEdit(); + 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); + }); } updateOrder(idOrder) { @@ -41,6 +50,21 @@ class Attachments { }); } + async startEdit(id) { + this.editContainer.classList.remove('hidden'); + this.listContainer.classList.add('hidden'); + + showLoading(this.editContainer); + const resp = await window.$http.get(`/attachments/edit/${id}`); + this.editContainer.innerHTML = resp.data; + window.components.init(this.editContainer); + } + + stopEdit() { + this.editContainer.classList.add('hidden'); + this.listContainer.classList.remove('hidden'); + } + } export default Attachments; \ No newline at end of file diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index 4b12867aa..5a7e29de5 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -9,11 +9,15 @@ class Dropzone { setup() { this.container = this.$el; this.url = this.$opts.url; + this.successMessage = this.$opts.successMessage; + this.removeMessage = this.$opts.removeMessage; + this.uploadLimitMessage = this.$opts.uploadLimitMessage; + this.timeoutMessage = this.$opts.timeoutMessage; const _this = this; this.dz = new DropZoneLib(this.container, { addRemoveLinks: true, - dictRemoveFile: window.trans('components.image_upload_remove'), + dictRemoveFile: this.removeMessage, timeout: Number(window.uploadTimeout) || 60000, maxFilesize: Number(window.uploadLimit) || 256, url: this.url, @@ -32,15 +36,20 @@ class Dropzone { const token = window.document.querySelector('meta[name=token]').getAttribute('content'); data.append('_token', token); - xhr.ontimeout = function (e) { + xhr.ontimeout = (e) => { this.dz.emit('complete', file); - this.dz.emit('error', file, window.trans('errors.file_upload_timeout')); + this.dz.emit('error', file, this.timeoutMessage); } } onSuccess(file, data) { this.container.dispatchEvent(new Event('dropzone')) this.$emit('success', {file, data}); + + if (this.successMessage) { + window.$events.emit('success', this.successMessage); + } + fadeOut(file.previewElement, 800, () => { this.dz.removeFile(file); }); @@ -55,7 +64,7 @@ class Dropzone { } if (xhr && xhr.status === 413) { - setMessage(window.trans('errors.server_upload_limit')) + setMessage(this.uploadLimitMessage); } else if (errorMessage.file) { setMessage(errorMessage.file); } diff --git a/resources/js/components/event-emit-select.js b/resources/js/components/event-emit-select.js new file mode 100644 index 000000000..cf0215850 --- /dev/null +++ b/resources/js/components/event-emit-select.js @@ -0,0 +1,29 @@ +import {onSelect} from "../services/dom"; + +/** + * EventEmitSelect + * Component will simply emit an event when selected. + * + * Has one required option: "name". + * A name of "hello" will emit a component DOM event of + * "event-emit-select-name" + * + * All options will be set as the "detail" of the event with + * their values included. + * + * @extends {Component} + */ +class EventEmitSelect { + setup() { + this.container = this.$el; + this.name = this.$opts.name; + + + onSelect(this.$el, () => { + this.$emit(this.name, this.$opts); + }); + } + +} + +export default EventEmitSelect; \ No newline at end of file diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js index 2a9fad8b3..00b34bf34 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.js @@ -53,6 +53,14 @@ export function onEnterPress(elements, callback) { if (!Array.isArray(elements)) { elements = [elements]; } + + const listener = event => { + if (event.key === 'Enter') { + callback(event); + } + } + + elements.forEach(e => e.addEventListener('keypress', listener)); } /** @@ -89,4 +97,13 @@ export function findText(selector, text) { } } return null; +} + +/** + * Show a loading indicator in the given element. + * This will effectively clear the element. + * @param {Element} element + */ +export function showLoading(element) { + element.innerHTML = `
{{ trans('entities.attachments_explain_link') }}
+{{ trans('entities.attachments_explain') }} {{ trans('entities.attachments_explain_instant_save') }}
- -{{ trans('entities.attachments_explain_link') }}
-