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 = `<div class="loading-container"><div></div><div></div><div></div></div>`; +} + +/** + * Remove any loading indicators within the given element. + * @param {Element} element + */ +export function removeLoading(element) { + const loadingEls = element.querySelectorAll('.loading-container'); + for (const el of loadingEls) { + el.remove(); + } } \ No newline at end of file diff --git a/resources/js/vues/image-manager.js b/resources/js/vues/image-manager.js deleted file mode 100644 index b87734556..000000000 --- a/resources/js/vues/image-manager.js +++ /dev/null @@ -1,204 +0,0 @@ -import * as Dates from "../services/dates"; -import dropzone from "./components/dropzone"; - -let page = 1; -let previousClickTime = 0; -let previousClickImage = 0; -let dataLoaded = false; -let callback = false; -let baseUrl = ''; - -let preSearchImages = []; -let preSearchHasMore = false; - -const data = { - images: [], - - imageType: false, - uploadedTo: false, - - selectedImage: false, - dependantPages: false, - showing: false, - filter: null, - hasMore: false, - searching: false, - searchTerm: '', - - imageUpdateSuccess: false, - imageDeleteSuccess: false, - deleteConfirm: false, -}; - -const methods = { - - show(providedCallback, imageType = null) { - callback = providedCallback; - this.showing = true; - this.$el.children[0].components.popup.show(); - - // Get initial images if they have not yet been loaded in. - if (dataLoaded && imageType === this.imageType) return; - if (imageType) { - this.imageType = imageType; - this.resetState(); - } - this.fetchData(); - dataLoaded = true; - }, - - hide() { - if (this.$refs.dropzone) { - this.$refs.dropzone.onClose(); - } - this.showing = false; - this.selectedImage = false; - this.$el.children[0].components.popup.hide(); - }, - - async fetchData() { - const params = { - page, - search: this.searching ? this.searchTerm : null, - uploaded_to: this.uploadedTo || null, - filter_type: this.filter, - }; - - const {data} = await this.$http.get(baseUrl, params); - this.images = this.images.concat(data.images); - this.hasMore = data.has_more; - page++; - }, - - setFilterType(filterType) { - this.filter = filterType; - this.resetState(); - this.fetchData(); - }, - - resetState() { - this.cancelSearch(); - this.resetListView(); - this.deleteConfirm = false; - baseUrl = window.baseUrl(`/images/${this.imageType}`); - }, - - resetListView() { - this.images = []; - this.hasMore = false; - page = 1; - }, - - searchImages() { - if (this.searchTerm === '') return this.cancelSearch(); - - // Cache current settings for later - if (!this.searching) { - preSearchImages = this.images; - preSearchHasMore = this.hasMore; - } - - this.searching = true; - this.resetListView(); - this.fetchData(); - }, - - cancelSearch() { - if (!this.searching) return; - this.searching = false; - this.searchTerm = ''; - this.images = preSearchImages; - this.hasMore = preSearchHasMore; - }, - - imageSelect(image) { - const dblClickTime = 300; - const currentTime = Date.now(); - const timeDiff = currentTime - previousClickTime; - const isDblClick = timeDiff < dblClickTime && image.id === previousClickImage; - - if (isDblClick) { - this.callbackAndHide(image); - } else { - this.selectedImage = image; - this.deleteConfirm = false; - this.dependantPages = false; - } - - previousClickTime = currentTime; - previousClickImage = image.id; - }, - - callbackAndHide(imageResult) { - if (callback) callback(imageResult); - this.hide(); - }, - - async saveImageDetails() { - let url = window.baseUrl(`/images/${this.selectedImage.id}`); - try { - await this.$http.put(url, this.selectedImage) - } catch (error) { - if (error.response.status === 422) { - let errors = error.response.data; - let message = ''; - Object.keys(errors).forEach((key) => { - message += errors[key].join('\n'); - }); - this.$events.emit('error', message); - } - } - }, - - async deleteImage() { - - if (!this.deleteConfirm) { - const url = window.baseUrl(`/images/usage/${this.selectedImage.id}`); - try { - const {data} = await this.$http.get(url); - this.dependantPages = data; - } catch (error) { - console.error(error); - } - this.deleteConfirm = true; - return; - } - - const url = window.baseUrl(`/images/${this.selectedImage.id}`); - await this.$http.delete(url); - this.images.splice(this.images.indexOf(this.selectedImage), 1); - this.selectedImage = false; - this.$events.emit('success', trans('components.image_delete_success')); - this.deleteConfirm = false; - }, - - getDate(stringDate) { - return Dates.formatDateTime(new Date(stringDate)); - }, - - uploadSuccess(event) { - this.images.unshift(event.data); - this.$events.emit('success', trans('components.image_upload_success')); - }, -}; - -const computed = { - uploadUrl() { - return window.baseUrl(`/images/${this.imageType}`); - } -}; - -function mounted() { - window.ImageManager = this; - this.imageType = this.$el.getAttribute('image-type'); - this.uploadedTo = this.$el.getAttribute('uploaded-to'); - baseUrl = window.baseUrl('/images/' + this.imageType) -} - -export default { - mounted, - methods, - data, - computed, - components: {dropzone}, -}; diff --git a/resources/js/vues/vues.js b/resources/js/vues/vues.js index faa191b95..d4bd88a52 100644 --- a/resources/js/vues/vues.js +++ b/resources/js/vues/vues.js @@ -4,10 +4,7 @@ function exists(id) { return document.getElementById(id) !== null; } -import imageManager from "./image-manager"; - let vueMapping = { - 'image-manager': imageManager, }; window.vues = {}; diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 68c58b92b..e87bd11a5 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -33,6 +33,7 @@ return [ 'copy' => 'Copy', 'reply' => 'Reply', 'delete' => 'Delete', + 'delete_confirm' => 'Confirm Deletion', 'search' => 'Search', 'search_clear' => 'Clear Search', 'reset' => 'Reset', diff --git a/resources/lang/en/components.php b/resources/lang/en/components.php index 32667eb4e..48a0a32fa 100644 --- a/resources/lang/en/components.php +++ b/resources/lang/en/components.php @@ -15,7 +15,7 @@ return [ 'image_load_more' => 'Load More', 'image_image_name' => 'Image Name', 'image_delete_used' => 'This image is used in the pages below.', - 'image_delete_confirm' => 'Click delete again to confirm you want to delete this image.', + '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', 'images_deleted' => 'Images Deleted', diff --git a/resources/sass/_colors.scss b/resources/sass/_colors.scss index 683694d96..a76d166e9 100644 --- a/resources/sass/_colors.scss +++ b/resources/sass/_colors.scss @@ -51,6 +51,11 @@ fill: currentColor !important; } +.text-white { + color: #fff; + fill: currentColor !important; +} + /* * Entity text colors */ diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index c73c503b4..eb40741d1 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -197,11 +197,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { transition: all cubic-bezier(.4, 0, 1, 1) 160ms; overflow: hidden; &.selected { - //transform: scale3d(0.92, 0.92, 0.92); - border: 4px solid #FFF; - overflow: hidden; - border-radius: 8px; - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); + transform: scale3d(0.92, 0.92, 0.92); + outline: currentColor 2px solid; } img { width: 100%; @@ -231,7 +228,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } -#image-manager .load-more { +.image-manager .load-more { display: block; text-align: center; @include lightDark(background-color, #EEE, #444); @@ -243,6 +240,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { font-style: italic; } +.image-manager .loading-container { + text-align: center; +} + .image-manager-sidebar { width: 300px; overflow-y: auto; @@ -250,6 +251,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { border-inline-start: 1px solid #DDD; @include lightDark(border-color, #ddd, #000); .inner { + min-height: auto; padding: $-m; } img { @@ -291,6 +293,12 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } +.image-manager .corner-button { + margin: 0; + border-radius: 0; + padding: $-m; +} + // Dropzone /* * The MIT License @@ -298,7 +306,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { */ .dz-message { font-size: 1em; - line-height: 2.35; + line-height: 2.85; font-style: italic; color: #888; text-align: center; @@ -601,9 +609,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: inline-block; @include lightDark(color, #666, #999); cursor: pointer; + border-right: 1px solid rgba(0, 0, 0, 0.1); + border-bottom: 2px solid transparent; &.selected { border-bottom: 2px solid var(--color-primary); } + &:last-child { + border-right: 0; + } } } diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index b6968afc6..439bf8512 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -121,6 +121,11 @@ body.flexbox { position: relative; } +.flex-container-column { + display: flex; + flex-direction: column; +} + .flex { min-height: 0; flex: 1; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 8af363469..330af51e8 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -140,8 +140,10 @@ $btt-size: 40px; .contained-search-box { display: flex; + height: 38px; input, button { border-radius: 0; + border: 1px solid #ddd; @include lightDark(border-color, #ddd, #000); margin-inline-start: -1px; } @@ -162,6 +164,9 @@ $btt-size: 40px; background-color: $negative; color: #EEE; } + svg { + margin: 0; + } } .entity-selector { diff --git a/resources/views/components/image-manager-form.blade.php b/resources/views/components/image-manager-form.blade.php new file mode 100644 index 000000000..e49a5fca7 --- /dev/null +++ b/resources/views/components/image-manager-form.blade.php @@ -0,0 +1,60 @@ +<div class="image-manager-details"> + + <form component="ajax-form" + option:ajax-form:success-message="{{ trans('components.image_update_success') }}" + option:ajax-form:method="put" + option:ajax-form:response-container=".image-manager-details" + option:ajax-form:url="{{ url('images/' . $image->id) }}"> + + <div class="image-manager-viewer"> + <a href="{{ $image->url }}" target="_blank" class="block"> + <img src="{{ $image->thumbs['display'] }}" + alt="{{ $image->name }}" + class="anim fadeIn" + title="{{ $image->name }}"> + </a> + </div> + <div class="form-group stretch-inputs"> + <label for="name">{{ trans('components.image_image_name') }}</label> + <input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}"> + </div> + <div class="grid half"> + <div> + <button type="button" + id="image-manager-delete" + title="{{ trans('common.delete') }}" + class="button icon outline">@icon('delete')</button> + </div> + <div class="text-right"> + <button type="submit" + class="button icon outline">{{ trans('common.save') }}</button> + </div> + </div> + </form> + + @if(!is_null($dependantPages)) + @if(count($dependantPages) > 0) + <p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p> + <ul class="text-neg"> + @foreach($dependantPages as $page) + <li> + <a href="{{ $page->url }}" + target="_blank" + class="text-neg">{{ $page->name }}</a> + </li> + @endforeach + </ul> + @endif + <p class="text-neg mb-xs">{{ trans('components.image_delete_confirm_text') }}</p> + <form component="ajax-form" + option:ajax-form:success-message="{{ trans('components.image_delete_success') }}" + option:ajax-form:method="delete" + option:ajax-form:response-container=".image-manager-details" + option:ajax-form:url="{{ url('images/' . $image->id) }}"> + <button type="submit" class="button neg"> + {{ trans('common.delete_confirm') }} + </button> + </form> + @endif + +</div> \ No newline at end of file diff --git a/resources/views/components/image-manager-list.blade.php b/resources/views/components/image-manager-list.blade.php new file mode 100644 index 000000000..e5562e10f --- /dev/null +++ b/resources/views/components/image-manager-list.blade.php @@ -0,0 +1,23 @@ +@foreach($images as $index => $image) +<div> + <div component="event-emit-select" + option:event-emit-select:name="image" + option:event-emit-select:data="{{ json_encode($image) }}" + class="image anim fadeIn text-primary" + style="animation-delay: {{ $index > 26 ? '160ms' : ($index * 25) . 'ms' }};"> + <img src="{{ $image->thumbs['gallery'] }}" + alt="{{ $image->name }}" + width="150" + height="150" + loading="lazy" + title="{{ $image->name }}"> + <div class="image-meta"> + <span class="name">{{ $image->name }}</span> + <span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d H:i:s')]) }}</span> + </div> + </div> +</div> +@endforeach +@if($hasMore) + <div class="load-more">{{ trans('components.image_load_more') }}</div> +@endif \ No newline at end of file diff --git a/resources/views/components/image-manager.blade.php b/resources/views/components/image-manager.blade.php index 5e2de7bb8..4f03eeaec 100644 --- a/resources/views/components/image-manager.blade.php +++ b/resources/views/components/image-manager.blade.php @@ -1,101 +1,62 @@ -<div id="image-manager" image-type="{{ $imageType }}" uploaded-to="{{ $uploaded_to ?? 0 }}"> +<div component="image-manager" + option:image-manager:uploaded-to="{{ $uploaded_to ?? 0 }}" + class="image-manager"> - @exposeTranslations([ - 'components.image_delete_success', - 'components.image_upload_success', - 'errors.server_upload_limit', - 'components.image_upload_remove', - 'components.file_upload_timeout', - ]) - - <div component="popup" class="popup-background" v-cloak @click="hide"> - <div class="popup-body" tabindex="-1" @click.stop> + <div component="popup" + refs="image-manager@popup" + class="popup-background"> + <div class="popup-body" tabindex="-1"> <div class="popup-header primary-background"> <div class="popup-title">{{ trans('components.image_select') }}</div> - <button class="popup-header-close" @click="hide()">x</button> + <button refs="popup@hide" type="button" class="popup-header-close">x</button> </div> <div class="flex-fill image-manager-body"> <div class="image-manager-content"> - <div v-if="imageType === 'gallery' || imageType === 'drawio'" class="image-manager-header primary-background-light nav-tabs grid third"> - <div class="tab-item" title="{{ trans('components.image_all_title') }}" :class="{selected: !filter}" @click="setFilterType(null)">@icon('images') {{ trans('components.image_all') }}</div> - <div class="tab-item" title="{{ trans('components.image_book_title') }}" :class="{selected: (filter=='book')}" @click="setFilterType('book')">@icon('book', ['class' => 'text-book svg-icon']) {{ trans('entities.book') }}</div> - <div class="tab-item" title="{{ trans('components.image_page_title') }}" :class="{selected: (filter=='page')}" @click="setFilterType('page')">@icon('page', ['class' => 'text-page svg-icon']) {{ trans('entities.page') }}</div> + <div class="image-manager-header primary-background-light nav-tabs grid third no-gap"> + <button refs="image-manager@filterTabs" + data-filter="all" + type="button" class="tab-item selected" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }}</button> + <button refs="image-manager@filterTabs" + data-filter="book" + type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'text-book svg-icon']) {{ trans('entities.book') }}</button> + <button refs="image-manager@filterTabs" + data-filter="page" + type="button" class="tab-item" title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'text-page svg-icon']) {{ trans('entities.page') }}</button> </div> <div> - <form @submit.prevent="searchImages" class="contained-search-box"> - <input placeholder="{{ trans('components.image_search_hint') }}" v-model="searchTerm" type="text"> - <button :class="{active: searching}" title="{{ trans('common.search_clear') }}" type="button" @click="cancelSearch()" class="text-button cancel">@icon('close')</button> - <button title="{{ trans('common.search') }}" class="text-button">@icon('search')</button> + <form refs="image-manager@searchForm" class="contained-search-box"> + <input refs="image-manager@searchInput" + placeholder="{{ trans('components.image_search_hint') }}" + type="text"> + <button refs="image-manager@cancelSearch" + title="{{ trans('common.search_clear') }}" + type="button" + class="cancel">@icon('close')</button> + <button type="submit" class="primary-background text-white" + title="{{ trans('common.search') }}">@icon('search')</button> </form> </div> - <div class="image-manager-list"> - <div v-if="images.length > 0" v-for="(image, idx) in images"> - <div class="image anim fadeIn" :style="{animationDelay: (idx > 26) ? '160ms' : ((idx * 25) + 'ms')}" - :class="{selected: (image==selectedImage)}" @click="imageSelect(image)"> - <img :src="image.thumbs.gallery" :alt="image.title" :title="image.name"> - <div class="image-meta"> - <span class="name" v-text="image.name"></span> - <span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => "{{ getDate(image.created_at) }" . "}"]) }}</span> - </div> - </div> - </div> - <div class="load-more" v-show="hasMore" @click="fetchData">{{ trans('components.image_load_more') }}</div> - </div> + <div refs="image-manager@listContainer" class="image-manager-list"></div> </div> - <div class="image-manager-sidebar"> - - <dropzone v-if="imageType !== 'drawio'" ref="dropzone" placeholder="{{ trans('components.image_dropzone') }}" :upload-url="uploadUrl" :uploaded-to="uploadedTo" @success="uploadSuccess"></dropzone> - - <div class="inner"> - - <div class="image-manager-details anim fadeIn" v-if="selectedImage"> - - <form @submit.prevent="saveImageDetails"> - <div class="image-manager-viewer"> - <a :href="selectedImage.url" target="_blank" style="display: block;"> - <img :src="selectedImage.thumbs.display" :alt="selectedImage.name" - :title="selectedImage.name"> - </a> - </div> - <div class="form-group"> - <label for="name">{{ trans('components.image_image_name') }}</label> - <input id="name" class="input-base" name="name" v-model="selectedImage.name"> - </div> - </form> - - <div class="clearfix"> - <div class="float left"> - <button type="button" class="button icon outline" @click="deleteImage">@icon('delete')</button> - - </div> - <button class="button anim fadeIn float right" v-show="selectedImage" @click="callbackAndHide(selectedImage)"> - {{ trans('components.image_select_image') }} - </button> - <div class="clearfix"></div> - <div v-show="dependantPages"> - <p class="text-neg text-small"> - {{ trans('components.image_delete_used') }} - </p> - <ul class="text-neg"> - <li v-for="page in dependantPages"> - <a :href="page.url" target="_blank" class="text-neg" v-text="page.name"></a> - </li> - </ul> - </div> - <div v-show="deleteConfirm" class="text-neg text-small"> - {{ trans('components.image_delete_confirm') }} - </div> - </div> - - </div> - - + <div class="image-manager-sidebar flex-container-column"> + <div refs="image-manager@dropzoneContainer"> + @include('components.dropzone', [ + 'placeholder' => trans('components.image_dropzone'), + 'successMessage' => trans('components.image_upload_success'), + 'url' => url('/images/gallery?' . http_build_query(['uploaded_to' => $uploaded_to ?? 0])) + ]) </div> + + <div refs="image-manager@formContainer" class="inner flex"></div> + + <button refs="image-manager@selectButton" type="button" class="hidden button corner-button"> + {{ trans('components.image_select_image') }} + </button> </div> </div> diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index cfb66fdd0..5acd11af4 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -20,8 +20,7 @@ </form> </div> - @include('components.image-manager', ['imageType' => 'gallery', 'uploaded_to' => $page->id]) + @include('components.image-manager', ['uploaded_to' => $page->id]) @include('components.code-editor') @include('components.entity-selector-popup') - @stop \ No newline at end of file diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php index 6ccb8d8f9..0c8ff843a 100644 --- a/resources/views/settings/index.blade.php +++ b/resources/views/settings/index.blade.php @@ -275,6 +275,5 @@ </div> - @include('components.image-manager', ['imageType' => 'system']) @include('components.entity-selector-popup', ['entityTypes' => 'page']) @stop diff --git a/routes/web.php b/routes/web.php index 314515fe8..fb586c1cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -101,22 +101,14 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/user/{userId}', 'UserController@showProfilePage'); // Image routes - Route::group(['prefix' => 'images'], function () { - - // Gallery - Route::get('/gallery', 'Images\GalleryImageController@list'); - Route::post('/gallery', 'Images\GalleryImageController@create'); - - // Drawio - Route::get('/drawio', 'Images\DrawioImageController@list'); - Route::get('/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64'); - Route::post('/drawio', 'Images\DrawioImageController@create'); - - // Shared gallery & draw.io endpoint - Route::get('/usage/{id}', 'Images\ImageController@usage'); - Route::put('/{id}', 'Images\ImageController@update'); - Route::delete('/{id}', 'Images\ImageController@destroy'); - }); + Route::get('/images/gallery', 'Images\GalleryImageController@list'); + Route::post('/images/gallery', 'Images\GalleryImageController@create'); + Route::get('/images/drawio', 'Images\DrawioImageController@list'); + Route::get('/images/drawio/base64/{id}', 'Images\DrawioImageController@getAsBase64'); + Route::post('/images/drawio', 'Images\DrawioImageController@create'); + Route::get('/images/edit/{id}', 'Images\ImageController@edit'); + Route::put('/images/{id}', 'Images\ImageController@update'); + Route::delete('/images/{id}', 'Images\ImageController@destroy'); // Attachments routes Route::get('/attachments/{id}', 'AttachmentController@get'); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 3cdcdf3fd..0de94158b 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -71,11 +71,7 @@ class ImageTest extends TestCase $newName = Str::random(); $update = $this->put('/images/' . $image->id, ['name' => $newName]); $update->assertSuccessful(); - $update->assertJson([ - 'id' => $image->id, - 'name' => $newName, - 'type' => 'gallery', - ]); + $update->assertSee($newName); $this->deleteImage($imgDetails['path']); @@ -92,31 +88,22 @@ class ImageTest extends TestCase $imgDetails = $this->uploadGalleryImage(); $image = Image::query()->first(); - $emptyJson = ['images' => [], 'has_more' => false]; - $resultJson = [ - 'images' => [ - [ - 'id' => $image->id, - 'name' => $imgDetails['name'], - ] - ], - 'has_more' => false, - ]; - $pageId = $imgDetails['page']->id; $firstPageRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}"); - $firstPageRequest->assertSuccessful()->assertJson($resultJson); + $firstPageRequest->assertSuccessful()->assertElementExists('div'); + $firstPageRequest->assertSuccessful()->assertSeeText($image->name); $secondPageRequest = $this->get("/images/gallery?page=2&uploaded_to={$pageId}"); - $secondPageRequest->assertSuccessful()->assertExactJson($emptyJson); + $secondPageRequest->assertSuccessful()->assertElementNotExists('div'); $namePartial = substr($imgDetails['name'], 0, 3); $searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}"); - $searchHitRequest->assertSuccessful()->assertJson($resultJson); + $searchHitRequest->assertSuccessful()->assertSee($imgDetails['name']); $namePartial = Str::random(16); - $searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}"); - $searchHitRequest->assertSuccessful()->assertExactJson($emptyJson); + $searchFailRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}"); + $searchFailRequest->assertSuccessful()->assertDontSee($imgDetails['name']); + $searchFailRequest->assertSuccessful()->assertElementNotExists('div'); } public function test_image_usage() @@ -131,14 +118,10 @@ class ImageTest extends TestCase $page->html = '<img src="'.$image->url.'">'; $page->save(); - $usage = $this->get('/images/usage/' . $image->id); + $usage = $this->get('/images/edit/' . $image->id . '?delete=true'); $usage->assertSuccessful(); - $usage->assertJson([ - [ - 'id' => $page->id, - 'name' => $page->name - ] - ]); + $usage->assertSeeText($page->name); + $usage->assertSee($page->getUrl()); $this->deleteImage($imgDetails['path']); }