From e4673246589974284295e7b8e3479f1d1a6fea1c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 24 May 2023 17:07:32 +0100 Subject: [PATCH 01/13] Updated image manager to use grid-based css --- resources/sass/_components.scss | 43 ++++++++++++------- .../pages/parts/image-manager-form.blade.php | 5 +++ .../pages/parts/image-manager-list.blade.php | 6 +-- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 5ba1286c0..459b41502 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -347,43 +347,61 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: none; } +.image-manager-list { + padding: 3px; + overflow-y: scroll; + flex: 1; + display: grid; + grid-template-columns: repeat( auto-fit, minmax(140px, 1fr) ); + gap: 3px; +} + .image-manager-list .image { display: block; position: relative; border-radius: 0; - float: left; margin: 0; + width: 100%; + text-align: start; + padding: 0; cursor: pointer; - width: math.div(100%, 6); - height: auto; + aspect-ratio: 1; @include lightDark(border-color, #ddd, #000); - box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); - transition: all cubic-bezier(.4, 0, 1, 1) 160ms; + transition: all linear 80ms; overflow: hidden; &.selected { - transform: scale3d(0.92, 0.92, 0.92); - outline: currentColor 2px solid; + background-color: var(--color-primary-light); + outline: currentColor 3px solid; + border-radius: 3px; + transform: scale3d(0.95, 0.95, 0.95); } img { width: 100%; max-width: 100%; display: block; + object-fit: cover; + height: auto; } .image-meta { + opacity: 0; position: absolute; width: 100%; bottom: 0; left: 0; color: #EEE; - background-color: rgba(0, 0, 0, 0.4); + background-color: rgba(0, 0, 0, 0.7); font-size: 10px; padding: 3px 4px; + pointer-events: none; + transition: opacity ease-in-out 80ms; span { display: block; } } - @include smaller-than($xl) { - width: math.div(100%, 4); + &.selected .image-meta, + &:hover .image-meta, + &:focus .image-meta { + opacity: 1; } @include smaller-than($m) { .image-meta { @@ -434,11 +452,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } -.image-manager-list { - overflow-y: scroll; - flex: 1; -} - .image-manager-content { display: flex; flex-direction: column; diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php index aa21e31bb..a68d20b98 100644 --- a/resources/views/pages/parts/image-manager-form.blade.php +++ b/resources/views/pages/parts/image-manager-form.blade.php @@ -14,6 +14,11 @@ title="{{ $image->name }}"> +
+

+ {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d H:i:s')]) }} +

+
diff --git a/resources/views/pages/parts/image-manager-list.blade.php b/resources/views/pages/parts/image-manager-list.blade.php index ccf79fb6d..d666725c5 100644 --- a/resources/views/pages/parts/image-manager-list.blade.php +++ b/resources/views/pages/parts/image-manager-list.blade.php @@ -1,6 +1,6 @@ @foreach($images as $index => $image)
- +
@endforeach @if($hasMore) From 6c91e09c73d8ed1d34223b886866a1ac78f94bae Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 26 May 2023 14:30:59 +0100 Subject: [PATCH 02/13] Image manager: Redesigned header bar(s) --- .../Controllers/GalleryImageController.php | 2 +- resources/js/components/image-manager.js | 12 ++- resources/sass/_components.scss | 79 +++++++++++++++---- resources/views/pages/edit.blade.php | 5 ++ .../pages/parts/image-manager-list.blade.php | 3 + .../views/pages/parts/image-manager.blade.php | 70 +++++++++------- 6 files changed, 123 insertions(+), 48 deletions(-) diff --git a/app/Uploads/Controllers/GalleryImageController.php b/app/Uploads/Controllers/GalleryImageController.php index fb74d6203..33d3dd74c 100644 --- a/app/Uploads/Controllers/GalleryImageController.php +++ b/app/Uploads/Controllers/GalleryImageController.php @@ -26,7 +26,7 @@ class GalleryImageController extends Controller $uploadedToFilter = $request->get('uploaded_to', null); $parentTypeFilter = $request->get('filter_type', null); - $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm); + $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm); return view('pages.parts.image-manager-list', [ 'images' => $imgData['images'], diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index b81782364..2c9c1d0cc 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -23,6 +23,7 @@ export class ImageManager extends Component { this.formContainer = this.$refs.formContainer; this.formContainerPlaceholder = this.$refs.formContainerPlaceholder; this.dropzoneContainer = this.$refs.dropzoneContainer; + this.loadMore = this.$refs.loadMore; // Instance data this.type = 'gallery'; @@ -59,12 +60,11 @@ export class ImageManager extends Component { this.loadGallery(); }); - onChildEvent(this.listContainer, '.load-more button', 'click', async event => { + onChildEvent(this.container, '.load-more button', 'click', async event => { const wrapper = event.target.closest('.load-more'); showLoading(wrapper); this.page += 1; await this.loadGallery(); - wrapper.remove(); }); this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this)); @@ -145,6 +145,14 @@ export class ImageManager extends Component { addReturnedHtmlElementsToList(html) { const el = document.createElement('div'); el.innerHTML = html; + + const loadMore = el.querySelector('.load-more'); + if (loadMore) { + loadMore.remove(); + this.loadMore.innerHTML = loadMore.innerHTML; + } + this.loadMore.toggleAttribute('hidden', !loadMore); + window.$components.init(el); for (const child of [...el.children]) { this.listContainer.appendChild(child); diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 459b41502..51d95236e 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -200,10 +200,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { flex: 1; } -.image-manager-body { - min-height: 70vh; -} - .dropzone-overlay { position: absolute; display: flex; @@ -347,13 +343,67 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: none; } +.image-manager-body { + min-height: 70vh; +} +.image-manager-filter-bar { + position: sticky; + top: 0; + z-index: 5; + background-color: rgba(255, 255, 255, 0.85); +} +.image-manager-filter-bar-bg { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + opacity: .15; + z-index: -1; +} +.image-manager-filter-bar .contained-search-box { + box-shadow: $bs-med; + border-radius: 4px; + margin: $-s $-m; + overflow: hidden; + input, button { + border: 0; + } + input:focus, input:active { + border: 0; + outline: 1px dotted var(--color-primary); + } + button { + width: 48px; + color: #444; + border-left: 1px solid #DDD; + background-color: #FFF; + } +} +.image-manager-filters { + box-shadow: $bs-med; + border-radius: 4px; + margin: $-s $-m; + overflow: hidden; + border-bottom: 0 !important; + button { + line-height: 0; + background-color: #FFF; + } + svg { + margin: 0; + } +} + .image-manager-list { padding: 3px; - overflow-y: scroll; - flex: 1; display: grid; grid-template-columns: repeat( auto-fit, minmax(140px, 1fr) ); gap: 3px; + z-index: 3; + > div { + aspect-ratio: 1; + } } .image-manager-list .image { @@ -411,7 +461,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .image-manager .load-more { - display: block; text-align: center; padding: $-s $-m; clear: both; @@ -456,6 +505,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { display: flex; flex-direction: column; flex: 1; + overflow-y: scroll; .container { width: 100%; } @@ -464,18 +514,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } -.image-manager [role="tablist"] button[role="tab"] { +.tab-container.bordered [role="tablist"] button[role="tab"] { border-right: 1px solid #DDD; - @include lightDark(border-color, #DDD, #000); + @include lightDark(border-right-color, #DDD, #000); &:last-child { border-right: none; } } -.image-manager-header { - z-index: 4; -} - .tab-container [role="tablist"] { display: flex; align-items: end; @@ -486,8 +532,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { margin-bottom: $-m; } -.tab-container [role="tablist"] button[role="tab"], -.image-manager [role="tablist"] button[role="tab"] { +.tab-container [role="tablist"] button[role="tab"] { display: inline-block; padding: $-s; @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5)); @@ -503,6 +548,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); } } +.tab-container.tab-primary [role="tablist"] button[role="tab"][aria-selected="true"] { + color: var(--color-primary) !important; + border-bottom-color: var(--color-primary) !important; +} .tab-container [role="tablist"].controls-card { margin-bottom: 0; border-bottom: 0; diff --git a/resources/views/pages/edit.blade.php b/resources/views/pages/edit.blade.php index 98adc849c..bdbbf8755 100644 --- a/resources/views/pages/edit.blade.php +++ b/resources/views/pages/edit.blade.php @@ -17,4 +17,9 @@ @include('pages.parts.image-manager', ['uploaded_to' => $page->id]) @include('pages.parts.code-editor') @include('entities.selector-popup') + @stop \ No newline at end of file diff --git a/resources/views/pages/parts/image-manager-list.blade.php b/resources/views/pages/parts/image-manager-list.blade.php index d666725c5..3a8a0b63b 100644 --- a/resources/views/pages/parts/image-manager-list.blade.php +++ b/resources/views/pages/parts/image-manager-list.blade.php @@ -18,6 +18,9 @@
@endforeach +@if(count($images) === 0) +

{{ trans('common.no_items') }}

+@endif @if($hasMore)
diff --git a/resources/views/pages/parts/image-manager.blade.php b/resources/views/pages/parts/image-manager.blade.php index da64d681c..53a361c27 100644 --- a/resources/views/pages/parts/image-manager.blade.php +++ b/resources/views/pages/parts/image-manager.blade.php @@ -25,39 +25,49 @@
-
-
- - - -
-
- +
+
+
+ +
+
+
+ + + +
+
+
From dc6133c4c4e67c1403dd98f42c86331b8a2170d8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 26 May 2023 18:05:29 +0100 Subject: [PATCH 03/13] Image manager: added ability to trigger load more via scroll --- resources/js/components/image-manager.js | 46 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js index 2c9c1d0cc..dddf6ad8f 100644 --- a/resources/js/components/image-manager.js +++ b/resources/js/components/image-manager.js @@ -41,6 +41,7 @@ export class ImageManager extends Component { } setupListeners() { + // Filter tab click onSelect(this.filterTabs, e => { this.resetAll(); this.filter = e.target.dataset.filter; @@ -48,31 +49,32 @@ export class ImageManager extends Component { this.loadGallery(); }); + // Search submit this.searchForm.addEventListener('submit', event => { this.resetListView(); this.loadGallery(); event.preventDefault(); }); + // Cancel search button onSelect(this.cancelSearch, () => { this.resetListView(); this.resetSearchView(); this.loadGallery(); }); - onChildEvent(this.container, '.load-more button', 'click', async event => { - const wrapper = event.target.closest('.load-more'); - showLoading(wrapper); - this.page += 1; - await this.loadGallery(); - }); + // Load more button click + onChildEvent(this.container, '.load-more button', 'click', this.runLoadMore.bind(this)); + // Select image event this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this)); + // Image load error handling this.listContainer.addEventListener('error', event => { event.target.src = window.baseUrl('loading_error.png'); }, true); + // Footer select button click onSelect(this.selectButton, () => { if (this.callback) { this.callback(this.lastSelected); @@ -80,17 +82,39 @@ export class ImageManager extends Component { this.hide(); }); + // Delete button click onChildEvent(this.formContainer, '#image-manager-delete', 'click', () => { if (this.lastSelected) { this.loadImageEditForm(this.lastSelected.id, true); } }); + // Edit form submit this.formContainer.addEventListener('ajax-form-success', () => { this.refreshGallery(); this.resetEditForm(); }); + + // Image upload success this.container.addEventListener('dropzone-upload-success', this.refreshGallery.bind(this)); + + // Auto load-more on scroll + const scrollZone = this.listContainer.parentElement; + let scrollEvents = []; + scrollZone.addEventListener('wheel', event => { + const scrollOffset = Math.ceil(scrollZone.scrollHeight - scrollZone.scrollTop); + const bottomedOut = scrollOffset === scrollZone.clientHeight; + if (!bottomedOut || event.deltaY < 1) { + return; + } + + const secondAgo = Date.now() - 1000; + scrollEvents.push(Date.now()); + scrollEvents = scrollEvents.filter(d => d >= secondAgo); + if (scrollEvents.length > 5 && this.canLoadMore()) { + this.runLoadMore(); + } + }); } show(callback, type = 'gallery') { @@ -232,4 +256,14 @@ export class ImageManager extends Component { window.$components.init(this.formContainer); } + runLoadMore() { + showLoading(this.loadMore); + this.page += 1; + this.loadGallery(); + } + + canLoadMore() { + return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden'); + } + } From 946c9ae80442bb3b413d0af44545f432e06f781c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 27 May 2023 16:58:10 +0100 Subject: [PATCH 04/13] Image manager: supported a tabbed interface on mobile Makes interface relatively usable now on mobile sizes. Required updating of tab handling to support tabs being active at only mobile screen sizes, include change on resize, upon support for potentially nested tab usage. Tab component will now search within sensible depths for finding its own tabs and panels to control. --- resources/js/components/tabs.js | 48 ++++++- resources/sass/_components.scss | 12 ++ resources/sass/_layout.scss | 4 + .../views/pages/parts/image-manager.blade.php | 136 ++++++++++-------- 4 files changed, 139 insertions(+), 61 deletions(-) diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.js index 560dc6273..c3788c747 100644 --- a/resources/js/components/tabs.js +++ b/resources/js/components/tabs.js @@ -21,15 +21,23 @@ export class Tabs extends Component { setup() { this.container = this.$el; - this.tabs = Array.from(this.container.querySelectorAll('[role="tab"]')); - this.panels = Array.from(this.container.querySelectorAll('[role="tabpanel"]')); + this.tabList = this.container.querySelector('[role="tablist"]'); + this.tabs = Array.from(this.tabList.querySelectorAll('[role="tab"]')); + this.panels = Array.from(this.container.querySelectorAll(':scope > [role="tabpanel"], :scope > * > [role="tabpanel"]')); + this.activeUnder = this.$opts.activeUnder ? Number(this.$opts.activeUnder) : 10000; + this.active = null; this.container.addEventListener('click', event => { - const button = event.target.closest('[role="tab"]'); - if (button) { - this.show(button.getAttribute('aria-controls')); + const tab = event.target.closest('[role="tab"]'); + if (tab && this.tabs.includes(tab)) { + this.show(tab.getAttribute('aria-controls')); } }); + + window.addEventListener('resize', this.updateActiveState.bind(this), { + passive: true, + }); + this.updateActiveState(); } show(sectionId) { @@ -46,4 +54,34 @@ export class Tabs extends Component { this.$emit('change', {showing: sectionId}); } + updateActiveState() { + const active = window.innerWidth < this.activeUnder; + if (active === this.active) { + return; + } + + if (active) { + this.activate(); + } else { + this.deactivate(); + } + + this.active = active; + } + + activate() { + this.show(this.panels[0].id); + this.tabList.toggleAttribute('hidden', false); + } + + deactivate() { + for (const panel of this.panels) { + panel.removeAttribute('hidden'); + } + for (const tab of this.tabs) { + tab.setAttribute('aria-selected', 'false'); + } + this.tabList.toggleAttribute('hidden', true); + } + } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 51d95236e..1a3e1669e 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -380,6 +380,12 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { background-color: #FFF; } } + +@include smaller-than($s) { + .image-manager-filter-bar .contained-search-box input { + width: 180px; + } +} .image-manager-filters { box-shadow: $bs-med; border-radius: 4px; @@ -475,6 +481,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .image-manager-sidebar { width: 300px; + margin: 0 auto; overflow-y: auto; overflow-x: hidden; border-inline-start: 1px solid #DDD; @@ -500,6 +507,11 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } } } +@include smaller-than($m) { + .image-manager-sidebar { + border-inline-start: 0; + } +} .image-manager-content { display: flex; diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 541978a65..11889da17 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -298,6 +298,10 @@ body.flexbox { } } +[hidden] { + display: none !important; +} + /** * Border radiuses */ diff --git a/resources/views/pages/parts/image-manager.blade.php b/resources/views/pages/parts/image-manager.blade.php index 53a361c27..9945f4d6c 100644 --- a/resources/views/pages/parts/image-manager.blade.php +++ b/resources/views/pages/parts/image-manager.blade.php @@ -24,67 +24,91 @@
-
-
-
-
-
- -
-
-
- - - +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
+ + + +
+
+
-
- - -
- -
-
-
- -
-

{{ trans('components.image_intro') }}

-

{{ trans('components.image_intro_upload') }}

-
- -
-
-
-
@@ -56,6 +56,7 @@ From 9ff7c97911fb274f94d5d9cb9be2fbd0f8299785 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 28 May 2023 12:07:19 +0100 Subject: [PATCH 06/13] Image manager: Added extra detail below image edit form --- lang/en/components.php | 3 ++ .../pages/parts/image-manager-form.blade.php | 32 +++++++++++++++---- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lang/en/components.php b/lang/en/components.php index 919efd331..a06c26d5b 100644 --- a/lang/en/components.php +++ b/lang/en/components.php @@ -17,6 +17,9 @@ return [ 'image_page_title' => 'View images uploaded to this page', 'image_search_hint' => 'Search by image name', 'image_uploaded' => 'Uploaded :uploadedDate', + 'image_uploaded_by' => 'Uploaded by :userName', + 'image_uploaded_to' => 'Uploaded to :pageLink', + 'image_updated' => 'Updated :updateDate', 'image_load_more' => 'Load More', 'image_image_name' => 'Image Name', 'image_delete_used' => 'This image is used in the pages below.', diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php index a68d20b98..66231a356 100644 --- a/resources/views/pages/parts/image-manager-form.blade.php +++ b/resources/views/pages/parts/image-manager-form.blade.php @@ -14,16 +14,11 @@ title="{{ $image->name }}">
-
-

- {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d H:i:s')]) }} -

-
-
+
@if(userCan('image-delete', $image)) @endif
-
+
@@ -65,4 +60,27 @@ @endif +
+
+
+ @icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->diffForHumans()]) }} +
+ @if($image->created_at->valueOf() !== $image->updated_at->valueOf()) +
+ @icon('edit') {{ trans('components.image_updated', ['updateDate' => $image->updated_at->diffForHumans()]) }} +
+ @endif + @if($image->createdBy) +
@icon('user') {{ trans('components.image_uploaded_by', ['userName' => $image->createdBy->name]) }}
+ @endif + @if(($page = $image->getPage()) && userCan('view', $page)) +
+ @icon('page') + {!! trans('components.image_uploaded_to', [ + 'pageLink' => '' . e($page->name) . '' + ]) !!} +
+ @endif +
+
\ No newline at end of file From e3c4a9d167af8a92b288bcc100347d52bff102bc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 28 May 2023 17:32:22 +0100 Subject: [PATCH 07/13] Added the ability to replace existing image files - Updated UI with image form dropdown containing delete and replace image actions. - Adds new endpoint and service/repo handling for replacing existing image. - Includes tests to cover. --- app/Uploads/Controllers/ImageController.php | 34 +++++++++++---- app/Uploads/ImageRepo.php | 41 +++++++++++------- app/Uploads/ImageService.php | 14 +++++-- lang/en/components.php | 2 + lang/en/errors.php | 1 + resources/js/components/dropzone.js | 4 ++ resources/sass/_lists.scss | 4 ++ .../pages/parts/image-manager-form.blade.php | 42 ++++++++++++++----- routes/web.php | 1 + tests/Uploads/ImageTest.php | 39 +++++++++++++++++ 10 files changed, 146 insertions(+), 36 deletions(-) diff --git a/app/Uploads/Controllers/ImageController.php b/app/Uploads/Controllers/ImageController.php index fea0713a2..2c611c515 100644 --- a/app/Uploads/Controllers/ImageController.php +++ b/app/Uploads/Controllers/ImageController.php @@ -14,13 +14,10 @@ use Illuminate\Validation\ValidationException; class ImageController extends Controller { - protected ImageRepo $imageRepo; - protected ImageService $imageService; - - public function __construct(ImageRepo $imageRepo, ImageService $imageService) - { - $this->imageRepo = $imageRepo; - $this->imageService = $imageService; + public function __construct( + protected ImageRepo $imageRepo, + protected ImageService $imageService + ) { } /** @@ -65,6 +62,29 @@ class ImageController extends Controller ]); } + /** + * Update the file for an existing image. + */ + public function updateFile(Request $request, string $id) + { + $this->validate($request, [ + 'file' => ['required', 'file', ...$this->getImageValidationRules()], + ]); + + $image = $this->imageRepo->getById($id); + $this->checkImagePermission($image); + $this->checkOwnablePermission('image-update', $image); + $file = $request->file('file'); + + try { + $this->imageRepo->updateImageFile($image, $file); + } catch (ImageUploadException $exception) { + return $this->jsonError($exception->getMessage()); + } + + return response(''); + } + /** * Get the form for editing the given image. * diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index 2d35d96ff..e28e7b794 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -11,16 +11,10 @@ use Symfony\Component\HttpFoundation\File\UploadedFile; class ImageRepo { - protected ImageService $imageService; - protected PermissionApplicator $permissions; - - /** - * ImageRepo constructor. - */ - public function __construct(ImageService $imageService, PermissionApplicator $permissions) - { - $this->imageService = $imageService; - $this->permissions = $permissions; + public function __construct( + protected ImageService $imageService, + protected PermissionApplicator $permissions + ) { } /** @@ -164,12 +158,29 @@ class ImageRepo public function updateImageDetails(Image $image, $updateDetails): Image { $image->fill($updateDetails); + $image->updated_by = user()->id; $image->save(); $this->loadThumbs($image); return $image; } + /** + * Update the image file of an existing image in the system. + * @throws ImageUploadException + */ + public function updateImageFile(Image $image, UploadedFile $file): void + { + if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) { + throw new ImageUploadException(trans('errors.image_upload_replace_type')); + } + + $image->updated_by = user()->id; + $image->save(); + $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file); + $this->loadThumbs($image, true); + } + /** * Destroys an Image object along with its revisions, files and thumbnails. * @@ -202,11 +213,11 @@ class ImageRepo /** * Load thumbnails onto an image object. */ - public function loadThumbs(Image $image): void + public function loadThumbs(Image $image, bool $forceCreate = false): void { $image->setAttribute('thumbs', [ - 'gallery' => $this->getThumbnail($image, 150, 150, false), - 'display' => $this->getThumbnail($image, 1680, null, true), + 'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate), + 'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate), ]); } @@ -215,10 +226,10 @@ class ImageRepo * If $keepRatio is true only the width will be used. * Checks the cache then storage to avoid creating / accessing the filesystem on every check. */ - protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio): ?string + protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string { try { - return $this->imageService->getThumbnail($image, $width, $height, $keepRatio); + return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate); } catch (Exception $exception) { return null; } diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 5458779e9..66596a57f 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -194,6 +194,14 @@ class ImageService return $image; } + public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void + { + $imageData = file_get_contents($file->getRealPath()); + $storage = $this->getStorageDisk($type); + $adjustedPath = $this->adjustPathForStorageDisk($path, $type); + $storage->put($adjustedPath, $imageData); + } + /** * Save image data for the given path in the public space, if possible, * for the provided storage mechanism. @@ -262,7 +270,7 @@ class ImageService * @throws Exception * @throws InvalidArgumentException */ - public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string + public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string { // Do not resize GIF images where we're not cropping if ($keepRatio && $this->isGif($image)) { @@ -277,13 +285,13 @@ class ImageService // Return path if in cache $cachedThumbPath = $this->cache->get($thumbCacheKey); - if ($cachedThumbPath) { + if ($cachedThumbPath && !$forceCreate) { return $this->getPublicUrl($cachedThumbPath); } // If thumbnail has already been generated, serve that and cache path $storage = $this->getStorageDisk($image->type); - if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) { + if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) { $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72); return $this->getPublicUrl($thumbFilePath); diff --git a/lang/en/components.php b/lang/en/components.php index a06c26d5b..8a105096b 100644 --- a/lang/en/components.php +++ b/lang/en/components.php @@ -32,6 +32,8 @@ return [ 'image_upload_success' => 'Image uploaded successfully', 'image_update_success' => 'Image details successfully updated', 'image_delete_success' => 'Image successfully deleted', + 'image_replace' => 'Replace Image', + 'image_replace_success' => 'Image file successfully updated', // Code Editor 'code_editor' => 'Edit Code', diff --git a/lang/en/errors.php b/lang/en/errors.php index 6991f96e4..b03fb8c35 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -49,6 +49,7 @@ return [ // Drawing & Images 'image_upload_error' => 'An error occurred uploading the image', 'image_upload_type_error' => 'The image type being uploaded is invalid', + 'image_upload_replace_type' => 'Image file replacements must be of the same type', 'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.', // Attachments diff --git a/resources/js/components/dropzone.js b/resources/js/components/dropzone.js index 2b8b35081..1cac09b4a 100644 --- a/resources/js/components/dropzone.js +++ b/resources/js/components/dropzone.js @@ -15,6 +15,7 @@ export class Dropzone extends Component { this.isActive = true; this.url = this.$opts.url; + this.method = (this.$opts.method || 'post').toUpperCase(); this.successMessage = this.$opts.successMessage; this.errorMessage = this.$opts.errorMessage; this.uploadLimitMb = Number(this.$opts.uploadLimit); @@ -167,6 +168,9 @@ export class Dropzone extends Component { startXhrForUpload(upload) { const formData = new FormData(); formData.append('file', upload.file, upload.file.name); + if (this.method !== 'POST') { + formData.append('_method', this.method); + } const component = this; const req = window.$http.createXMLHttpRequest('POST', this.url, { diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index 33e500d6a..ad0803e71 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -674,6 +674,10 @@ ul.pagination { text-align: start !important; max-height: 500px; overflow-y: auto; + &.anchor-left { + inset-inline-end: auto; + inset-inline-start: 0; + } &.wide { min-width: 220px; } diff --git a/resources/views/pages/parts/image-manager-form.blade.php b/resources/views/pages/parts/image-manager-form.blade.php index 66231a356..75750ef2f 100644 --- a/resources/views/pages/parts/image-manager-form.blade.php +++ b/resources/views/pages/parts/image-manager-form.blade.php @@ -1,4 +1,14 @@ -
+
id}/file") }}" + option:dropzone:method="PUT" + option:dropzone:success-message="{{ trans('components.image_update_success') }}" + option:dropzone:upload-limit="{{ config('app.upload_limit') }}" + option:dropzone:upload-limit-message="{{ trans('errors.server_upload_limit') }}" + option:dropzone:zone-text="{{ trans('entities.attachments_dropzone') }}" + option:dropzone:file-accept="image/*" + class="image-manager-details"> + +
-
- @if(userCan('image-delete', $image)) - - @endif -
-
+ @if(userCan('image-delete', $image) || userCan('image-update', $image)) + + @endif -
@if(!is_null($dependantPages)) +
@if(count($dependantPages) > 0)

{{ trans('components.image_delete_used') }}

    diff --git a/routes/web.php b/routes/web.php index 92e0a003a..48f6c27ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -140,6 +140,7 @@ Route::middleware('auth')->group(function () { Route::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']); Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']); Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']); + Route::put('/images/{id}/file', [UploadControllers\ImageController::class, 'updateFile']); Route::put('/images/{id}', [UploadControllers\ImageController::class, 'update']); Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 97e36001c..55d08dad1 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -92,6 +92,45 @@ class ImageTest extends TestCase ]); } + public function test_image_file_update() + { + $page = $this->entities->page(); + $this->asEditor(); + + $imgDetails = $this->files->uploadGalleryImageToPage($this, $page); + $relPath = $imgDetails['path']; + + $newUpload = $this->files->uploadedImage('updated-image.png', 'compressed.png'); + $this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath)); + + $imageId = $imgDetails['response']->id; + $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload]) + ->assertOk(); + + $this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath)); + + $this->files->deleteAtRelativePath($relPath); + } + + public function test_image_file_update_does_not_allow_change_in_image_extension() + { + $page = $this->entities->page(); + $this->asEditor(); + + $imgDetails = $this->files->uploadGalleryImageToPage($this, $page); + $relPath = $imgDetails['path']; + $newUpload = $this->files->uploadedImage('updated-image.jpg', 'compressed.png'); + + $imageId = $imgDetails['response']->id; + $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload]) + ->assertJson([ + "message" => "Image file replacements must be of the same type", + "status" => "error", + ]); + + $this->files->deleteAtRelativePath($relPath); + } + public function test_gallery_get_list_format() { $this->asEditor(); From f78c0635eee694e3deeb362c4d49ce6a3bcbe906 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 May 2023 14:41:59 +0100 Subject: [PATCH 08/13] Fixed bad /api docs redirection on sub path Direct route redirect does not seem to go via standard URL generator so misses off generation via base URL. --- app/Api/ApiDocsController.php | 8 ++++++++ routes/web.php | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/Api/ApiDocsController.php b/app/Api/ApiDocsController.php index 020c8902f..382ec15eb 100644 --- a/app/Api/ApiDocsController.php +++ b/app/Api/ApiDocsController.php @@ -28,4 +28,12 @@ class ApiDocsController extends ApiController return response()->json($docs); } + + /** + * Redirect to the API docs page. + */ + public function redirect() + { + return redirect('/api/docs'); + } } diff --git a/routes/web.php b/routes/web.php index 48f6c27ba..468c300ba 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,7 +29,7 @@ Route::middleware('auth')->group(function () { ->where('path', '.*$'); // API docs routes - Route::redirect('/api', '/api/docs'); + Route::get('/api', [ApiDocsController::class, 'redirect']); Route::get('/api/docs', [ApiDocsController::class, 'display']); Route::get('/pages/recently-updated', [EntityControllers\PageController::class, 'showRecentlyUpdated']); From cd4b612019991aee8d0af2332d59ee0e57fa9eea Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 May 2023 15:06:17 +0100 Subject: [PATCH 09/13] Image update API: added update image file ability --- .../Controllers/ImageGalleryApiController.php | 7 ++++++- app/Uploads/ImageRepo.php | 1 + tests/Api/ImageGalleryApiTest.php | 19 ++++++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index 1614b6445..4fca6a4dd 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -30,6 +30,7 @@ class ImageGalleryApiController extends ApiController ], 'update' => [ 'name' => ['string', 'max:180'], + 'image' => ['file', ...$this->getImageValidationRules()], ] ]; } @@ -89,7 +90,8 @@ class ImageGalleryApiController extends ApiController /** * Update the details of an existing image in the system. - * Only allows updating of the image name at this time. + * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a + * new image file. Updated image files should be of the same file type as the original image. */ public function update(Request $request, string $id) { @@ -99,6 +101,9 @@ class ImageGalleryApiController extends ApiController $this->checkOwnablePermission('image-update', $image); $this->imageRepo->updateImageDetails($image, $data); + if (isset($data['image'])) { + $this->imageRepo->updateImageFile($image, $data['image']); + } return response()->json($this->formatForSingleResponse($image)); } diff --git a/app/Uploads/ImageRepo.php b/app/Uploads/ImageRepo.php index e28e7b794..cdd5485ac 100644 --- a/app/Uploads/ImageRepo.php +++ b/app/Uploads/ImageRepo.php @@ -175,6 +175,7 @@ class ImageRepo throw new ImageUploadException(trans('errors.image_upload_replace_type')); } + $image->refresh(); $image->updated_by = user()->id; $image->save(); $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file); diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 17c90518c..067173a6b 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -295,7 +295,24 @@ class ImageGalleryApiTest extends TestCase ]); } - public function test_update_endpoint_requires_image_delete_permission() + public function test_update_existing_image_file() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($data['path'])); + + $resp = $this->call('PUT', $this->baseEndpoint . "/{$image->id}", [], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png', 'compressed.png'), + ]); + + $resp->assertStatus(200); + $this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($data['path'])); + } + + public function test_update_endpoint_requires_image_update_permission() { $user = $this->users->editor(); $this->actingAsForApi($user); From 948e95e1ad89ed9e20e37cef2233f2387c634d10 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 May 2023 15:16:16 +0100 Subject: [PATCH 10/13] Updated test to align with image manager HTML changes --- tests/Uploads/ImageTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 55d08dad1..f9cc419a4 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -532,15 +532,15 @@ class ImageTest extends TestCase $image = Image::first(); $resp = $this->get("/images/edit/{$image->id}"); - $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]'); + $this->withHtml($resp)->assertElementExists('button#image-manager-delete'); $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}"); - $this->withHtml($resp)->assertElementNotExists('button#image-manager-delete[title="Delete"]'); + $this->withHtml($resp)->assertElementNotExists('button#image-manager-delete'); $this->permissions->grantUserRolePermissions($viewer, ['image-delete-all']); $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}"); - $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]'); + $this->withHtml($resp)->assertElementExists('button#image-manager-delete'); $this->files->deleteAtRelativePath($relPath); } From f5ef52ca59207eafbabe24b9bf7dc2574f8d6cc3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 May 2023 15:50:36 +0100 Subject: [PATCH 11/13] Image manager: cleaned up style changes, dark mode support - Updated tab handling to be smarter on initial tab selection, to first target non-hidden tab panels where they may be handled server-side. - Extracted contained search box handling styles to _forms.scss, after merging with image-manager-specific styles since this is only usage of contained variant. - Aligned focus handling on image manager UI elements. --- resources/js/components/tabs.js | 3 +- resources/sass/_components.scss | 47 +++++------------ resources/sass/_forms.scss | 52 +++++++++++++++++++ resources/sass/styles.scss | 38 -------------- .../views/pages/parts/image-manager.blade.php | 8 +-- 5 files changed, 71 insertions(+), 77 deletions(-) diff --git a/resources/js/components/tabs.js b/resources/js/components/tabs.js index c3788c747..f0fc058ce 100644 --- a/resources/js/components/tabs.js +++ b/resources/js/components/tabs.js @@ -70,7 +70,8 @@ export class Tabs extends Component { } activate() { - this.show(this.panels[0].id); + const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0]; + this.show(panelToShow.id); this.tabList.toggleAttribute('hidden', false); } diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss index 1a3e1669e..1521e6eaa 100644 --- a/resources/sass/_components.scss +++ b/resources/sass/_components.scss @@ -350,7 +350,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { position: sticky; top: 0; z-index: 5; - background-color: rgba(255, 255, 255, 0.85); + @include lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85)); } .image-manager-filter-bar-bg { position: absolute; @@ -361,40 +361,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { opacity: .15; z-index: -1; } -.image-manager-filter-bar .contained-search-box { - box-shadow: $bs-med; - border-radius: 4px; - margin: $-s $-m; - overflow: hidden; - input, button { - border: 0; - } - input:focus, input:active { - border: 0; - outline: 1px dotted var(--color-primary); - } - button { - width: 48px; - color: #444; - border-left: 1px solid #DDD; - background-color: #FFF; - } -} -@include smaller-than($s) { - .image-manager-filter-bar .contained-search-box input { - width: 180px; - } -} .image-manager-filters { box-shadow: $bs-med; border-radius: 4px; - margin: $-s $-m; overflow: hidden; border-bottom: 0 !important; + @include whenDark { + border: 1px solid #000 !important; + } button { line-height: 0; - background-color: #FFF; + @include lightDark(background-color, #FFF, #333); } svg { margin: 0; @@ -527,10 +505,10 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { } .tab-container.bordered [role="tablist"] button[role="tab"] { - border-right: 1px solid #DDD; - @include lightDark(border-right-color, #DDD, #000); + border-inline-end: 1px solid #DDD; + @include lightDark(border-inline-end-color, #DDD, #000); &:last-child { - border-right: none; + border-inline-end: none; } } @@ -554,15 +532,16 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { &[aria-selected="true"] { color: var(--color-link) !important; border-bottom-color: var(--color-link) !important; + outline: 0 !important; } &:hover, &:focus { @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8)); @include lightDark(border-bottom-color, rgba(0, 0, 0, .2), rgba(255, 255, 255, .2)); } -} -.tab-container.tab-primary [role="tablist"] button[role="tab"][aria-selected="true"] { - color: var(--color-primary) !important; - border-bottom-color: var(--color-primary) !important; + &:focus { + outline: 1px dotted var(--color-primary); + outline-offset: -2px; + } } .tab-container [role="tablist"].controls-card { margin-bottom: 0; diff --git a/resources/sass/_forms.scss b/resources/sass/_forms.scss index 37f8f1bfc..5276bb566 100644 --- a/resources/sass/_forms.scss +++ b/resources/sass/_forms.scss @@ -467,6 +467,58 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] { } } +.contained-search-box { + display: flex; + height: 38px; + z-index: -1; + &.floating { + box-shadow: $bs-med; + border-radius: 4px; + overflow: hidden; + @include whenDark { + border: 1px solid #000; + } + } + 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 { + border: 0; + flex: 5; + padding: $-xs $-s; + &:focus, &:active { + outline: 1px dotted var(--color-primary); + outline-offset: -2px; + border: 0; + } + } + button { + border: 0; + width: 48px; + border-inline-start: 1px solid #DDD; + background-color: #FFF; + @include lightDark(background-color, #FFF, #333); + @include lightDark(color, #444, #AAA); + } + button:focus { + outline: 1px dotted var(--color-primary); + outline-offset: -2px; + } + svg { + margin: 0; + } + @include smaller-than($s) { + width: 180px; + } +} + .outline > input { border: 0; border-bottom: 2px solid #DDD; diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 2ed680646..9a8e5b36d 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -117,44 +117,6 @@ $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: 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; - } - svg { - margin: 0; - } -} - .entity-selector { border: 1px solid #DDD; @include lightDark(border-color, #ddd, #111); diff --git a/resources/views/pages/parts/image-manager.blade.php b/resources/views/pages/parts/image-manager.blade.php index 1824e5fa6..a57e3a1a2 100644 --- a/resources/views/pages/parts/image-manager.blade.php +++ b/resources/views/pages/parts/image-manager.blade.php @@ -40,7 +40,7 @@ role="tab">{{ trans('components.image_details') }}
-
+
-
-
-
+
+
- +
-