Merge pull request #4265 from BookStackApp/image_manager_responsive
Enhanced Responsive Image Manager
This commit is contained in:
		
						commit
						242d23788d
					
				| 
						 | 
					@ -28,4 +28,12 @@ class ApiDocsController extends ApiController
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return response()->json($docs);
 | 
					        return response()->json($docs);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * Redirect to the API docs page.
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    public function redirect()
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					        return redirect('/api/docs');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,7 @@ class GalleryImageController extends Controller
 | 
				
			||||||
        $uploadedToFilter = $request->get('uploaded_to', null);
 | 
					        $uploadedToFilter = $request->get('uploaded_to', null);
 | 
				
			||||||
        $parentTypeFilter = $request->get('filter_type', 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', [
 | 
					        return view('pages.parts.image-manager-list', [
 | 
				
			||||||
            'images'  => $imgData['images'],
 | 
					            'images'  => $imgData['images'],
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,13 +14,10 @@ use Illuminate\Validation\ValidationException;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ImageController extends Controller
 | 
					class ImageController extends Controller
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    protected ImageRepo $imageRepo;
 | 
					    public function __construct(
 | 
				
			||||||
    protected ImageService $imageService;
 | 
					        protected ImageRepo $imageRepo,
 | 
				
			||||||
 | 
					        protected ImageService $imageService
 | 
				
			||||||
    public function __construct(ImageRepo $imageRepo, ImageService $imageService)
 | 
					    ) {
 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        $this->imageRepo = $imageRepo;
 | 
					 | 
				
			||||||
        $this->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.
 | 
					     * Get the form for editing the given image.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,6 +30,7 @@ class ImageGalleryApiController extends ApiController
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
            'update' => [
 | 
					            'update' => [
 | 
				
			||||||
                'name'  => ['string', 'max:180'],
 | 
					                '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.
 | 
					     * 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)
 | 
					    public function update(Request $request, string $id)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
| 
						 | 
					@ -99,6 +101,9 @@ class ImageGalleryApiController extends ApiController
 | 
				
			||||||
        $this->checkOwnablePermission('image-update', $image);
 | 
					        $this->checkOwnablePermission('image-update', $image);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $this->imageRepo->updateImageDetails($image, $data);
 | 
					        $this->imageRepo->updateImageDetails($image, $data);
 | 
				
			||||||
 | 
					        if (isset($data['image'])) {
 | 
				
			||||||
 | 
					            $this->imageRepo->updateImageFile($image, $data['image']);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return response()->json($this->formatForSingleResponse($image));
 | 
					        return response()->json($this->formatForSingleResponse($image));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -11,16 +11,10 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ImageRepo
 | 
					class ImageRepo
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
    protected ImageService $imageService;
 | 
					    public function __construct(
 | 
				
			||||||
    protected PermissionApplicator $permissions;
 | 
					        protected ImageService $imageService,
 | 
				
			||||||
 | 
					        protected PermissionApplicator $permissions
 | 
				
			||||||
    /**
 | 
					    ) {
 | 
				
			||||||
     * ImageRepo constructor.
 | 
					 | 
				
			||||||
     */
 | 
					 | 
				
			||||||
    public function __construct(ImageService $imageService, PermissionApplicator $permissions)
 | 
					 | 
				
			||||||
    {
 | 
					 | 
				
			||||||
        $this->imageService = $imageService;
 | 
					 | 
				
			||||||
        $this->permissions = $permissions;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
| 
						 | 
					@ -164,12 +158,30 @@ class ImageRepo
 | 
				
			||||||
    public function updateImageDetails(Image $image, $updateDetails): Image
 | 
					    public function updateImageDetails(Image $image, $updateDetails): Image
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $image->fill($updateDetails);
 | 
					        $image->fill($updateDetails);
 | 
				
			||||||
 | 
					        $image->updated_by = user()->id;
 | 
				
			||||||
        $image->save();
 | 
					        $image->save();
 | 
				
			||||||
        $this->loadThumbs($image);
 | 
					        $this->loadThumbs($image);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return $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.
 | 
					     * Destroys an Image object along with its revisions, files and thumbnails.
 | 
				
			||||||
     *
 | 
					     *
 | 
				
			||||||
| 
						 | 
					@ -202,11 +214,11 @@ class ImageRepo
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
     * Load thumbnails onto an image object.
 | 
					     * Load thumbnails onto an image object.
 | 
				
			||||||
     */
 | 
					     */
 | 
				
			||||||
    public function loadThumbs(Image $image): void
 | 
					    public function loadThumbs(Image $image, bool $forceCreate = false): void
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $image->setAttribute('thumbs', [
 | 
					        $image->setAttribute('thumbs', [
 | 
				
			||||||
            'gallery' => $this->getThumbnail($image, 150, 150, false),
 | 
					            'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
 | 
				
			||||||
            'display' => $this->getThumbnail($image, 1680, null, true),
 | 
					            'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -215,10 +227,10 @@ class ImageRepo
 | 
				
			||||||
     * If $keepRatio is true only the width will be used.
 | 
					     * If $keepRatio is true only the width will be used.
 | 
				
			||||||
     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
 | 
					     * 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 {
 | 
					        try {
 | 
				
			||||||
            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
 | 
					            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
 | 
				
			||||||
        } catch (Exception $exception) {
 | 
					        } catch (Exception $exception) {
 | 
				
			||||||
            return null;
 | 
					            return null;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -194,6 +194,14 @@ class ImageService
 | 
				
			||||||
        return $image;
 | 
					        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,
 | 
					     * Save image data for the given path in the public space, if possible,
 | 
				
			||||||
     * for the provided storage mechanism.
 | 
					     * for the provided storage mechanism.
 | 
				
			||||||
| 
						 | 
					@ -262,7 +270,7 @@ class ImageService
 | 
				
			||||||
     * @throws Exception
 | 
					     * @throws Exception
 | 
				
			||||||
     * @throws InvalidArgumentException
 | 
					     * @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
 | 
					        // Do not resize GIF images where we're not cropping
 | 
				
			||||||
        if ($keepRatio && $this->isGif($image)) {
 | 
					        if ($keepRatio && $this->isGif($image)) {
 | 
				
			||||||
| 
						 | 
					@ -277,13 +285,13 @@ class ImageService
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Return path if in cache
 | 
					        // Return path if in cache
 | 
				
			||||||
        $cachedThumbPath = $this->cache->get($thumbCacheKey);
 | 
					        $cachedThumbPath = $this->cache->get($thumbCacheKey);
 | 
				
			||||||
        if ($cachedThumbPath) {
 | 
					        if ($cachedThumbPath && !$forceCreate) {
 | 
				
			||||||
            return $this->getPublicUrl($cachedThumbPath);
 | 
					            return $this->getPublicUrl($cachedThumbPath);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // If thumbnail has already been generated, serve that and cache path
 | 
					        // If thumbnail has already been generated, serve that and cache path
 | 
				
			||||||
        $storage = $this->getStorageDisk($image->type);
 | 
					        $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);
 | 
					            $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            return $this->getPublicUrl($thumbFilePath);
 | 
					            return $this->getPublicUrl($thumbFilePath);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@ return [
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Buttons
 | 
					    // Buttons
 | 
				
			||||||
    'cancel' => 'Cancel',
 | 
					    'cancel' => 'Cancel',
 | 
				
			||||||
 | 
					    'close' => 'Close',
 | 
				
			||||||
    'confirm' => 'Confirm',
 | 
					    'confirm' => 'Confirm',
 | 
				
			||||||
    'back' => 'Back',
 | 
					    'back' => 'Back',
 | 
				
			||||||
    'save' => 'Save',
 | 
					    'save' => 'Save',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,8 @@ return [
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Image Manager
 | 
					    // Image Manager
 | 
				
			||||||
    'image_select' => 'Image Select',
 | 
					    'image_select' => 'Image Select',
 | 
				
			||||||
 | 
					    'image_list' => 'Image List',
 | 
				
			||||||
 | 
					    'image_details' => 'Image Details',
 | 
				
			||||||
    'image_upload' => 'Upload Image',
 | 
					    'image_upload' => 'Upload Image',
 | 
				
			||||||
    'image_intro' => 'Here you can select and manage images that have been previously uploaded to the system.',
 | 
					    '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.',
 | 
					    '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_page_title' => 'View images uploaded to this page',
 | 
				
			||||||
    'image_search_hint' => 'Search by image name',
 | 
					    'image_search_hint' => 'Search by image name',
 | 
				
			||||||
    'image_uploaded' => 'Uploaded :uploadedDate',
 | 
					    '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_load_more' => 'Load More',
 | 
				
			||||||
    'image_image_name' => 'Image Name',
 | 
					    'image_image_name' => 'Image Name',
 | 
				
			||||||
    'image_delete_used' => 'This image is used in the pages below.',
 | 
					    'image_delete_used' => 'This image is used in the pages below.',
 | 
				
			||||||
| 
						 | 
					@ -27,6 +32,8 @@ return [
 | 
				
			||||||
    'image_upload_success' => 'Image uploaded successfully',
 | 
					    'image_upload_success' => 'Image uploaded successfully',
 | 
				
			||||||
    'image_update_success' => 'Image details successfully updated',
 | 
					    'image_update_success' => 'Image details successfully updated',
 | 
				
			||||||
    'image_delete_success' => 'Image successfully deleted',
 | 
					    'image_delete_success' => 'Image successfully deleted',
 | 
				
			||||||
 | 
					    'image_replace' => 'Replace Image',
 | 
				
			||||||
 | 
					    'image_replace_success' => 'Image file successfully updated',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Code Editor
 | 
					    // Code Editor
 | 
				
			||||||
    'code_editor' => 'Edit Code',
 | 
					    'code_editor' => 'Edit Code',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -49,6 +49,7 @@ return [
 | 
				
			||||||
    // Drawing & Images
 | 
					    // Drawing & Images
 | 
				
			||||||
    'image_upload_error' => 'An error occurred uploading the image',
 | 
					    'image_upload_error' => 'An error occurred uploading the image',
 | 
				
			||||||
    'image_upload_type_error' => 'The image type being uploaded is invalid',
 | 
					    '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.',
 | 
					    '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
 | 
					    // Attachments
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -15,6 +15,7 @@ export class Dropzone extends Component {
 | 
				
			||||||
        this.isActive = true;
 | 
					        this.isActive = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.url = this.$opts.url;
 | 
					        this.url = this.$opts.url;
 | 
				
			||||||
 | 
					        this.method = (this.$opts.method || 'post').toUpperCase();
 | 
				
			||||||
        this.successMessage = this.$opts.successMessage;
 | 
					        this.successMessage = this.$opts.successMessage;
 | 
				
			||||||
        this.errorMessage = this.$opts.errorMessage;
 | 
					        this.errorMessage = this.$opts.errorMessage;
 | 
				
			||||||
        this.uploadLimitMb = Number(this.$opts.uploadLimit);
 | 
					        this.uploadLimitMb = Number(this.$opts.uploadLimit);
 | 
				
			||||||
| 
						 | 
					@ -167,6 +168,9 @@ export class Dropzone extends Component {
 | 
				
			||||||
    startXhrForUpload(upload) {
 | 
					    startXhrForUpload(upload) {
 | 
				
			||||||
        const formData = new FormData();
 | 
					        const formData = new FormData();
 | 
				
			||||||
        formData.append('file', upload.file, upload.file.name);
 | 
					        formData.append('file', upload.file, upload.file.name);
 | 
				
			||||||
 | 
					        if (this.method !== 'POST') {
 | 
				
			||||||
 | 
					            formData.append('_method', this.method);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        const component = this;
 | 
					        const component = this;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const req = window.$http.createXMLHttpRequest('POST', this.url, {
 | 
					        const req = window.$http.createXMLHttpRequest('POST', this.url, {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,6 +23,7 @@ export class ImageManager extends Component {
 | 
				
			||||||
        this.formContainer = this.$refs.formContainer;
 | 
					        this.formContainer = this.$refs.formContainer;
 | 
				
			||||||
        this.formContainerPlaceholder = this.$refs.formContainerPlaceholder;
 | 
					        this.formContainerPlaceholder = this.$refs.formContainerPlaceholder;
 | 
				
			||||||
        this.dropzoneContainer = this.$refs.dropzoneContainer;
 | 
					        this.dropzoneContainer = this.$refs.dropzoneContainer;
 | 
				
			||||||
 | 
					        this.loadMore = this.$refs.loadMore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // Instance data
 | 
					        // Instance data
 | 
				
			||||||
        this.type = 'gallery';
 | 
					        this.type = 'gallery';
 | 
				
			||||||
| 
						 | 
					@ -40,6 +41,7 @@ export class ImageManager extends Component {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setupListeners() {
 | 
					    setupListeners() {
 | 
				
			||||||
 | 
					        // Filter tab click
 | 
				
			||||||
        onSelect(this.filterTabs, e => {
 | 
					        onSelect(this.filterTabs, e => {
 | 
				
			||||||
            this.resetAll();
 | 
					            this.resetAll();
 | 
				
			||||||
            this.filter = e.target.dataset.filter;
 | 
					            this.filter = e.target.dataset.filter;
 | 
				
			||||||
| 
						 | 
					@ -47,32 +49,33 @@ export class ImageManager extends Component {
 | 
				
			||||||
            this.loadGallery();
 | 
					            this.loadGallery();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Search submit
 | 
				
			||||||
        this.searchForm.addEventListener('submit', event => {
 | 
					        this.searchForm.addEventListener('submit', event => {
 | 
				
			||||||
            this.resetListView();
 | 
					            this.resetListView();
 | 
				
			||||||
            this.loadGallery();
 | 
					            this.loadGallery();
 | 
				
			||||||
 | 
					            this.cancelSearch.toggleAttribute('hidden', !this.searchInput.value);
 | 
				
			||||||
            event.preventDefault();
 | 
					            event.preventDefault();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Cancel search button
 | 
				
			||||||
        onSelect(this.cancelSearch, () => {
 | 
					        onSelect(this.cancelSearch, () => {
 | 
				
			||||||
            this.resetListView();
 | 
					            this.resetListView();
 | 
				
			||||||
            this.resetSearchView();
 | 
					            this.resetSearchView();
 | 
				
			||||||
            this.loadGallery();
 | 
					            this.loadGallery();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        onChildEvent(this.listContainer, '.load-more button', 'click', async event => {
 | 
					        // Load more button click
 | 
				
			||||||
            const wrapper = event.target.closest('.load-more');
 | 
					        onChildEvent(this.container, '.load-more button', 'click', this.runLoadMore.bind(this));
 | 
				
			||||||
            showLoading(wrapper);
 | 
					 | 
				
			||||||
            this.page += 1;
 | 
					 | 
				
			||||||
            await this.loadGallery();
 | 
					 | 
				
			||||||
            wrapper.remove();
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Select image event
 | 
				
			||||||
        this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
 | 
					        this.listContainer.addEventListener('event-emit-select-image', this.onImageSelectEvent.bind(this));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Image load error handling
 | 
				
			||||||
        this.listContainer.addEventListener('error', event => {
 | 
					        this.listContainer.addEventListener('error', event => {
 | 
				
			||||||
            event.target.src = window.baseUrl('loading_error.png');
 | 
					            event.target.src = window.baseUrl('loading_error.png');
 | 
				
			||||||
        }, true);
 | 
					        }, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Footer select button click
 | 
				
			||||||
        onSelect(this.selectButton, () => {
 | 
					        onSelect(this.selectButton, () => {
 | 
				
			||||||
            if (this.callback) {
 | 
					            if (this.callback) {
 | 
				
			||||||
                this.callback(this.lastSelected);
 | 
					                this.callback(this.lastSelected);
 | 
				
			||||||
| 
						 | 
					@ -80,17 +83,39 @@ export class ImageManager extends Component {
 | 
				
			||||||
            this.hide();
 | 
					            this.hide();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Delete button click
 | 
				
			||||||
        onChildEvent(this.formContainer, '#image-manager-delete', 'click', () => {
 | 
					        onChildEvent(this.formContainer, '#image-manager-delete', 'click', () => {
 | 
				
			||||||
            if (this.lastSelected) {
 | 
					            if (this.lastSelected) {
 | 
				
			||||||
                this.loadImageEditForm(this.lastSelected.id, true);
 | 
					                this.loadImageEditForm(this.lastSelected.id, true);
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Edit form submit
 | 
				
			||||||
        this.formContainer.addEventListener('ajax-form-success', () => {
 | 
					        this.formContainer.addEventListener('ajax-form-success', () => {
 | 
				
			||||||
            this.refreshGallery();
 | 
					            this.refreshGallery();
 | 
				
			||||||
            this.resetEditForm();
 | 
					            this.resetEditForm();
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Image upload success
 | 
				
			||||||
        this.container.addEventListener('dropzone-upload-success', this.refreshGallery.bind(this));
 | 
					        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') {
 | 
					    show(callback, type = 'gallery') {
 | 
				
			||||||
| 
						 | 
					@ -145,6 +170,14 @@ export class ImageManager extends Component {
 | 
				
			||||||
    addReturnedHtmlElementsToList(html) {
 | 
					    addReturnedHtmlElementsToList(html) {
 | 
				
			||||||
        const el = document.createElement('div');
 | 
					        const el = document.createElement('div');
 | 
				
			||||||
        el.innerHTML = html;
 | 
					        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);
 | 
					        window.$components.init(el);
 | 
				
			||||||
        for (const child of [...el.children]) {
 | 
					        for (const child of [...el.children]) {
 | 
				
			||||||
            this.listContainer.appendChild(child);
 | 
					            this.listContainer.appendChild(child);
 | 
				
			||||||
| 
						 | 
					@ -169,6 +202,7 @@ export class ImageManager extends Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    resetSearchView() {
 | 
					    resetSearchView() {
 | 
				
			||||||
        this.searchInput.value = '';
 | 
					        this.searchInput.value = '';
 | 
				
			||||||
 | 
					        this.cancelSearch.toggleAttribute('hidden', true);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    resetEditForm() {
 | 
					    resetEditForm() {
 | 
				
			||||||
| 
						 | 
					@ -224,4 +258,14 @@ export class ImageManager extends Component {
 | 
				
			||||||
        window.$components.init(this.formContainer);
 | 
					        window.$components.init(this.formContainer);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    runLoadMore() {
 | 
				
			||||||
 | 
					        showLoading(this.loadMore);
 | 
				
			||||||
 | 
					        this.page += 1;
 | 
				
			||||||
 | 
					        this.loadGallery();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    canLoadMore() {
 | 
				
			||||||
 | 
					        return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,15 +21,23 @@ export class Tabs extends Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    setup() {
 | 
					    setup() {
 | 
				
			||||||
        this.container = this.$el;
 | 
					        this.container = this.$el;
 | 
				
			||||||
        this.tabs = Array.from(this.container.querySelectorAll('[role="tab"]'));
 | 
					        this.tabList = this.container.querySelector('[role="tablist"]');
 | 
				
			||||||
        this.panels = Array.from(this.container.querySelectorAll('[role="tabpanel"]'));
 | 
					        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 => {
 | 
					        this.container.addEventListener('click', event => {
 | 
				
			||||||
            const button = event.target.closest('[role="tab"]');
 | 
					            const tab = event.target.closest('[role="tab"]');
 | 
				
			||||||
            if (button) {
 | 
					            if (tab && this.tabs.includes(tab)) {
 | 
				
			||||||
                this.show(button.getAttribute('aria-controls'));
 | 
					                this.show(tab.getAttribute('aria-controls'));
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        window.addEventListener('resize', this.updateActiveState.bind(this), {
 | 
				
			||||||
 | 
					            passive: true,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        this.updateActiveState();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    show(sectionId) {
 | 
					    show(sectionId) {
 | 
				
			||||||
| 
						 | 
					@ -46,4 +54,35 @@ export class Tabs extends Component {
 | 
				
			||||||
        this.$emit('change', {showing: sectionId});
 | 
					        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);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,7 @@
 | 
				
			||||||
.anim.fadeIn {
 | 
					.anim.fadeIn {
 | 
				
			||||||
  opacity: 0;
 | 
					  opacity: 0;
 | 
				
			||||||
  animation-name: fadeIn;
 | 
					  animation-name: fadeIn;
 | 
				
			||||||
  animation-duration: 180ms;
 | 
					  animation-duration: 120ms;
 | 
				
			||||||
  animation-timing-function: ease-in-out;
 | 
					  animation-timing-function: ease-in-out;
 | 
				
			||||||
  animation-fill-mode: forwards;
 | 
					  animation-fill-mode: forwards;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -200,10 +200,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 | 
				
			||||||
  flex: 1;
 | 
					  flex: 1;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.image-manager-body {
 | 
					 | 
				
			||||||
  min-height: 70vh;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.dropzone-overlay {
 | 
					.dropzone-overlay {
 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
| 
						 | 
					@ -347,43 +343,99 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 | 
				
			||||||
  display: none;
 | 
					  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 {
 | 
					.image-manager-list .image {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
  border-radius: 0;
 | 
					  border-radius: 0;
 | 
				
			||||||
  float: left;
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  text-align: start;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
  cursor: pointer;
 | 
					  cursor: pointer;
 | 
				
			||||||
  width: math.div(100%, 6);
 | 
					  aspect-ratio: 1;
 | 
				
			||||||
  height: auto;
 | 
					 | 
				
			||||||
  @include lightDark(border-color, #ddd, #000);
 | 
					  @include lightDark(border-color, #ddd, #000);
 | 
				
			||||||
  box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
 | 
					  transition: all linear 80ms;
 | 
				
			||||||
  transition: all cubic-bezier(.4, 0, 1, 1) 160ms;
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
  &.selected {
 | 
					  &.selected {
 | 
				
			||||||
    transform: scale3d(0.92, 0.92, 0.92);
 | 
					    background-color: var(--color-primary-light);
 | 
				
			||||||
    outline: currentColor 2px solid;
 | 
					    outline: currentColor 3px solid;
 | 
				
			||||||
 | 
					    border-radius: 3px;
 | 
				
			||||||
 | 
					    transform: scale3d(0.95, 0.95, 0.95);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  img {
 | 
					  img {
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
    max-width: 100%;
 | 
					    max-width: 100%;
 | 
				
			||||||
    display: block;
 | 
					    display: block;
 | 
				
			||||||
 | 
					    object-fit: cover;
 | 
				
			||||||
 | 
					    height: auto;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  .image-meta {
 | 
					  .image-meta {
 | 
				
			||||||
 | 
					    opacity: 0;
 | 
				
			||||||
    position: absolute;
 | 
					    position: absolute;
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
    bottom: 0;
 | 
					    bottom: 0;
 | 
				
			||||||
    left: 0;
 | 
					    left: 0;
 | 
				
			||||||
    color: #EEE;
 | 
					    color: #EEE;
 | 
				
			||||||
    background-color: rgba(0, 0, 0, 0.4);
 | 
					    background-color: rgba(0, 0, 0, 0.7);
 | 
				
			||||||
    font-size: 10px;
 | 
					    font-size: 10px;
 | 
				
			||||||
    padding: 3px 4px;
 | 
					    padding: 3px 4px;
 | 
				
			||||||
 | 
					    pointer-events: none;
 | 
				
			||||||
 | 
					    transition: opacity ease-in-out 80ms;
 | 
				
			||||||
    span {
 | 
					    span {
 | 
				
			||||||
      display: block;
 | 
					      display: block;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  @include smaller-than($xl) {
 | 
					  &.selected .image-meta,
 | 
				
			||||||
    width: math.div(100%, 4);
 | 
					  &:hover .image-meta,
 | 
				
			||||||
 | 
					  &:focus .image-meta {
 | 
				
			||||||
 | 
					    opacity: 1;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  @include smaller-than($m) {
 | 
					  @include smaller-than($m) {
 | 
				
			||||||
    .image-meta {
 | 
					    .image-meta {
 | 
				
			||||||
| 
						 | 
					@ -393,7 +445,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.image-manager .load-more {
 | 
					.image-manager .load-more {
 | 
				
			||||||
  display: block;
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					  text-align: center;
 | 
				
			||||||
  padding: $-s $-m;
 | 
					  padding: $-s $-m;
 | 
				
			||||||
  clear: both;
 | 
					  clear: both;
 | 
				
			||||||
| 
						 | 
					@ -408,6 +459,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.image-manager-sidebar {
 | 
					.image-manager-sidebar {
 | 
				
			||||||
  width: 300px;
 | 
					  width: 300px;
 | 
				
			||||||
 | 
					  margin: 0 auto;
 | 
				
			||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
  overflow-x: hidden;
 | 
					  overflow-x: hidden;
 | 
				
			||||||
  border-inline-start: 1px solid #DDD;
 | 
					  border-inline-start: 1px solid #DDD;
 | 
				
			||||||
| 
						 | 
					@ -433,16 +485,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					@include smaller-than($m) {
 | 
				
			||||||
.image-manager-list {
 | 
					  .image-manager-sidebar {
 | 
				
			||||||
  overflow-y: scroll;
 | 
					    border-inline-start: 0;
 | 
				
			||||||
  flex: 1;
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.image-manager-content {
 | 
					.image-manager-content {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  flex-direction: column;
 | 
					  flex-direction: column;
 | 
				
			||||||
  flex: 1;
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  overflow-y: scroll;
 | 
				
			||||||
  .container {
 | 
					  .container {
 | 
				
			||||||
    width: 100%;
 | 
					    width: 100%;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -451,18 +504,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;
 | 
					  border-inline-end: 1px solid #DDD;
 | 
				
			||||||
  @include lightDark(border-color, #DDD, #000);
 | 
					  @include lightDark(border-inline-end-color, #DDD, #000);
 | 
				
			||||||
  &:last-child {
 | 
					  &:last-child {
 | 
				
			||||||
    border-right: none;
 | 
					    border-inline-end: none;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.image-manager-header {
 | 
					 | 
				
			||||||
  z-index: 4;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.tab-container [role="tablist"] {
 | 
					.tab-container [role="tablist"] {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  align-items: end;
 | 
					  align-items: end;
 | 
				
			||||||
| 
						 | 
					@ -473,8 +522,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 | 
				
			||||||
  margin-bottom: $-m;
 | 
					  margin-bottom: $-m;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.tab-container [role="tablist"] button[role="tab"],
 | 
					.tab-container [role="tablist"] button[role="tab"] {
 | 
				
			||||||
.image-manager [role="tablist"] button[role="tab"] {
 | 
					 | 
				
			||||||
  display: inline-block;
 | 
					  display: inline-block;
 | 
				
			||||||
  padding: $-s;
 | 
					  padding: $-s;
 | 
				
			||||||
  @include lightDark(color, rgba(0, 0, 0, .5), rgba(255, 255, 255, .5));
 | 
					  @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"] {
 | 
					  &[aria-selected="true"] {
 | 
				
			||||||
    color: var(--color-link) !important;
 | 
					    color: var(--color-link) !important;
 | 
				
			||||||
    border-bottom-color: var(--color-link) !important;
 | 
					    border-bottom-color: var(--color-link) !important;
 | 
				
			||||||
 | 
					    outline: 0 !important;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  &:hover, &:focus {
 | 
					  &:hover, &:focus {
 | 
				
			||||||
    @include lightDark(color, rgba(0, 0, 0, .8), rgba(255, 255, 255, .8));
 | 
					    @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));
 | 
					    @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 {
 | 
					.tab-container [role="tablist"].controls-card {
 | 
				
			||||||
  margin-bottom: 0;
 | 
					  margin-bottom: 0;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 {
 | 
					.outline > input {
 | 
				
			||||||
  border: 0;
 | 
					  border: 0;
 | 
				
			||||||
  border-bottom: 2px solid #DDD;
 | 
					  border-bottom: 2px solid #DDD;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -298,6 +298,10 @@ body.flexbox {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[hidden] {
 | 
				
			||||||
 | 
					  display: none !important;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Border radiuses
 | 
					 * Border radiuses
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -674,6 +674,10 @@ ul.pagination {
 | 
				
			||||||
  text-align: start !important;
 | 
					  text-align: start !important;
 | 
				
			||||||
  max-height: 500px;
 | 
					  max-height: 500px;
 | 
				
			||||||
  overflow-y: auto;
 | 
					  overflow-y: auto;
 | 
				
			||||||
 | 
					  &.anchor-left {
 | 
				
			||||||
 | 
					    inset-inline-end: auto;
 | 
				
			||||||
 | 
					    inset-inline-start: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
  &.wide {
 | 
					  &.wide {
 | 
				
			||||||
    min-width: 220px;
 | 
					    min-width: 220px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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 {
 | 
					.entity-selector {
 | 
				
			||||||
  border: 1px solid #DDD;
 | 
					  border: 1px solid #DDD;
 | 
				
			||||||
  @include lightDark(border-color, #ddd, #111);
 | 
					  @include lightDark(border-color, #ddd, #111);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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"
 | 
					    <form component="ajax-form"
 | 
				
			||||||
          option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
 | 
					          option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
 | 
				
			||||||
| 
						 | 
					@ -18,23 +28,33 @@
 | 
				
			||||||
            <label for="name">{{ trans('components.image_image_name') }}</label>
 | 
					            <label for="name">{{ trans('components.image_image_name') }}</label>
 | 
				
			||||||
            <input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
 | 
					            <input id="name" class="input-base" type="text" name="name" value="{{ $image->name }}">
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <div class="grid half">
 | 
					        <div class="flex-container-row justify-space-between gap-m">
 | 
				
			||||||
            <div>
 | 
					            @if(userCan('image-delete', $image) || userCan('image-update', $image))
 | 
				
			||||||
                @if(userCan('image-delete', $image))
 | 
					                <div component="dropdown"
 | 
				
			||||||
                    <button type="button"
 | 
					                     class="dropdown-container">
 | 
				
			||||||
                        id="image-manager-delete"
 | 
					                    <button refs="dropdown@toggle" type="button" class="button icon outline">@icon('more')</button>
 | 
				
			||||||
                        title="{{ trans('common.delete') }}"
 | 
					                    <div refs="dropdown@menu" class="dropdown-menu anchor-left">
 | 
				
			||||||
                        class="button icon outline">@icon('delete')</button>
 | 
					                        @if(userCan('image-delete', $image))
 | 
				
			||||||
                @endif
 | 
					                            <button type="button"
 | 
				
			||||||
            </div>
 | 
					                                    id="image-manager-delete"
 | 
				
			||||||
            <div class="text-right">
 | 
					                                    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"
 | 
					                <button type="submit"
 | 
				
			||||||
                        class="button icon outline">{{ trans('common.save') }}</button>
 | 
					                        class="button icon outline">{{ trans('common.save') }}</button>
 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
    </form>
 | 
					    </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @if(!is_null($dependantPages))
 | 
					    @if(!is_null($dependantPages))
 | 
				
			||||||
 | 
					        <hr>
 | 
				
			||||||
        @if(count($dependantPages) > 0)
 | 
					        @if(count($dependantPages) > 0)
 | 
				
			||||||
            <p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
 | 
					            <p class="text-neg mb-xs mt-m">{{ trans('components.image_delete_used') }}</p>
 | 
				
			||||||
            <ul class="text-neg">
 | 
					            <ul class="text-neg">
 | 
				
			||||||
| 
						 | 
					@ -60,4 +80,27 @@
 | 
				
			||||||
        </form>
 | 
					        </form>
 | 
				
			||||||
    @endif
 | 
					    @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>
 | 
					</div>
 | 
				
			||||||
| 
						 | 
					@ -1,23 +1,26 @@
 | 
				
			||||||
@foreach($images as $index => $image)
 | 
					@foreach($images as $index => $image)
 | 
				
			||||||
<div>
 | 
					<div>
 | 
				
			||||||
    <div component="event-emit-select"
 | 
					    <button component="event-emit-select"
 | 
				
			||||||
         option:event-emit-select:name="image"
 | 
					         option:event-emit-select:name="image"
 | 
				
			||||||
         option:event-emit-select:data="{{ json_encode($image) }}"
 | 
					         option:event-emit-select:data="{{ json_encode($image) }}"
 | 
				
			||||||
         class="image anim fadeIn text-link"
 | 
					         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'] }}"
 | 
					        <img src="{{ $image->thumbs['gallery'] }}"
 | 
				
			||||||
             alt="{{ $image->name }}"
 | 
					             alt="{{ $image->name }}"
 | 
				
			||||||
 | 
					             role="none"
 | 
				
			||||||
             width="150"
 | 
					             width="150"
 | 
				
			||||||
             height="150"
 | 
					             height="150"
 | 
				
			||||||
             loading="lazy"
 | 
					             loading="lazy">
 | 
				
			||||||
             title="{{ $image->name }}">
 | 
					 | 
				
			||||||
        <div class="image-meta">
 | 
					        <div class="image-meta">
 | 
				
			||||||
            <span class="name">{{ $image->name }}</span>
 | 
					            <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>
 | 
				
			||||||
    </div>
 | 
					    </button>
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
@endforeach
 | 
					@endforeach
 | 
				
			||||||
 | 
					@if(count($images) === 0)
 | 
				
			||||||
 | 
					    <p class="m-m text-bigger italic text-muted">{{ trans('common.no_items') }}</p>
 | 
				
			||||||
 | 
					@endif
 | 
				
			||||||
@if($hasMore)
 | 
					@if($hasMore)
 | 
				
			||||||
    <div class="load-more">
 | 
					    <div class="load-more">
 | 
				
			||||||
        <button type="button" class="button small outline">{{ trans('components.image_load_more') }}</button>
 | 
					        <button type="button" class="button small outline">{{ trans('components.image_load_more') }}</button>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,60 +21,98 @@
 | 
				
			||||||
                    <span>@icon('upload')</span>
 | 
					                    <span>@icon('upload')</span>
 | 
				
			||||||
                    <span>{{ trans('components.image_upload') }}</span>
 | 
					                    <span>{{ trans('components.image_upload') }}</span>
 | 
				
			||||||
                </button>
 | 
					                </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>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div refs="dropzone@drop-target" class="flex-fill image-manager-body">
 | 
					            <div component="tabs"
 | 
				
			||||||
 | 
					                 option:tabs:active-under="880"
 | 
				
			||||||
                <div class="image-manager-content">
 | 
					                 refs="dropzone@drop-target"
 | 
				
			||||||
                    <div role="tablist" class="image-manager-header grid third no-gap">
 | 
					                 class="flex-container-column image-manager-body">
 | 
				
			||||||
                        <button refs="image-manager@filterTabs"
 | 
					                <div class="tab-container">
 | 
				
			||||||
                                data-filter="all"
 | 
					                    <div role="tablist" class="hide-over-m mb-none">
 | 
				
			||||||
                                role="tab"
 | 
					                        <button id="image-manager-list-tab"
 | 
				
			||||||
                                aria-selected="true"
 | 
					                                aria-selected="true"
 | 
				
			||||||
                                type="button" class="tab-item" title="{{ trans('components.image_all_title') }}">@icon('images') {{ trans('components.image_all') }}</button>
 | 
					                                aria-controls="image-manager-list"
 | 
				
			||||||
                        <button refs="image-manager@filterTabs"
 | 
					                                role="tab">{{ trans('components.image_list') }}</button>
 | 
				
			||||||
                                data-filter="book"
 | 
					                        <button id="image-manager-info-tab"
 | 
				
			||||||
                                role="tab"
 | 
					                                aria-selected="true"
 | 
				
			||||||
                                aria-selected="false"
 | 
					                                aria-controls="image-manager-info"
 | 
				
			||||||
                                type="button" class="tab-item" title="{{ trans('components.image_book_title') }}">@icon('book', ['class' => 'svg-icon']) {{ trans('entities.book') }}</button>
 | 
					                                role="tab">{{ trans('components.image_details') }}</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>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                    <div refs="image-manager@listContainer" class="image-manager-list"></div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                <div class="image-manager-sidebar flex-container-column">
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    <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@formContainer" class="inner flex">
 | 
					 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                </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 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@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>
 | 
				
			||||||
 | 
					                    </div>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="popup-footer">
 | 
					            <div class="popup-footer">
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -29,7 +29,7 @@ Route::middleware('auth')->group(function () {
 | 
				
			||||||
        ->where('path', '.*$');
 | 
					        ->where('path', '.*$');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // API docs routes
 | 
					    // API docs routes
 | 
				
			||||||
    Route::redirect('/api', '/api/docs');
 | 
					    Route::get('/api', [ApiDocsController::class, 'redirect']);
 | 
				
			||||||
    Route::get('/api/docs', [ApiDocsController::class, 'display']);
 | 
					    Route::get('/api/docs', [ApiDocsController::class, 'display']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Route::get('/pages/recently-updated', [EntityControllers\PageController::class, 'showRecentlyUpdated']);
 | 
					    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::get('/images/drawio/base64/{id}', [UploadControllers\DrawioImageController::class, 'getAsBase64']);
 | 
				
			||||||
    Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']);
 | 
					    Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']);
 | 
				
			||||||
    Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']);
 | 
					    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::put('/images/{id}', [UploadControllers\ImageController::class, 'update']);
 | 
				
			||||||
    Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);
 | 
					    Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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();
 | 
					        $user = $this->users->editor();
 | 
				
			||||||
        $this->actingAsForApi($user);
 | 
					        $this->actingAsForApi($user);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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()
 | 
					    public function test_gallery_get_list_format()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $this->asEditor();
 | 
					        $this->asEditor();
 | 
				
			||||||
| 
						 | 
					@ -493,15 +532,15 @@ class ImageTest extends TestCase
 | 
				
			||||||
        $image = Image::first();
 | 
					        $image = Image::first();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $resp = $this->get("/images/edit/{$image->id}");
 | 
					        $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}");
 | 
					        $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']);
 | 
					        $this->permissions->grantUserRolePermissions($viewer, ['image-delete-all']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}");
 | 
					        $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);
 | 
					        $this->files->deleteAtRelativePath($relPath);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue