diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index f9d65c48b..2e5d5f303 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -1,14 +1,9 @@ -json($imgData); } + /** + * Search through images within a particular type. + * @param $type + * @param int $page + * @param Request $request + * @return mixed + */ + public function searchByType($type, $page = 0, Request $request) + { + $this->validate($request, [ + 'term' => 'required|string' + ]); + + $searchTerm = $request->get('term'); + $imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm); + return response()->json($imgData); + } + /** * Get all images for a user. * @param int $page @@ -55,6 +68,27 @@ class ImageController extends Controller return response()->json($imgData); } + /** + * Get gallery images with a specific filter such as book or page + * @param $filter + * @param int $page + * @param Request $request + */ + public function getGalleryFiltered($filter, $page = 0, Request $request) + { + $this->validate($request, [ + 'page_id' => 'required|integer' + ]); + + $validFilters = collect(['page', 'book']); + if (!$validFilters->contains($filter)) return response('Invalid filter', 500); + + $pageId = $request->get('page_id'); + $imgData = $this->imageRepo->getGalleryFiltered($page, 24, strtolower($filter), $pageId); + + return response()->json($imgData); + } + /** * Handles image uploads for use on pages. * @param string $type diff --git a/app/Http/routes.php b/app/Http/routes.php index eca37347c..9565b7576 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -75,6 +75,8 @@ Route::group(['middleware' => 'auth'], function () { Route::post('/{type}/upload', 'ImageController@uploadByType'); Route::get('/{type}/all', 'ImageController@getAllByType'); Route::get('/{type}/all/{page}', 'ImageController@getAllByType'); + Route::get('/{type}/search/{page}', 'ImageController@searchByType'); + Route::get('/gallery/{filter}/{page}', 'ImageController@getGalleryFiltered'); Route::delete('/{imageId}', 'ImageController@destroy'); }); diff --git a/app/Repos/ImageRepo.php b/app/Repos/ImageRepo.php index 2e2624a6e..8dd4d346d 100644 --- a/app/Repos/ImageRepo.php +++ b/app/Repos/ImageRepo.php @@ -2,6 +2,7 @@ use BookStack\Image; +use BookStack\Page; use BookStack\Services\ImageService; use BookStack\Services\RestrictionService; use Setting; @@ -13,18 +14,21 @@ class ImageRepo protected $image; protected $imageService; protected $restictionService; + protected $page; /** * ImageRepo constructor. * @param Image $image * @param ImageService $imageService * @param RestrictionService $restrictionService + * @param Page $page */ - public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService) + public function __construct(Image $image, ImageService $imageService, RestrictionService $restrictionService, Page $page) { $this->image = $image; $this->imageService = $imageService; $this->restictionService = $restrictionService; + $this->page = $page; } @@ -38,6 +42,31 @@ class ImageRepo return $this->image->findOrFail($id); } + /** + * Execute a paginated query, returning in a standard format. + * Also runs the query through the restriction system. + * @param $query + * @param int $page + * @param int $pageSize + * @return array + */ + private function returnPaginated($query, $page = 0, $pageSize = 24) + { + $images = $this->restictionService->filterRelatedPages($query, 'images', 'uploaded_to'); + $images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get(); + $hasMore = count($images) > $pageSize; + + $returnImages = $images->take(24); + $returnImages->each(function ($image) { + $this->loadThumbs($image); + }); + + return [ + 'images' => $returnImages, + 'hasMore' => $hasMore + ]; + } + /** * Gets a load images paginated, filtered by image type. * @param string $type @@ -54,19 +83,46 @@ class ImageRepo $images = $images->where('created_by', '=', $userFilter); } - $images = $this->restictionService->filterRelatedPages($images, 'images', 'uploaded_to'); - $images = $images->orderBy('created_at', 'desc')->skip($pageSize * $page)->take($pageSize + 1)->get(); - $hasMore = count($images) > $pageSize; + return $this->returnPaginated($images, $page, $pageSize); + } - $returnImages = $images->take(24); - $returnImages->each(function ($image) { - $this->loadThumbs($image); - }); + /** + * Search for images by query, of a particular type. + * @param string $type + * @param int $page + * @param int $pageSize + * @param string $searchTerm + * @return array + */ + public function searchPaginatedByType($type, $page = 0, $pageSize = 24, $searchTerm) + { + $images = $this->image->where('type', '=', strtolower($type))->where('name', 'LIKE', '%' . $searchTerm . '%'); + return $this->returnPaginated($images, $page, $pageSize); + } - return [ - 'images' => $returnImages, - 'hasMore' => $hasMore - ]; + /** + * Get gallery images with a particular filter criteria such as + * being within the current book or page. + * @param int $pagination + * @param int $pageSize + * @param $filter + * @param $pageId + * @return array + */ + public function getGalleryFiltered($pagination = 0, $pageSize = 24, $filter, $pageId) + { + $images = $this->image->where('type', '=', 'gallery'); + + $page = $this->page->findOrFail($pageId); + + if ($filter === 'page') { + $images = $images->where('uploaded_to', '=', $page->id); + } elseif ($filter === 'book') { + $validPageIds = $page->book->pages->pluck('id')->toArray(); + $images = $images->whereIn('uploaded_to', $validPageIds); + } + + return $this->returnPaginated($images, $pagination, $pageSize); } /** diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index dbd2e1ae6..83e58ee4b 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -14,20 +14,40 @@ module.exports = function (ngApp, events) { $scope.imageUpdateSuccess = false; $scope.imageDeleteSuccess = false; $scope.uploadedTo = $attrs.uploadedTo; + $scope.view = 'all'; + + $scope.searching = false; + $scope.searchTerm = ''; var page = 0; var previousClickTime = 0; + var previousClickImage = 0; var dataLoaded = false; var callback = false; + var preSearchImages = []; + var preSearchHasMore = false; + /** - * Simple returns the appropriate upload url depending on the image type set. + * Used by dropzone to get the endpoint to upload to. * @returns {string} */ $scope.getUploadUrl = function () { return '/images/' + $scope.imageType + '/upload'; }; + /** + * Cancel the current search operation. + */ + function cancelSearch() { + $scope.searching = false; + $scope.searchTerm = ''; + $scope.images = preSearchImages; + $scope.hasMore = preSearchHasMore; + } + $scope.cancelSearch = cancelSearch; + + /** * Runs on image upload, Adds an image to local list of images * and shows a success message to the user. @@ -59,7 +79,7 @@ module.exports = function (ngApp, events) { var currentTime = Date.now(); var timeDiff = currentTime - previousClickTime; - if (timeDiff < dblClickTime) { + if (timeDiff < dblClickTime && image.id === previousClickImage) { // If double click callbackAndHide(image); } else { @@ -68,6 +88,7 @@ module.exports = function (ngApp, events) { $scope.dependantPages = false; } previousClickTime = currentTime; + previousClickImage = image.id; }; /** @@ -110,20 +131,69 @@ module.exports = function (ngApp, events) { $scope.showing = false; }; + var baseUrl = '/images/' + $scope.imageType + '/all/' + /** * Fetch the list image data from the server. */ function fetchData() { - var url = '/images/' + $scope.imageType + '/all/' + page; + var url = baseUrl + page + '?'; + var components = {}; + if ($scope.uploadedTo) components['page_id'] = $scope.uploadedTo; + if ($scope.searching) components['term'] = $scope.searchTerm; + + + var urlQueryString = Object.keys(components).map((key) => { + return key + '=' + encodeURIComponent(components[key]); + }).join('&'); + url += urlQueryString; + $http.get(url).then((response) => { $scope.images = $scope.images.concat(response.data.images); $scope.hasMore = response.data.hasMore; page++; }); } - $scope.fetchData = fetchData; + /** + * Start a search operation + * @param searchTerm + */ + $scope.searchImages = function() { + + if ($scope.searchTerm === '') { + cancelSearch(); + return; + } + + if (!$scope.searching) { + preSearchImages = $scope.images; + preSearchHasMore = $scope.hasMore; + } + + $scope.searching = true; + $scope.images = []; + $scope.hasMore = false; + page = 0; + baseUrl = '/images/' + $scope.imageType + '/search/'; + fetchData(); + }; + + /** + * Set the current image listing view. + * @param viewName + */ + $scope.setView = function(viewName) { + cancelSearch(); + $scope.images = []; + $scope.hasMore = false; + page = 0; + $scope.view = viewName; + baseUrl = '/images/' + $scope.imageType + '/' + viewName + '/'; + fetchData(); + } + /** * Save the details of an image. * @param event diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss index aa7c2f471..e0b1a99cb 100644 --- a/resources/assets/sass/_header.scss +++ b/resources/assets/sass/_header.scss @@ -189,12 +189,13 @@ form.search-box { } } -.setting-nav { +.nav-tabs { text-align: center; - a { + a, .tab-item { padding: $-m; display: inline-block; color: #666; + cursor: pointer; &.selected { border-bottom: 2px solid $primary; } diff --git a/resources/assets/sass/_image-manager.scss b/resources/assets/sass/_image-manager.scss index 8b18d24f3..73b3b59d6 100644 --- a/resources/assets/sass/_image-manager.scss +++ b/resources/assets/sass/_image-manager.scss @@ -120,7 +120,6 @@ .image-manager-list { overflow-y: scroll; flex: 1; - border-top: 1px solid #ddd; } .image-manager-content { @@ -128,6 +127,12 @@ flex-direction: column; height: 100%; flex: 1; + .container { + width: 100%; + } + .full-tab { + text-align: center; + } } // Dropzone diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 7c7821242..d8453b9ed 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -176,4 +176,29 @@ $btt-size: 40px; position: relative; top: -5px; } +} + +.contained-search-box { + display: flex; + input, button { + border-radius: 0; + border: 1px solid #DDD; + margin-left: -1px; + } + input { + flex: 5; + &:focus, &:active { + outline: 0; + } + } + button { + width: 60px; + } + button i { + padding: 0; + } + button.cancel.active { + background-color: $negative; + color: #EEE; + } } \ No newline at end of file diff --git a/resources/views/partials/custom-styles.blade.php b/resources/views/partials/custom-styles.blade.php index 011f06654..de324d284 100644 --- a/resources/views/partials/custom-styles.blade.php +++ b/resources/views/partials/custom-styles.blade.php @@ -12,7 +12,7 @@ .button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus { background-color: {{ Setting::get('app-color') }}; } - .setting-nav a.selected { + .nav-tabs a.selected, .nav-tabs .tab-item.selected { border-bottom-color: {{ Setting::get('app-color') }}; } p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus { diff --git a/resources/views/partials/image-manager.blade.php b/resources/views/partials/image-manager.blade.php index a394975d8..69928e119 100644 --- a/resources/views/partials/image-manager.blade.php +++ b/resources/views/partials/image-manager.blade.php @@ -3,6 +3,20 @@
+
+ +
+
+ +