diff --git a/app/Http/Controllers/Images/DrawioImageController.php b/app/Http/Controllers/Images/DrawioImageController.php index 106dfd630..29b1e9027 100644 --- a/app/Http/Controllers/Images/DrawioImageController.php +++ b/app/Http/Controllers/Images/DrawioImageController.php @@ -30,7 +30,10 @@ class DrawioImageController extends Controller $parentTypeFilter = $request->get('filter_type', null); $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm); - return response()->json($imgData); + return view('components.image-manager-list', [ + 'images' => $imgData['images'], + 'hasMore' => $imgData['has_more'], + ]); } /** @@ -72,6 +75,7 @@ class DrawioImageController extends Controller if ($imageData === null) { return $this->jsonError("Image data could not be found"); } + return response()->json([ 'content' => base64_encode($imageData) ]); diff --git a/app/Http/Controllers/Images/GalleryImageController.php b/app/Http/Controllers/Images/GalleryImageController.php index e506215ca..61907c003 100644 --- a/app/Http/Controllers/Images/GalleryImageController.php +++ b/app/Http/Controllers/Images/GalleryImageController.php @@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\Uploads\ImageRepo; use Illuminate\Http\Request; use BookStack\Http\Controllers\Controller; +use Illuminate\Validation\ValidationException; class GalleryImageController extends Controller { @@ -13,7 +14,6 @@ class GalleryImageController extends Controller /** * GalleryImageController constructor. - * @param ImageRepo $imageRepo */ public function __construct(ImageRepo $imageRepo) { @@ -24,8 +24,6 @@ class GalleryImageController extends Controller /** * Get a list of gallery images, in a list. * Can be paged and filtered by entity. - * @param Request $request - * @return \Illuminate\Http\JsonResponse */ public function list(Request $request) { @@ -35,14 +33,15 @@ class GalleryImageController extends Controller $parentTypeFilter = $request->get('filter_type', null); $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm); - return response()->json($imgData); + return view('components.image-manager-list', [ + 'images' => $imgData['images'], + 'hasMore' => $imgData['has_more'], + ]); } /** * Store a new gallery image in the system. - * @param Request $request - * @return Illuminate\Http\JsonResponse - * @throws \Exception + * @throws ValidationException */ public function create(Request $request) { diff --git a/app/Http/Controllers/Images/ImageController.php b/app/Http/Controllers/Images/ImageController.php index 9c67704dd..7d06facff 100644 --- a/app/Http/Controllers/Images/ImageController.php +++ b/app/Http/Controllers/Images/ImageController.php @@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller; use BookStack\Repos\PageRepo; use BookStack\Uploads\Image; use BookStack\Uploads\ImageRepo; +use Exception; use Illuminate\Filesystem\Filesystem as File; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; class ImageController extends Controller { @@ -17,9 +20,6 @@ class ImageController extends Controller /** * ImageController constructor. - * @param Image $image - * @param File $file - * @param ImageRepo $imageRepo */ public function __construct(Image $image, File $file, ImageRepo $imageRepo) { @@ -31,8 +31,6 @@ class ImageController extends Controller /** * Provide an image file from storage. - * @param string $path - * @return mixed */ public function showImage(string $path) { @@ -47,13 +45,10 @@ class ImageController extends Controller /** * Update image details - * @param Request $request - * @param integer $id - * @return \Illuminate\Http\JsonResponse * @throws ImageUploadException - * @throws \Exception + * @throws ValidationException */ - public function update(Request $request, $id) + public function update(Request $request, string $id) { $this->validate($request, [ 'name' => 'required|min:2|string' @@ -64,47 +59,50 @@ class ImageController extends Controller $this->checkOwnablePermission('image-update', $image); $image = $this->imageRepo->updateImageDetails($image, $request->all()); - return response()->json($image); + + $this->imageRepo->loadThumbs($image); + return view('components.image-manager-form', [ + 'image' => $image, + 'dependantPages' => null, + ]); } /** - * Show the usage of an image on pages. + * Get the form for editing the given image. + * @throws Exception */ - public function usage(int $id) + public function edit(Request $request, string $id) { $image = $this->imageRepo->getById($id); $this->checkImagePermission($image); - $pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']); - foreach ($pages as $page) { - $page->url = $page->getUrl(); - $page->html = ''; - $page->text = ''; + if ($request->has('delete')) { + $dependantPages = $this->imageRepo->getPagesUsingImage($image); } - $result = count($pages) > 0 ? $pages : false; - return response()->json($result); + $this->imageRepo->loadThumbs($image); + return view('components.image-manager-form', [ + 'image' => $image, + 'dependantPages' => $dependantPages ?? null, + ]); } /** * Deletes an image and all thumbnail/image files - * @param int $id - * @return \Illuminate\Http\JsonResponse - * @throws \Exception + * @throws Exception */ - public function destroy($id) + public function destroy(string $id) { $image = $this->imageRepo->getById($id); $this->checkOwnablePermission('image-delete', $image); $this->checkImagePermission($image); $this->imageRepo->destroyImage($image); - return response()->json(trans('components.images_deleted')); + return response(''); } /** * Check related page permission and ensure type is drawio or gallery. - * @param Image $image */ protected function checkImagePermission(Image $image) { diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index b7a21809f..a08555085 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -185,7 +185,7 @@ class ImageRepo * Load thumbnails onto an image object. * @throws Exception */ - protected function loadThumbs(Image $image) + public function loadThumbs(Image $image) { $image->thumbs = [ 'gallery' => $this->getThumbnail($image, 150, 150, false), @@ -219,4 +219,20 @@ class ImageRepo return null; } } + + /** + * Get the user visible pages using the given image. + */ + public function getPagesUsingImage(Image $image): array + { + $pages = Page::visible() + ->where('html', 'like', '%' . $image->url . '%') + ->get(['id', 'name', 'slug', 'book_id']); + + foreach ($pages as $page) { + $page->url = $page->getUrl(); + } + + return $pages->all(); + } } diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js index 92b19dcff..91029d042 100644 --- a/resources/js/components/ajax-form.js +++ b/resources/js/components/ajax-form.js @@ -5,52 +5,76 @@ import {onEnterPress, onSelect} from "../services/dom"; * 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. + * + * Will handle a real form if that's what the component is added to + * otherwise will act as a fake form element. + * * @extends {Component} */ class AjaxForm { setup() { this.container = this.$el; + this.responseContainer = this.container; this.url = this.$opts.url; this.method = this.$opts.method || 'post'; this.successMessage = this.$opts.successMessage; this.submitButtons = this.$manyRefs.submit || []; + if (this.$opts.responseContainer) { + this.responseContainer = this.container.closest(this.$opts.responseContainer); + } + this.setupListeners(); } setupListeners() { + + if (this.container.tagName === 'FORM') { + this.container.addEventListener('submit', this.submitRealForm.bind(this)); + return; + } + onEnterPress(this.container, event => { - this.submit(); + this.submitFakeForm(); event.preventDefault(); }); - this.submitButtons.forEach(button => onSelect(button, this.submit.bind(this))); + this.submitButtons.forEach(button => onSelect(button, this.submitFakeForm.bind(this))); } - async submit() { + submitFakeForm() { 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.submit(fd); + } + + submitRealForm(event) { + event.preventDefault(); + const fd = new FormData(this.container); + this.submit(fd); + } + + async submit(formData) { + this.responseContainer.style.opacity = '0.7'; + this.responseContainer.style.pointerEvents = 'none'; - 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}); + const resp = await window.$http[this.method.toLowerCase()](this.url, formData); + this.$emit('success', {formData}); + this.responseContainer.innerHTML = resp.data; if (this.successMessage) { window.$events.emit('success', this.successMessage); } } catch (err) { - this.container.innerHTML = err.data; + this.responseContainer.innerHTML = err.data; } - window.components.init(this.container); - this.container.style.opacity = null; - this.container.style.pointerEvents = null; + window.components.init(this.responseContainer); + this.responseContainer.style.opacity = null; + this.responseContainer.style.pointerEvents = null; } } diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index 5a7e29de5..e7273df62 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -43,7 +43,6 @@ class Dropzone { } onSuccess(file, data) { - this.container.dispatchEvent(new Event('dropzone')) this.$emit('success', {file, data}); if (this.successMessage) { diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js new file mode 100644 index 000000000..71bc55f2e --- /dev/null +++ b/resources/js/components/image-manager.js @@ -0,0 +1,201 @@ +import {onChildEvent, onSelect, removeLoading, showLoading} from "../services/dom"; + +/** + * ImageManager + * @extends {Component} + */ +class ImageManager { + + setup() { + + // Options + this.uploadedTo = this.$opts.uploadedTo; + + // Element References + this.container = this.$el; + this.popupEl = this.$refs.popup; + this.searchForm = this.$refs.searchForm; + this.searchInput = this.$refs.searchInput; + this.cancelSearch = this.$refs.cancelSearch; + this.listContainer = this.$refs.listContainer; + this.filterTabs = this.$manyRefs.filterTabs; + this.selectButton = this.$refs.selectButton; + this.formContainer = this.$refs.formContainer; + this.dropzoneContainer = this.$refs.dropzoneContainer; + + // Instance data + this.type = 'gallery'; + this.lastSelected = {}; + this.lastSelectedTime = 0; + this.resetState = () => { + this.callback = null; + this.hasData = false; + this.page = 1; + this.filter = 'all'; + }; + this.resetState(); + + this.setupListeners(); + + window.ImageManager = this; + } + + setupListeners() { + onSelect(this.filterTabs, e => { + this.resetAll(); + this.filter = e.target.dataset.filter; + this.setActiveFilterTab(this.filter); + this.loadGallery(); + }); + + this.searchForm.addEventListener('submit', event => { + this.resetListView(); + this.loadGallery(); + event.preventDefault(); + }); + + onSelect(this.cancelSearch, event => { + this.resetListView(); + this.resetSearchView(); + this.loadGallery(); + this.cancelSearch.classList.remove('active'); + }); + + this.searchInput.addEventListener('input', event => { + this.cancelSearch.classList.toggle('active', this.searchInput.value.trim()); + }); + + onChildEvent(this.listContainer, '.load-more', 'click', async event => { + showLoading(event.target); + this.page++; + await this.loadGallery(); + event.target.remove(); + }); + + this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this)); + + onSelect(this.selectButton, () => { + if (this.callback) { + this.callback(this.lastSelected); + } + this.hide(); + }); + + onChildEvent(this.formContainer, '#image-manager-delete', 'click', event => { + if (this.lastSelected) { + this.loadImageEditForm(this.lastSelected.id, true); + } + }); + + this.formContainer.addEventListener('ajax-form-success', this.refreshGallery.bind(this)); + this.container.addEventListener('dropzone-success', this.refreshGallery.bind(this)); + } + + show(callback, type = 'gallery') { + this.resetAll(); + + this.callback = callback; + this.type = type; + this.popupEl.components.popup.show(); + this.dropzoneContainer.classList.toggle('hidden', type !== 'gallery'); + + if (!this.hasData) { + this.loadGallery(); + this.hasData = true; + } + } + + hide() { + this.popupEl.components.popup.hide(); + } + + async loadGallery() { + const params = { + page: this.page, + search: this.searchInput.value || null, + uploaded_to: this.uploadedTo, + filter_type: this.filter === 'all' ? null : this.filter, + }; + + const {data: html} = await window.$http.get(`images/${this.type}`, params); + this.addReturnedHtmlElementsToList(html); + removeLoading(this.listContainer); + } + + addReturnedHtmlElementsToList(html) { + const el = document.createElement('div'); + el.innerHTML = html; + window.components.init(el); + for (const child of [...el.children]) { + this.listContainer.appendChild(child); + } + } + + setActiveFilterTab(filterName) { + this.filterTabs.forEach(t => t.classList.remove('selected')); + const activeTab = this.filterTabs.find(t => t.dataset.filter === filterName); + if (activeTab) { + activeTab.classList.add('selected'); + } + } + + resetAll() { + this.resetState(); + this.resetListView(); + this.resetSearchView(); + this.formContainer.innerHTML = ''; + this.setActiveFilterTab('all'); + } + + resetSearchView() { + this.searchInput.value = ''; + } + + resetListView() { + showLoading(this.listContainer); + this.page = 1; + } + + refreshGallery() { + this.resetListView(); + this.loadGallery(); + } + + onImageSelectEvent(event) { + const image = JSON.parse(event.detail.data); + const isDblClick = ((image && image.id === this.lastSelected.id) + && Date.now() - this.lastSelectedTime < 400); + const alreadySelected = event.target.classList.contains('selected'); + [...this.listContainer.querySelectorAll('.selected')].forEach(el => { + el.classList.remove('selected'); + }); + + if (!alreadySelected) { + event.target.classList.add('selected'); + this.loadImageEditForm(image.id); + } + this.selectButton.classList.toggle('hidden', alreadySelected); + + if (isDblClick && this.callback) { + this.callback(image); + this.hide(); + } + + this.lastSelected = image; + this.lastSelectedTime = Date.now(); + } + + async loadImageEditForm(imageId, requestDelete = false) { + if (!requestDelete) { + this.formContainer.innerHTML = ''; + } + + const params = requestDelete ? {delete: true} : {}; + const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params); + this.formContainer.innerHTML = formHtml; + window.components.init(this.formContainer); + } + +} + +export default ImageManager; \ No newline at end of file diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js index 00b34bf34..7a7b2c9bc 100644 --- a/resources/js/services/dom.js +++ b/resources/js/services/dom.js @@ -106,4 +106,15 @@ export function findText(selector, text) { */ export function showLoading(element) { element.innerHTML = `
{{ trans('components.image_delete_used') }}
+{{ trans('components.image_delete_confirm_text') }}
+ + @endif + +