From cbff2c6035aa6204092b7b93061b1c941fb95eb8 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 3 Apr 2016 14:59:54 +0100
Subject: [PATCH] Added uploaded to book/page filters & search in image manager

Also refactored tab styles which affected the settings area.

Closes #41
---
 app/Http/Controllers/ImageController.php      | 46 +++++++++--
 app/Http/routes.php                           |  2 +
 app/Repos/ImageRepo.php                       | 80 ++++++++++++++++---
 resources/assets/js/controllers.js            | 78 +++++++++++++++++-
 resources/assets/sass/_header.scss            |  5 +-
 resources/assets/sass/_image-manager.scss     |  7 +-
 resources/assets/sass/styles.scss             | 25 ++++++
 .../views/partials/custom-styles.blade.php    |  2 +-
 .../views/partials/image-manager.blade.php    | 14 ++++
 resources/views/settings/navbar.blade.php     |  2 +-
 10 files changed, 234 insertions(+), 27 deletions(-)

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 @@
-<?php
-
-namespace BookStack\Http\Controllers;
+<?php namespace BookStack\Http\Controllers;
 
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Repos\ImageRepo;
 use Illuminate\Filesystem\Filesystem as File;
 use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Auth;
-use Intervention\Image\Facades\Image as ImageTool;
-use Illuminate\Support\Facades\DB;
 use BookStack\Image;
 use BookStack\Repos\PageRepo;
 
@@ -44,6 +39,24 @@ class ImageController extends Controller
         return response()->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 @@
         <div class="image-manager-body" ng-click="$event.stopPropagation()">
 
             <div class="image-manager-content">
+                <div ng-if="imageType === 'gallery'" class="container">
+                    <div class="image-manager-header row faded-small nav-tabs">
+                        <div class="col-xs-4 tab-item" title="View all images" ng-class="{selected: (view=='all')}" ng-click="setView('all')"><i class="zmdi zmdi-collection-image"></i> All</div>
+                        <div class="col-xs-4 tab-item" title="View images uploaded to this book" ng-class="{selected: (view=='book')}" ng-click="setView('book')"><i class="zmdi zmdi-book text-book"></i> Book</div>
+                        <div class="col-xs-4 tab-item" title="View images uploaded to this page" ng-class="{selected: (view=='page')}" ng-click="setView('page')"><i class="zmdi zmdi-file-text text-page"></i> Page</div>
+                    </div>
+                </div>
+                <div ng-show="view === 'all'" >
+                    <form ng-submit="searchImages()" class="contained-search-box">
+                        <input type="text" placeholder="Search by image name" ng-model="searchTerm">
+                        <button ng-class="{active: searching}" title="Clear Search" type="button" ng-click="cancelSearch()" class="text-button cancel"><i class="zmdi zmdi-close-circle-o"></i></button>
+                        <button title="Search" class="text-button" type="submit"><i class="zmdi zmdi-search"></i></button>
+                    </form>
+                </div>
                 <div class="image-manager-list">
                     <div ng-repeat="image in images">
                         <div class="image anim fadeIn" ng-style="{animationDelay: ($index > 26) ? '160ms' : ($index * 25) + 'ms'}"
diff --git a/resources/views/settings/navbar.blade.php b/resources/views/settings/navbar.blade.php
index 7c3186889..4b084a3bf 100644
--- a/resources/views/settings/navbar.blade.php
+++ b/resources/views/settings/navbar.blade.php
@@ -2,7 +2,7 @@
 <div class="faded-small toolbar">
     <div class="container">
         <div class="row">
-            <div class="col-md-12 setting-nav">
+            <div class="col-md-12 setting-nav nav-tabs">
                 <a href="/settings" @if($selected == 'settings') class="selected text-button" @endif><i class="zmdi zmdi-settings"></i>Settings</a>
                 <a href="/settings/users" @if($selected == 'users') class="selected text-button" @endif><i class="zmdi zmdi-accounts"></i>Users</a>
                 <a href="/settings/roles" @if($selected == 'roles') class="selected text-button" @endif><i class="zmdi zmdi-lock-open"></i>Roles</a>