]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #4265 from BookStackApp/image_manager_responsive
authorDan Brown <redacted>
Mon, 29 May 2023 15:52:55 +0000 (16:52 +0100)
committerGitHub <redacted>
Mon, 29 May 2023 15:52:55 +0000 (16:52 +0100)
Enhanced Responsive Image Manager

24 files changed:
app/Api/ApiDocsController.php
app/Uploads/Controllers/GalleryImageController.php
app/Uploads/Controllers/ImageController.php
app/Uploads/Controllers/ImageGalleryApiController.php
app/Uploads/ImageRepo.php
app/Uploads/ImageService.php
lang/en/common.php
lang/en/components.php
lang/en/errors.php
resources/js/components/dropzone.js
resources/js/components/image-manager.js
resources/js/components/tabs.js
resources/sass/_animations.scss
resources/sass/_components.scss
resources/sass/_forms.scss
resources/sass/_layout.scss
resources/sass/_lists.scss
resources/sass/styles.scss
resources/views/pages/parts/image-manager-form.blade.php
resources/views/pages/parts/image-manager-list.blade.php
resources/views/pages/parts/image-manager.blade.php
routes/web.php
tests/Api/ImageGalleryApiTest.php
tests/Uploads/ImageTest.php

index 020c8902f32a19a7d0ef5a896c78874ca10c5eb1..382ec15ebf4f5cfc55be8797f2ce3a8d16646fc0 100644 (file)
@@ -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');
+    }
 }
index fb74d620300c691e7ea6f5896065f8a054338249..33d3dd74c671b323bc2c503d14d82d28998a137a 100644 (file)
@@ -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'],
index fea0713a242f48cbe9cc8e60f584274af11dff10..2c611c515bff8746f87c868c0d5ec5f3dce1c0e4 100644 (file)
@@ -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.
      *
index 1614b64450ef151720dca38a19e038deec81b4d0..4fca6a4dd7d2fe28cacfd0f5227a21399b9100a7 100644 (file)
@@ -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));
     }
index 2d35d96ffa127ca167c3d8ac78af622e51693da2..cdd5485acc127845670792f2128ac816a989e231 100644 (file)
@@ -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,30 @@ 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->refresh();
+        $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 +214,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 +227,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;
         }
index 5458779e943906db62a58cce8f12587a248d5139..66596a57f0980fc19da26a7eeaf0d5efb1458b6a 100644 (file)
@@ -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);
index c74dcc90775219416dfe1e56cd0c2d9c577c6f4d..de7937b2be406a00fb344bb079b0773a77b5533c 100644 (file)
@@ -6,6 +6,7 @@ return [
 
     // Buttons
     'cancel' => 'Cancel',
+    'close' => 'Close',
     'confirm' => 'Confirm',
     'back' => 'Back',
     'save' => 'Save',
index cd5dca251bbc7ec59f779fb9d727b57dfbd2c0c2..8a105096b4f6133295c637acaab526e44f4811a4 100644 (file)
@@ -6,6 +6,8 @@ return [
 
     // Image Manager
     'image_select' => 'Image Select',
+    'image_list' => 'Image List',
+    'image_details' => 'Image Details',
     'image_upload' => 'Upload Image',
     'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.',
     'image_intro_upload' => 'Upload a new image by dragging an image file into this window, or by using the "Upload Image" button above.',
@@ -15,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.',
@@ -27,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',
index 6991f96e4359facd80e789406e28054c8432b2b0..b03fb8c355aa7bc9e1b18b95035dbc1b42ea085d 100644 (file)
@@ -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
index 2b8b35081188f8754a744b5fd412a86076224670..1cac09b4a5d16e21baac1a6d2bc931b261827c7c 100644 (file)
@@ -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, {
index b817823643506b4c34a8a253ee6ffae9ec9e0749..78abcf30d8b8c5465f360094f9a9d193be02dd5e 100644 (file)
@@ -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';
@@ -40,6 +41,7 @@ export class ImageManager extends Component {
     }
 
     setupListeners() {
+        // Filter tab click
         onSelect(this.filterTabs, e => {
             this.resetAll();
             this.filter = e.target.dataset.filter;
@@ -47,32 +49,33 @@ export class ImageManager extends Component {
             this.loadGallery();
         });
 
+        // Search submit
         this.searchForm.addEventListener('submit', event => {
             this.resetListView();
             this.loadGallery();
+            this.cancelSearch.toggleAttribute('hidden', !this.searchInput.value);
             event.preventDefault();
         });
 
+        // Cancel search button
         onSelect(this.cancelSearch, () => {
             this.resetListView();
             this.resetSearchView();
             this.loadGallery();
         });
 
-        onChildEvent(this.listContainer, '.load-more button', 'click', async event => {
-            const wrapper = event.target.closest('.load-more');
-            showLoading(wrapper);
-            this.page += 1;
-            await this.loadGallery();
-            wrapper.remove();
-        });
+        // 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 +83,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') {
@@ -145,6 +170,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);
@@ -169,6 +202,7 @@ export class ImageManager extends Component {
 
     resetSearchView() {
         this.searchInput.value = '';
+        this.cancelSearch.toggleAttribute('hidden', true);
     }
 
     resetEditForm() {
@@ -224,4 +258,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');
+    }
+
 }
index 560dc6273e3937eeb0744c3b8ae9210eb9bb6d92..f0fc058ced7fd8377123f831ac93c631d27d4952 100644 (file)
@@ -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,35 @@ 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() {
+        const panelToShow = this.panels.find(p => !p.hasAttribute('hidden')) || this.panels[0];
+        this.show(panelToShow.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);
+    }
+
 }
index eb9f4e767a4e9cf604287c411b8cee55299b4faa..f1aa3139b8ea7aff9f01088d8a680584b483e9c1 100644 (file)
@@ -2,7 +2,7 @@
 .anim.fadeIn {
   opacity: 0;
   animation-name: fadeIn;
-  animation-duration: 180ms;
+  animation-duration: 120ms;
   animation-timing-function: ease-in-out;
   animation-fill-mode: forwards;
 }
index 5ba1286c0c35600cc97c7e82a48e7307b0829357..1521e6eaa60d2c759280c404ac1dfec78caafc59 100644 (file)
@@ -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,43 +343,99 @@ 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;
+  @include lightDark(background-color, rgba(255, 255, 255, 0.85), rgba(80, 80, 80, 0.85));
+}
+.image-manager-filter-bar-bg {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+  opacity: .15;
+  z-index: -1;
+}
+
+.image-manager-filters {
+  box-shadow: $bs-med;
+  border-radius: 4px;
+  overflow: hidden;
+  border-bottom: 0 !important;
+  @include whenDark {
+    border: 1px solid #000 !important;
+  }
+  button {
+    line-height: 0;
+    @include lightDark(background-color, #FFF, #333);
+  }
+  svg {
+    margin: 0;
+  }
+}
+
+.image-manager-list {
+  padding: 3px;
+  display: grid;
+  grid-template-columns: repeat( auto-fit, minmax(140px, 1fr) );
+  gap: 3px;
+  z-index: 3;
+  > div {
+    aspect-ratio: 1;
+  }
+}
+
 .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 {
@@ -393,7 +445,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;
@@ -408,6 +459,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;
@@ -433,16 +485,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
     }
   }
 }
-
-.image-manager-list {
-  overflow-y: scroll;
-  flex: 1;
+@include smaller-than($m) {
+  .image-manager-sidebar {
+    border-inline-start: 0;
+  }
 }
 
 .image-manager-content {
   display: flex;
   flex-direction: column;
   flex: 1;
+  overflow-y: scroll;
   .container {
     width: 100%;
   }
@@ -451,18 +504,14 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   }
 }
 
-.image-manager [role="tablist"] button[role="tab"] {
-  border-right: 1px solid #DDD;
-  @include lightDark(border-color, #DDD, #000);
+.tab-container.bordered [role="tablist"] button[role="tab"] {
+  border-inline-end: 1px solid #DDD;
+  @include lightDark(border-inline-end-color, #DDD, #000);
   &:last-child {
-    border-right: none;
+    border-inline-end: none;
   }
 }
 
-.image-manager-header {
-  z-index: 4;
-}
-
 .tab-container [role="tablist"] {
   display: flex;
   align-items: end;
@@ -473,8 +522,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));
@@ -484,11 +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));
   }
+  &:focus {
+    outline: 1px dotted var(--color-primary);
+    outline-offset: -2px;
+  }
 }
 .tab-container [role="tablist"].controls-card {
   margin-bottom: 0;
index 37f8f1bfcf720d28568eb522033a845fc27629ab..5276bb566b0c8839a58376017fa79fed23166638 100644 (file)
@@ -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;
index 541978a65e2ac14aba3d34ee1624c3a6afcca849..11889da172239b0ef346ab93d298729d86d36555 100644 (file)
@@ -298,6 +298,10 @@ body.flexbox {
   }
 }
 
+[hidden] {
+  display: none !important;
+}
+
 /**
  * Border radiuses
  */
index 33e500d6ad573ca555a5576292e8ef7f2241fcfb..ad0803e712596d4a753da39fa9401416b4907400 100644 (file)
@@ -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;
   }
index 2ed6806468767df04c72a928e70913549a7fffdc..9a8e5b36dc38747066df07d5e9d9157b10635dcc 100644 (file)
@@ -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);
index aa21e31bb6b5193cd4f6658518c4760a887da7c4..75750ef2f76373057f58d94041db194bfbda01fe 100644 (file)
@@ -1,4 +1,14 @@
-<div class="image-manager-details">
+<div component="dropzone"
+     option:dropzone:url="{{ url("/images/{$image->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">
+
+    <div refs="dropzone@status-area dropzone@drop-target"></div>
 
     <form component="ajax-form"
           option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
             <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>
-                @if(userCan('image-delete', $image))
-                    <button type="button"
-                        id="image-manager-delete"
-                        title="{{ trans('common.delete') }}"
-                        class="button icon outline">@icon('delete')</button>
-                @endif
-            </div>
-            <div class="text-right">
+        <div class="flex-container-row justify-space-between gap-m">
+            @if(userCan('image-delete', $image) || userCan('image-update', $image))
+                <div component="dropdown"
+                     class="dropdown-container">
+                    <button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
+                    <div refs="dropdown@menu" class="dropdown-menu anchor-left">
+                        @if(userCan('image-delete', $image))
+                            <button type="button"
+                                    id="image-manager-delete"
+                                    class="text-item">{{ trans('common.delete') }}</button>
+                        @endif
+                        @if(userCan('image-update', $image))
+                            <button type="button"
+                                    id="image-manager-replace"
+                                    refs="dropzone@select-button"
+                                    class="text-item">{{ trans('components.image_replace') }}</button>
+                        @endif
+                    </div>
+                </div>
+            @endif
                 <button type="submit"
                         class="button icon outline">{{ trans('common.save') }}</button>
-            </div>
         </div>
     </form>
 
     @if(!is_null($dependantPages))
+        <hr>
         @if(count($dependantPages) > 0)
             <p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
             <ul class="text-neg">
         </form>
     @endif
 
+    <div class="text-muted text-small">
+        <hr class="my-m">
+        <div title="{{ $image->created_at->format('Y-m-d H:i:s') }}">
+            @icon('star') {{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->diffForHumans()]) }}
+        </div>
+        @if($image->created_at->valueOf() !== $image->updated_at->valueOf())
+            <div title="{{ $image->updated_at->format('Y-m-d H:i:s') }}">
+                @icon('edit') {{ trans('components.image_updated', ['updateDate' => $image->updated_at->diffForHumans()]) }}
+            </div>
+        @endif
+        @if($image->createdBy)
+            <div>@icon('user') {{ trans('components.image_uploaded_by', ['userName' => $image->createdBy->name]) }}</div>
+        @endif
+        @if(($page = $image->getPage()) && userCan('view', $page))
+            <div>
+                @icon('page')
+                {!! trans('components.image_uploaded_to', [
+                    'pageLink' => '<a class="text-page" href="' . e($page->getUrl()) . '" target="_blank">' . e($page->name) . '</a>'
+                ]) !!}
+            </div>
+        @endif
+    </div>
+
 </div>
\ No newline at end of file
index ccf79fb6d3d8ca8c38db5f551ffadc885c7c16b4..7e660c747dca135c9d947596dca6d76ace6ad1d8 100644 (file)
@@ -1,23 +1,26 @@
 @foreach($images as $index => $image)
 <div>
-    <div component="event-emit-select"
+    <button component="event-emit-select"
          option:event-emit-select:name="image"
          option:event-emit-select:data="{{ json_encode($image) }}"
          class="image anim fadeIn text-link"
-         style="animation-delay: {{ $index > 26 ? '160ms' : ($index * 25) . 'ms' }};">
+         style="animation-delay: {{ min($index * 10, 260) . 'ms' }};">
         <img src="{{ $image->thumbs['gallery'] }}"
              alt="{{ $image->name }}"
+             role="none"
              width="150"
              height="150"
-             loading="lazy"
-             title="{{ $image->name }}">
+             loading="lazy">
         <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>
+            <span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => $image->created_at->format('Y-m-d')]) }}</span>
         </div>
-    </div>
+    </button>
 </div>
 @endforeach
+@if(count($images) === 0)
+    <p class="m-m text-bigger italic text-muted">{{ trans('common.no_items') }}</p>
+@endif
 @if($hasMore)
     <div class="load-more">
         <button type="button" class="button small outline">{{ trans('components.image_load_more') }}</button>
index da64d681c5283df580367b6ecb38643ec6cfa9ca..3050f5c5b3403097a2d4180cdc19792bcb12c526 100644 (file)
                     <span>@icon('upload')</span>
                     <span>{{ trans('components.image_upload') }}</span>
                 </button>
-                <button refs="popup@hide" type="button" class="popup-header-close">@icon('close')</button>
+                <button refs="popup@hide"
+                        type="button"
+                        title="{{ trans('common.close') }}"
+                        class="popup-header-close">@icon('close')</button>
             </div>
 
-            <div refs="dropzone@drop-target" class="flex-fill image-manager-body">
-
-                <div class="image-manager-content">
-                    <div role="tablist" class="image-manager-header grid third no-gap">
-                        <button refs="image-manager@filterTabs"
-                                data-filter="all"
-                                role="tab"
+            <div component="tabs"
+                 option:tabs:active-under="880"
+                 refs="dropzone@drop-target"
+                 class="flex-container-column image-manager-body">
+                <div class="tab-container">
+                    <div role="tablist" class="hide-over-m mb-none">
+                        <button id="image-manager-list-tab"
                                 aria-selected="true"
-                                type="button" class="tab-item" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }}</button>
-                        <button refs="image-manager@filterTabs"
-                                data-filter="book"
-                                role="tab"
-                                aria-selected="false"
-                                type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'svg-icon']) {{ trans('entities.book') }}</button>
-                        <button refs="image-manager@filterTabs"
-                                data-filter="page"
-                                role="tab"
-                                aria-selected="false"
-                                type="button" class="tab-item" title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'svg-icon']) {{ trans('entities.page') }}</button>
-                    </div>
-                    <div>
-                        <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>
+                                aria-controls="image-manager-list"
+                                role="tab">{{ trans('components.image_list') }}</button>
+                        <button id="image-manager-info-tab"
+                                aria-selected="true"
+                                aria-controls="image-manager-info"
+                                role="tab">{{ trans('components.image_details') }}</button>
                     </div>
-                    <div refs="image-manager@listContainer" class="image-manager-list"></div>
                 </div>
+                <div class="flex-container-row flex-fill flex">
+                    <div id="image-manager-list"
+                         tabindex="0"
+                         role="tabpanel"
+                         aria-labelledby="image-manager-list-tab"
+                         class="image-manager-content">
+                        <div class="image-manager-filter-bar flex-container-row wrap justify-space-between">
+                            <div class="primary-background image-manager-filter-bar-bg"></div>
+                            <div>
+                                <form refs="image-manager@searchForm" role="search" class="contained-search-box floating mx-m my-s">
+                                    <input refs="image-manager@searchInput"
+                                           placeholder="{{ trans('components.image_search_hint') }}"
+                                           type="search">
+                                    <button refs="image-manager@cancelSearch"
+                                            title="{{ trans('common.search_clear') }}"
+                                            type="button"
+                                            hidden="hidden"
+                                            class="cancel">@icon('close')</button>
+                                    <button type="submit"
+                                            title="{{ trans('common.search') }}">@icon('search')</button>
+                                </form>
+                            </div>
+                            <div class="tab-container bordered mx-m my-s">
+                                <div role="tablist" class="image-manager-filters flex-container-row mb-none">
+                                    <button refs="image-manager@filterTabs"
+                                            data-filter="all"
+                                            role="tab"
+                                            aria-selected="true"
+                                            type="button"
+                                            title="{{ trans('components.image_all_title') }}">@icon('images')</button>
+                                    <button refs="image-manager@filterTabs"
+                                            data-filter="book"
+                                            role="tab"
+                                            aria-selected="false"
+                                            type="button"
+                                            title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'svg-icon'])</button>
+                                    <button refs="image-manager@filterTabs"
+                                            data-filter="page"
+                                            role="tab"
+                                            aria-selected="false"
+                                            type="button"
+                                            title="{{ trans('components.image_page_title') }}">@icon('page', ['class' => 'svg-icon'])</button>
+                                </div>
+                            </div>
+                        </div>
+                        <div refs="image-manager@listContainer" class="image-manager-list"></div>
+                        <div refs="image-manager@loadMore" class="load-more" hidden>
+                            <button type="button" class="button small outline">Load More</button>
+                        </div>
+                    </div>
 
-                <div class="image-manager-sidebar flex-container-column">
+                    <div id="image-manager-info"
+                         tabindex="0"
+                         role="tabpanel"
+                         aria-labelledby="image-manager-info-tab"
+                         class="image-manager-sidebar flex-container-column">
 
-                    <div refs="image-manager@dropzoneContainer">
-                        <div refs="dropzone@status-area"></div>
-                    </div>
+                        <div refs="image-manager@dropzoneContainer">
+                            <div refs="dropzone@status-area"></div>
+                        </div>
 
-                    <div refs="image-manager@form-container-placeholder" class="p-m text-small text-muted">
-                        <p>{{ trans('components.image_intro') }}</p>
-                        <p refs="image-manager@upload-hint">{{ trans('components.image_intro_upload') }}</p>
-                    </div>
+                        <div refs="image-manager@form-container-placeholder" class="p-m text-small text-muted">
+                            <p>{{ trans('components.image_intro') }}</p>
+                            <p refs="image-manager@upload-hint">{{ trans('components.image_intro_upload') }}</p>
+                        </div>
 
-                    <div refs="image-manager@formContainer" class="inner flex">
+                        <div refs="image-manager@formContainer" class="inner flex">
+                        </div>
                     </div>
                 </div>
-
             </div>
 
             <div class="popup-footer">
index 92e0a003acdcd57ef9e8e1689cb35430fc7a67c4..468c300ba190eeafdfef7fdd36a06ea3ca6f9876 100644 (file)
@@ -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']);
@@ -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']);
 
index 17c90518ce6fcc9481ebbc10f1cbfefad00ef8e9..067173a6b7d8260a276bf2156fea10bd4bcc8d7c 100644 (file)
@@ -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);
index 97e36001c63a92a44d012b4c5a1f23e2d51d8833..f9cc419a4fb1a1420e6a4f618825f2d3af7e6820 100644 (file)
@@ -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();
@@ -493,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);
     }