]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'master' into release
authorDan Brown <redacted>
Sun, 3 Jul 2016 09:52:04 +0000 (10:52 +0100)
committerDan Brown <redacted>
Sun, 3 Jul 2016 09:52:04 +0000 (10:52 +0100)
57 files changed:
.travis.yml
LICENSE [new file with mode: 0644]
app/Activity.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/ImageController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/SearchController.php
app/Http/Controllers/TagController.php
app/Http/routes.php
app/Providers/AppServiceProvider.php
app/Repos/BookRepo.php
app/Repos/ChapterRepo.php
app/Repos/PageRepo.php
app/Repos/TagRepo.php
app/Services/ActivityService.php
app/Services/PermissionService.php
app/Services/ViewService.php
app/helpers.php
config/setting-defaults.php
database/migrations/2015_07_12_114933_create_books_table.php
database/migrations/2015_07_12_190027_create_pages_table.php
database/migrations/2015_07_27_172342_create_chapters_table.php
readme.md
resources/assets/js/controllers.js
resources/assets/js/directives.js
resources/assets/js/global.js
resources/assets/js/pages/page-form.js
resources/assets/js/pages/page-show.js
resources/assets/sass/_blocks.scss
resources/assets/sass/_forms.scss
resources/assets/sass/_lists.scss
resources/assets/sass/_pages.scss
resources/assets/sass/_text.scss
resources/assets/sass/_variables.scss
resources/assets/sass/styles.scss
resources/lang/en/activities.php
resources/views/books/list-item.blade.php
resources/views/chapters/list-item.blade.php
resources/views/chapters/move.blade.php [new file with mode: 0644]
resources/views/chapters/show.blade.php
resources/views/home.blade.php
resources/views/pages/form-toolbox.blade.php
resources/views/pages/form.blade.php
resources/views/pages/list-item.blade.php
resources/views/pages/move.blade.php [new file with mode: 0644]
resources/views/pages/show.blade.php
resources/views/partials/activity-list.blade.php
resources/views/partials/custom-styles.blade.php
resources/views/partials/entity-list.blade.php
resources/views/partials/entity-selector.blade.php [new file with mode: 0644]
resources/views/partials/notifications.blade.php
resources/views/search/entity-ajax-list.blade.php [new file with mode: 0644]
tests/Entity/EntitySearchTest.php
tests/Entity/SortTest.php
tests/ImageTest.php [new file with mode: 0644]
tests/TestCase.php
tests/test-image.jpg [new file with mode: 0644]

index ff387bd3dcbe650019e158e17e3e5fc4fdd96748..83e9e10f5edff887590f9f3d7cbe8217252a4fc8 100644 (file)
@@ -1,3 +1,5 @@
+dist: trusty
+sudo: required
 language: php
 php:
   - 7.0
@@ -5,15 +7,21 @@ php:
 cache:
   directories:
     - vendor
+    - node_modules
+    - $HOME/.composer/cache
 
 addons:
-  mariadb: '10.0'
+  apt:
+    packages:
+    - mysql-server-5.6
+    - mysql-client-core-5.6
+    - mysql-client-5.6
 
 before_install:
   - npm install -g npm@latest
 
 before_script:
-  - mysql -e 'create database `bookstack-test`;'
+  - mysql -u root -e 'create database `bookstack-test`;'
   - composer config -g github-oauth.github.com $GITHUB_ACCESS_TOKEN
   - phpenv config-rm xdebug.ini
   - composer self-update
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..281814b
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Dan Brown
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
index d43419c17f495bb92c0f0b220a0f9c3fee8ceced..af386700afe2ab3d7081d12018bff8acca5c3bc0 100644 (file)
@@ -44,7 +44,7 @@ class Activity extends Model
      * @return bool
      */
     public function isSimilarTo($activityB) {
-        return [$this->key, $this->entitiy_type, $this->entitiy_id] === [$activityB->key, $activityB->entitiy_type, $activityB->entitiy_id];
+        return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
     }
 
 }
index 69e9488b908d7fe887b762e71adae1e13b8a59a4..3c9050bf6b4b705aeb371a09b8273ed9ea581245 100644 (file)
@@ -59,7 +59,7 @@ class ChapterController extends Controller
 
         $input = $request->all();
         $input['priority'] = $this->bookRepo->getNewPriority($book);
-        $chapter = $this->chapterRepo->createFromInput($request->all(), $book);
+        $chapter = $this->chapterRepo->createFromInput($input, $book);
         Activity::add($chapter, 'chapter_create', $book->id);
         return redirect($chapter->getUrl());
     }
@@ -154,6 +154,63 @@ class ChapterController extends Controller
         return redirect($book->getUrl());
     }
 
+    /**
+     * Show the page for moving a chapter.
+     * @param $bookSlug
+     * @param $chapterSlug
+     * @return mixed
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function showMove($bookSlug, $chapterSlug) {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
+        $this->checkOwnablePermission('chapter-update', $chapter);
+        return view('chapters/move', [
+            'chapter' => $chapter,
+            'book' => $book
+        ]);
+    }
+
+    /**
+     * Perform the move action for a chapter.
+     * @param $bookSlug
+     * @param $chapterSlug
+     * @param Request $request
+     * @return mixed
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function move($bookSlug, $chapterSlug, Request $request) {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $chapter = $this->chapterRepo->getBySlug($chapterSlug, $book->id);
+        $this->checkOwnablePermission('chapter-update', $chapter);
+
+        $entitySelection = $request->get('entity_selection', null);
+        if ($entitySelection === null || $entitySelection === '') {
+            return redirect($chapter->getUrl());
+        }
+
+        $stringExploded = explode(':', $entitySelection);
+        $entityType = $stringExploded[0];
+        $entityId = intval($stringExploded[1]);
+
+        $parent = false;
+
+        if ($entityType == 'book') {
+            $parent = $this->bookRepo->getById($entityId);
+        }
+
+        if ($parent === false || $parent === null) {
+            session()->flash('The selected Book was not found');
+            return redirect()->back();
+        }
+
+        $this->chapterRepo->changeBook($parent->id, $chapter);
+        Activity::add($chapter, 'chapter_move', $chapter->book->id);
+        session()->flash('success', sprintf('Chapter moved to "%s"', $parent->name));
+
+        return redirect($chapter->getUrl());
+    }
+
     /**
      * Show the Restrictions view.
      * @param $bookSlug
index 2e5d5f3034180dd3b2d6f40483a664ff677f6f6c..621c23e85d576e1cf4a777c80eb07c1dc5b55ee6 100644 (file)
@@ -51,9 +51,9 @@ class ImageController extends Controller
         $this->validate($request, [
             'term' => 'required|string'
         ]);
-        
+
         $searchTerm = $request->get('term');
-        $imgData = $this->imageRepo->searchPaginatedByType($type, $page,24, $searchTerm);
+        $imgData = $this->imageRepo->searchPaginatedByType($type, $page, 24, $searchTerm);
         return response()->json($imgData);
     }
 
@@ -99,7 +99,7 @@ class ImageController extends Controller
     {
         $this->checkPermission('image-create-all');
         $this->validate($request, [
-            'file' => 'image|mimes:jpeg,gif,png'
+            'file' => 'is_image'
         ]);
 
         $imageUpload = $request->file('file');
index da927374332c9ccecfc600a3355f9d5f16d6f47a..f35834e6221ea73332b7e88b9187c73b899fb8a5 100644 (file)
@@ -92,7 +92,7 @@ class PageController extends Controller
 
         $draftPage = $this->pageRepo->getById($pageId, true);
 
-        $chapterId = $draftPage->chapter_id;
+        $chapterId = intval($draftPage->chapter_id);
         $parent = $chapterId !== 0 ? $this->chapterRepo->getById($chapterId) : $book;
         $this->checkOwnablePermission('page-create', $parent);
 
@@ -221,8 +221,8 @@ class PageController extends Controller
         $updateTime = $draft->updated_at->timestamp;
         $utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset;
         return response()->json([
-            'status' => 'success',
-            'message' => 'Draft saved at ',
+            'status'    => 'success',
+            'message'   => 'Draft saved at ',
             'timestamp' => $utcUpdateTimestamp
         ]);
     }
@@ -450,6 +450,67 @@ class PageController extends Controller
         ]);
     }
 
+    /**
+     * Show the view to choose a new parent to move a page into.
+     * @param $bookSlug
+     * @param $pageSlug
+     * @return mixed
+     * @throws NotFoundException
+     */
+    public function showMove($bookSlug, $pageSlug)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
+        $this->checkOwnablePermission('page-update', $page);
+        return view('pages/move', [
+            'book' => $book,
+            'page' => $page
+        ]);
+    }
+
+    /**
+     * Does the action of moving the location of a page
+     * @param $bookSlug
+     * @param $pageSlug
+     * @param Request $request
+     * @return mixed
+     * @throws NotFoundException
+     */
+    public function move($bookSlug, $pageSlug, Request $request)
+    {
+        $book = $this->bookRepo->getBySlug($bookSlug);
+        $page = $this->pageRepo->getBySlug($pageSlug, $book->id);
+        $this->checkOwnablePermission('page-update', $page);
+
+        $entitySelection = $request->get('entity_selection', null);
+        if ($entitySelection === null || $entitySelection === '') {
+            return redirect($page->getUrl());
+        }
+
+        $stringExploded = explode(':', $entitySelection);
+        $entityType = $stringExploded[0];
+        $entityId = intval($stringExploded[1]);
+
+        $parent = false;
+
+        if ($entityType == 'chapter') {
+            $parent = $this->chapterRepo->getById($entityId);
+        } else if ($entityType == 'book') {
+            $parent = $this->bookRepo->getById($entityId);
+        }
+
+        if ($parent === false || $parent === null) {
+            session()->flash('The selected Book or Chapter was not found');
+            return redirect()->back();
+        }
+
+        $this->pageRepo->changePageParent($page, $parent);
+        Activity::add($page, 'page_move', $page->book->id);
+        session()->flash('success', sprintf('Page moved to "%s"', $parent->name));
+
+        return redirect($page->getUrl());
+    }
+
     /**
      * Set the permissions for this page.
      * @param $bookSlug
index e198dc767913b9ac2f3ae5ffb7b8bdae00b5056f..58ad737c4b431afa7cc5f982d4ec5fc9ffc6211c 100644 (file)
@@ -2,10 +2,10 @@
 
 namespace BookStack\Http\Controllers;
 
+use BookStack\Services\ViewService;
 use Illuminate\Http\Request;
 
 use BookStack\Http\Requests;
-use BookStack\Http\Controllers\Controller;
 use BookStack\Repos\BookRepo;
 use BookStack\Repos\ChapterRepo;
 use BookStack\Repos\PageRepo;
@@ -15,18 +15,21 @@ class SearchController extends Controller
     protected $pageRepo;
     protected $bookRepo;
     protected $chapterRepo;
+    protected $viewService;
 
     /**
      * SearchController constructor.
-     * @param $pageRepo
-     * @param $bookRepo
-     * @param $chapterRepo
+     * @param PageRepo $pageRepo
+     * @param BookRepo $bookRepo
+     * @param ChapterRepo $chapterRepo
+     * @param ViewService $viewService
      */
-    public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo)
+    public function __construct(PageRepo $pageRepo, BookRepo $bookRepo, ChapterRepo $chapterRepo, ViewService $viewService)
     {
         $this->pageRepo = $pageRepo;
         $this->bookRepo = $bookRepo;
         $this->chapterRepo = $chapterRepo;
+        $this->viewService = $viewService;
         parent::__construct();
     }
 
@@ -48,9 +51,9 @@ class SearchController extends Controller
         $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 10, $paginationAppends);
         $this->setPageTitle('Search For ' . $searchTerm);
         return view('search/all', [
-            'pages' => $pages,
-            'books' => $books,
-            'chapters' => $chapters,
+            'pages'      => $pages,
+            'books'      => $books,
+            'chapters'   => $chapters,
             'searchTerm' => $searchTerm
         ]);
     }
@@ -69,8 +72,8 @@ class SearchController extends Controller
         $pages = $this->pageRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
         $this->setPageTitle('Page Search For ' . $searchTerm);
         return view('search/entity-search-list', [
-            'entities' => $pages,
-            'title' => 'Page Search Results',
+            'entities'   => $pages,
+            'title'      => 'Page Search Results',
             'searchTerm' => $searchTerm
         ]);
     }
@@ -89,8 +92,8 @@ class SearchController extends Controller
         $chapters = $this->chapterRepo->getBySearch($searchTerm, [], 20, $paginationAppends);
         $this->setPageTitle('Chapter Search For ' . $searchTerm);
         return view('search/entity-search-list', [
-            'entities' => $chapters,
-            'title' => 'Chapter Search Results',
+            'entities'   => $chapters,
+            'title'      => 'Chapter Search Results',
             'searchTerm' => $searchTerm
         ]);
     }
@@ -109,8 +112,8 @@ class SearchController extends Controller
         $books = $this->bookRepo->getBySearch($searchTerm, 20, $paginationAppends);
         $this->setPageTitle('Book Search For ' . $searchTerm);
         return view('search/entity-search-list', [
-            'entities' => $books,
-            'title' => 'Book Search Results',
+            'entities'   => $books,
+            'title'      => 'Book Search Results',
             'searchTerm' => $searchTerm
         ]);
     }
@@ -134,4 +137,35 @@ class SearchController extends Controller
         return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]);
     }
 
+
+    /**
+     * Search for a list of entities and return a partial HTML response of matching entities.
+     * Returns the most popular entities if no search is provided.
+     * @param Request $request
+     * @return mixed
+     */
+    public function searchEntitiesAjax(Request $request)
+    {
+        $entities = collect();
+        $entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']);
+        $searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false;
+
+        // Search for entities otherwise show most popular
+        if ($searchTerm !== false) {
+            if ($entityTypes->contains('page')) $entities = $entities->merge($this->pageRepo->getBySearch($searchTerm)->items());
+            if ($entityTypes->contains('chapter')) $entities = $entities->merge($this->chapterRepo->getBySearch($searchTerm)->items());
+            if ($entityTypes->contains('book')) $entities = $entities->merge($this->bookRepo->getBySearch($searchTerm)->items());
+            $entities = $entities->sortByDesc('title_relevance');
+        } else {
+            $entityNames = $entityTypes->map(function ($type) {
+                return 'BookStack\\' . ucfirst($type);
+            })->toArray();
+            $entities = $this->viewService->getPopular(20, 0, $entityNames);
+        }
+
+        return view('search/entity-ajax-list', ['entities' => $entities]);
+    }
+
 }
+
+
index 1823b0dc8b1e7ee47ddd8188fd0430942231fe5d..c8a35654108ca6bde5d3429515711d702342b8e2 100644 (file)
@@ -55,7 +55,7 @@ class TagController extends Controller
      */
     public function getNameSuggestions(Request $request)
     {
-        $searchTerm = $request->get('search');
+        $searchTerm = $request->has('search') ? $request->get('search') : false;
         $suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
         return response()->json($suggestions);
     }
@@ -66,8 +66,9 @@ class TagController extends Controller
      */
     public function getValueSuggestions(Request $request)
     {
-        $searchTerm = $request->get('search');
-        $suggestions = $this->tagRepo->getValueSuggestions($searchTerm);
+        $searchTerm = $request->has('search') ? $request->get('search') : false;
+        $tagName = $request->has('name') ? $request->get('name') : false;
+        $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
         return response()->json($suggestions);
     }
 
index 9f226efd725d47086aea4b7aae98caef7a75a9fd..eb35f2a119846bd87836b3ff4b7ff156f242c89a 100644 (file)
@@ -34,6 +34,8 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/{bookSlug}/page/{pageSlug}/export/html', 'PageController@exportHtml');
         Route::get('/{bookSlug}/page/{pageSlug}/export/plaintext', 'PageController@exportPlainText');
         Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit');
+        Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove');
+        Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move');
         Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete');
         Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft');
         Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict');
@@ -53,6 +55,8 @@ Route::group(['middleware' => 'auth'], function () {
         Route::post('/{bookSlug}/chapter/create', 'ChapterController@store');
         Route::get('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@show');
         Route::put('/{bookSlug}/chapter/{chapterSlug}', 'ChapterController@update');
+        Route::get('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@showMove');
+        Route::put('/{bookSlug}/chapter/{chapterSlug}/move', 'ChapterController@move');
         Route::get('/{bookSlug}/chapter/{chapterSlug}/edit', 'ChapterController@edit');
         Route::get('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@showRestrict');
         Route::put('/{bookSlug}/chapter/{chapterSlug}/permissions', 'ChapterController@restrict');
@@ -93,6 +97,8 @@ Route::group(['middleware' => 'auth'], function () {
         Route::post('/update/{entityType}/{entityId}', 'TagController@updateForEntity');
     });
 
+    Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
+
     // Links
     Route::get('/link/{id}', 'PageController@redirectFromLink');
 
index 8bcbcbdad2c800cd75ea263a2a6487e0af2a8ee9..f214c9141aa2b5835de735b8baf8e6f01b0ff6b5 100644 (file)
@@ -15,7 +15,12 @@ class AppServiceProvider extends ServiceProvider
      */
     public function boot()
     {
-        //
+        // Custom validation methods
+        \Validator::extend('is_image', function($attribute, $value, $parameters, $validator) {
+            $imageMimes = ['image/png', 'image/bmp', 'image/gif', 'image/jpeg', 'image/jpg', 'image/tiff', 'image/webp'];
+            return in_array($value->getMimeType(), $imageMimes);
+        });
+
     }
 
     /**
index b0530b4f5ebe99e9fd017704293b7898f2c8a6e1..a11ed2763c9ce51ad7efc23d1678e1f06eeb9af1 100644 (file)
@@ -251,7 +251,10 @@ class BookRepo extends EntityRepo
         }]);
         $chapterQuery = $this->permissionService->enforceChapterRestrictions($chapterQuery, 'view');
         $chapters = $chapterQuery->get();
-        $children = $pages->merge($chapters);
+        $children = $pages->values();
+        foreach ($chapters as $chapter) {
+            $children->push($chapter);
+        }
         $bookSlug = $book->slug;
 
         $children->each(function ($child) use ($bookSlug) {
index 048e0a63bd24ef047a59c810570b446f83b6d93c..3c518bde9de3f01a0c6a056ffdcc50cb5f27c772 100644 (file)
@@ -9,6 +9,18 @@ use BookStack\Chapter;
 
 class ChapterRepo extends EntityRepo
 {
+    protected $pageRepo;
+
+    /**
+     * ChapterRepo constructor.
+     * @param $pageRepo
+     */
+    public function __construct(PageRepo $pageRepo)
+    {
+        $this->pageRepo = $pageRepo;
+        parent::__construct();
+    }
+
     /**
      * Base query for getting chapters, Takes permissions into account.
      * @return mixed
@@ -189,12 +201,21 @@ class ChapterRepo extends EntityRepo
     public function changeBook($bookId, Chapter $chapter)
     {
         $chapter->book_id = $bookId;
+        // Update related activity
         foreach ($chapter->activity as $activity) {
             $activity->book_id = $bookId;
             $activity->save();
         }
         $chapter->slug = $this->findSuitableSlug($chapter->name, $bookId, $chapter->id);
         $chapter->save();
+        // Update all child pages
+        foreach ($chapter->pages as $page) {
+            $this->pageRepo->changeBook($bookId, $page);
+        }
+        // Update permissions
+        $chapter->load('book');
+        $this->permissionService->buildJointPermissionsForEntity($chapter->book);
+
         return $chapter;
     }
 
index 504c3fa3b6f549e0facbaa991e863e80c3a1bcdb..de050e1c7cb8ad245157f81316c6305c2fdff87a 100644 (file)
@@ -3,6 +3,7 @@
 use Activity;
 use BookStack\Book;
 use BookStack\Chapter;
+use BookStack\Entity;
 use BookStack\Exceptions\NotFoundException;
 use Carbon\Carbon;
 use DOMDocument;
@@ -572,6 +573,22 @@ class PageRepo extends EntityRepo
         return $page;
     }
 
+
+    /**
+     * Change the page's parent to the given entity.
+     * @param Page $page
+     * @param Entity $parent
+     */
+    public function changePageParent(Page $page, Entity $parent)
+    {
+        $book = $parent->isA('book') ? $parent : $parent->book;
+        $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
+        $page->save();
+        $page = $this->changeBook($book->id, $page);
+        $page->load('book');
+        $this->permissionService->buildJointPermissionsForEntity($book);
+    }
+
     /**
      * Gets a suitable slug for the resource
      * @param            $name
index 7d51d87f743db6d7a5ba432639ac0d12a963a26f..6d0857f8b234ead8a7b3cb0fbf4f34871f5ca4b6 100644 (file)
@@ -58,29 +58,48 @@ class TagRepo
 
     /**
      * Get tag name suggestions from scanning existing tag names.
+     * If no search term is given the 50 most popular tag names are provided.
      * @param $searchTerm
      * @return array
      */
-    public function getNameSuggestions($searchTerm)
+    public function getNameSuggestions($searchTerm = false)
     {
-        if ($searchTerm === '') return [];
-        $query = $this->tag->where('name', 'LIKE', $searchTerm . '%')->groupBy('name')->orderBy('name', 'desc');
+        $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
+
+        if ($searchTerm) {
+            $query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
+        } else {
+            $query = $query->orderBy('count', 'desc')->take(50);
+        }
+
         $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
         return $query->get(['name'])->pluck('name');
     }
 
     /**
      * Get tag value suggestions from scanning existing tag values.
+     * If no search is given the 50 most popular values are provided.
+     * Passing a tagName will only find values for a tags with a particular name.
      * @param $searchTerm
+     * @param $tagName
      * @return array
      */
-    public function getValueSuggestions($searchTerm)
+    public function getValueSuggestions($searchTerm = false, $tagName = false)
     {
-        if ($searchTerm === '') return [];
-        $query = $this->tag->where('value', 'LIKE', $searchTerm . '%')->groupBy('value')->orderBy('value', 'desc');
+        $query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
+
+        if ($searchTerm) {
+            $query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
+        } else {
+            $query = $query->orderBy('count', 'desc')->take(50);
+        }
+
+        if ($tagName !== false) $query = $query->where('name', '=', $tagName);
+
         $query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
         return $query->get(['value'])->pluck('value');
     }
+
     /**
      * Save an array of tags to an entity
      * @param Entity $entity
index 90a3a6d82359e8e1bf7498267970b94aae83bb0a..f6fea33a191ee2d1e02b7e63d394653d8b53322e 100644 (file)
@@ -90,7 +90,7 @@ class ActivityService
     {
         $activityList = $this->permissionService
             ->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
-            ->orderBy('created_at', 'desc')->skip($count * $page)->take($count)->get();
+            ->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
 
         return $this->filterSimilar($activityList);
     }
index 218cb30a578f7483bdde8ea61cf29fba7d959dd7..0fffe60f29d523153516fc33b12431af8f9a5660 100644 (file)
@@ -4,6 +4,7 @@ use BookStack\Book;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\JointPermission;
+use BookStack\Ownable;
 use BookStack\Page;
 use BookStack\Role;
 use BookStack\User;
@@ -307,16 +308,16 @@ class PermissionService
 
     /**
      * Checks if an entity has a restriction set upon it.
-     * @param Entity $entity
+     * @param Ownable $ownable
      * @param $permission
      * @return bool
      */
-    public function checkEntityUserAccess(Entity $entity, $permission)
+    public function checkOwnableUserAccess(Ownable $ownable, $permission)
     {
         if ($this->isAdmin) return true;
         $explodedPermission = explode('-', $permission);
 
-        $baseQuery = $entity->where('id', '=', $entity->id);
+        $baseQuery = $ownable->where('id', '=', $ownable->id);
         $action = end($explodedPermission);
         $this->currentAction = $action;
 
@@ -327,7 +328,7 @@ class PermissionService
             $allPermission = $this->currentUser && $this->currentUser->can($permission . '-all');
             $ownPermission = $this->currentUser && $this->currentUser->can($permission . '-own');
             $this->currentAction = 'view';
-            $isOwner = $this->currentUser && $this->currentUser->id === $entity->created_by;
+            $isOwner = $this->currentUser && $this->currentUser->id === $ownable->created_by;
             return ($allPermission || ($isOwner && $ownPermission));
         }
 
index 849a164cf409995a596dd7113d34bebb47d50601..aac9831f74cac2ffee9c84c19f6e94e18934bb34 100644 (file)
@@ -50,7 +50,7 @@ class ViewService
      * Get the entities with the most views.
      * @param int $count
      * @param int $page
-     * @param bool|false $filterModel
+     * @param bool|false|array $filterModel
      */
     public function getPopular($count = 10, $page = 0, $filterModel = false)
     {
@@ -60,7 +60,11 @@ class ViewService
             ->groupBy('viewable_id', 'viewable_type')
             ->orderBy('view_count', 'desc');
 
-        if ($filterModel) $query->where('viewable_type', '=', get_class($filterModel));
+        if ($filterModel && is_array($filterModel)) {
+            $query->whereIn('viewable_type', $filterModel);
+        } else if ($filterModel) {
+            $query->where('viewable_type', '=', get_class($filterModel));
+        };
 
         return $query->with('viewable')->skip($skipCount)->take($count)->get()->pluck('viewable');
     }
index b8f61d94e3fde57f757c115b5f45c4fa0dc84080..42e4c189476c8669a2d8bd336a7c9b84a7223df2 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use BookStack\Ownable;
+
 if (!function_exists('versioned_asset')) {
     /**
      * Get the path to a versioned file.
@@ -34,18 +36,18 @@ if (!function_exists('versioned_asset')) {
  * If an ownable element is passed in the jointPermissions are checked against
  * that particular item.
  * @param $permission
- * @param \BookStack\Ownable $ownable
+ * @param Ownable $ownable
  * @return mixed
  */
-function userCan($permission, \BookStack\Ownable $ownable = null)
+function userCan($permission, Ownable $ownable = null)
 {
     if ($ownable === null) {
         return auth()->user() && auth()->user()->can($permission);
     }
 
     // Check permission on ownable item
-    $permissionService = app('BookStack\Services\PermissionService');
-    return $permissionService->checkEntityUserAccess($ownable, $permission);
+    $permissionService = app(\BookStack\Services\PermissionService::class);
+    return $permissionService->checkOwnableUserAccess($ownable, $permission);
 }
 
 /**
index 17bae1848ddd1e6f47ae6ba8f37912a3390fd186..6a55a0dc33b6d12be2fde9099d72947bcb1ec769 100644 (file)
@@ -5,6 +5,8 @@
  */
 return [
 
-    'app-editor' => 'wysiwyg'
+    'app-editor' => 'wysiwyg',
+    'app-color'  => '#0288D1',
+    'app-color-light' => 'rgba(21, 101, 192, 0.15)'
 
 ];
\ No newline at end of file
index 51fb55c48547f86be43d0929b8d44d414c7e40af..4220809d56e5145e1be711392f618e066de59d9c 100644 (file)
@@ -12,7 +12,13 @@ class CreateBooksTable extends Migration
      */
     public function up()
     {
-        Schema::create('books', function (Blueprint $table) {
+        $pdo = \DB::connection()->getPdo();
+        $mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
+        $requiresISAM = strpos($mysqlVersion, '5.5') === 0;
+
+        Schema::create('books', function (Blueprint $table) use ($requiresISAM) {
+               if($requiresISAM) $table->engine = 'MyISAM';
+            
             $table->increments('id');
             $table->string('name');
             $table->string('slug')->indexed();
index b3b2b9244f234e50d79229179f54b86188738e49..0a29d1087195024ba57bd82c4af036c6772882b0 100644 (file)
@@ -12,7 +12,13 @@ class CreatePagesTable extends Migration
      */
     public function up()
     {
-        Schema::create('pages', function (Blueprint $table) {
+        $pdo = \DB::connection()->getPdo();
+        $mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
+        $requiresISAM = strpos($mysqlVersion, '5.5') === 0;
+
+        Schema::create('pages', function (Blueprint $table) use ($requiresISAM) {
+            if($requiresISAM) $table->engine = 'MyISAM';
+            
             $table->increments('id');
             $table->integer('book_id');
             $table->integer('chapter_id');
index 7974759f26e343f2c75051917ecbfa85324930cd..3ec414480773089f64823e2936f0656beae7e623 100644 (file)
@@ -12,7 +12,12 @@ class CreateChaptersTable extends Migration
      */
     public function up()
     {
-        Schema::create('chapters', function (Blueprint $table) {
+        $pdo = \DB::connection()->getPdo();
+        $mysqlVersion = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION);
+        $requiresISAM = strpos($mysqlVersion, '5.5') === 0;
+
+        Schema::create('chapters', function (Blueprint $table) use ($requiresISAM) {
+            if($requiresISAM) $table->engine = 'MyISAM';
             $table->increments('id');
             $table->integer('book_id');
             $table->string('slug')->indexed();
index 8a20d52d922384d96d6c6bb90d28019f3dddd370..29ac44f5e50c2ad5cc87cf10b5bb23d480a01e89 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -1,5 +1,7 @@
 # BookStack
 
+[![GitHub release](https://img.shields.io/github/release/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/releases/latest)
+[![license](https://img.shields.io/github/license/ssddanbrown/BookStack.svg?maxAge=2592000)](https://github.com/ssddanbrown/BookStack/blob/master/LICENSE)
 [![Build Status](https://travis-ci.org/ssddanbrown/BookStack.svg)](https://travis-ci.org/ssddanbrown/BookStack)
 
 A platform for storing and organising information and documentation. General information and documentation for BookStack can be found at https://www.bookstackapp.com/.
index 8f434bf7e63191d23fc4807531eed1e5f3927376..406fd7e77ee30b639cb5426a8efeca8450d452be 100644 (file)
@@ -379,6 +379,15 @@ module.exports = function (ngApp, events) {
             saveDraft();
         };
 
+        // Listen to shortcuts coming via events
+        $scope.$on('editor-keydown', (event, data) => {
+            // Save shortcut (ctrl+s)
+            if (data.keyCode == 83 && (navigator.platform.match("Mac") ? data.metaKey : data.ctrlKey)) {
+                data.preventDefault();
+                saveDraft();
+            }
+        });
+
         /**
          * Discard the current draft and grab the current page
          * content from the system via an AJAX request.
index 62557f9767c984ec3a5647d259f459629ed21635..0119ded4210eb633be69303aa9422fac39aff920 100644 (file)
@@ -149,7 +149,10 @@ module.exports = function (ngApp, events) {
         };
     }]);
 
-
+    /**
+     * Dropdown
+     * Provides some simple logic to create small dropdown menus
+     */
     ngApp.directive('dropdown', [function () {
         return {
             restrict: 'A',
@@ -166,7 +169,11 @@ module.exports = function (ngApp, events) {
         };
     }]);
 
-    ngApp.directive('tinymce', ['$timeout', function($timeout) {
+    /**
+     * TinyMCE
+     * An angular wrapper around the tinyMCE editor.
+     */
+    ngApp.directive('tinymce', ['$timeout', function ($timeout) {
         return {
             restrict: 'A',
             scope: {
@@ -185,6 +192,10 @@ module.exports = function (ngApp, events) {
                         scope.mceChange(content);
                     });
 
+                    editor.on('keydown', (event) => {
+                        scope.$emit('editor-keydown', event);
+                    });
+
                     editor.on('init', (e) => {
                         scope.mceModel = editor.getContent();
                     });
@@ -200,8 +211,8 @@ module.exports = function (ngApp, events) {
                 scope.tinymce.extraSetups.push(tinyMceSetup);
 
                 // Custom tinyMCE plugins
-                tinymce.PluginManager.add('customhr', function(editor) {
-                    editor.addCommand('InsertHorizontalRule', function() {
+                tinymce.PluginManager.add('customhr', function (editor) {
+                    editor.addCommand('InsertHorizontalRule', function () {
                         var hrElem = document.createElement('hr');
                         var cNode = editor.selection.getNode();
                         var parentNode = cNode.parentNode;
@@ -227,7 +238,11 @@ module.exports = function (ngApp, events) {
         }
     }]);
 
-    ngApp.directive('markdownInput', ['$timeout', function($timeout) {
+    /**
+     * Markdown input
+     * Handles the logic for just the editor input field.
+     */
+    ngApp.directive('markdownInput', ['$timeout', function ($timeout) {
         return {
             restrict: 'A',
             scope: {
@@ -251,7 +266,7 @@ module.exports = function (ngApp, events) {
 
                 scope.$on('markdown-update', (event, value) => {
                     element.val(value);
-                    scope.mdModel= value;
+                    scope.mdModel = value;
                     scope.mdChange(markdown(value));
                 });
 
@@ -259,23 +274,59 @@ module.exports = function (ngApp, events) {
         }
     }]);
 
-    ngApp.directive('markdownEditor', ['$timeout', function($timeout) {
+    /**
+     * Markdown Editor
+     * Handles all functionality of the markdown editor.
+     */
+    ngApp.directive('markdownEditor', ['$timeout', function ($timeout) {
         return {
             restrict: 'A',
             link: function (scope, element, attrs) {
 
                 // Elements
-                var input = element.find('textarea[markdown-input]');
-                var insertImage = element.find('button[data-action="insertImage"]');
+                const input = element.find('textarea[markdown-input]');
+                const display = element.find('.markdown-display').first();
+                const insertImage = element.find('button[data-action="insertImage"]');
 
-                var currentCaretPos = 0;
+                let currentCaretPos = 0;
 
-                input.blur((event) => {
+                input.blur(event => {
                     currentCaretPos = input[0].selectionStart;
                 });
 
-                // Insert image shortcut
-                input.keydown((event) => {
+                // Scroll sync
+                let inputScrollHeight,
+                    inputHeight,
+                    displayScrollHeight,
+                    displayHeight;
+
+                function setScrollHeights() {
+                    inputScrollHeight = input[0].scrollHeight;
+                    inputHeight = input.height();
+                    displayScrollHeight = display[0].scrollHeight;
+                    displayHeight = display.height();
+                }
+
+                setTimeout(() => {
+                    setScrollHeights();
+                }, 200);
+                window.addEventListener('resize', setScrollHeights);
+                let scrollDebounceTime = 800;
+                let lastScroll = 0;
+                input.on('scroll', event => {
+                    let now = Date.now();
+                    if (now - lastScroll > scrollDebounceTime) {
+                        setScrollHeights()
+                    }
+                    let scrollPercent = (input.scrollTop() / (inputScrollHeight - inputHeight));
+                    let displayScrollY = (displayScrollHeight - displayHeight) * scrollPercent;
+                    display.scrollTop(displayScrollY);
+                    lastScroll = now;
+                });
+
+                // Editor key-presses
+                input.keydown(event => {
+                    // Insert image shortcut
                     if (event.which === 73 && event.ctrlKey && event.shiftKey) {
                         event.preventDefault();
                         var caretPos = input[0].selectionStart;
@@ -285,12 +336,15 @@ module.exports = function (ngApp, events) {
                         input.focus();
                         input[0].selectionStart = caretPos + ("![](".length);
                         input[0].selectionEnd = caretPos + ('![](http://'.length);
+                        return;
                     }
+                    // Pass key presses to controller via event
+                    scope.$emit('editor-keydown', event);
                 });
 
                 // Insert image from image manager
-                insertImage.click((event) => {
-                    window.ImageManager.showExternal((image) => {
+                insertImage.click(event => {
+                    window.ImageManager.showExternal(image => {
                         var caretPos = currentCaretPos;
                         var currentContent = input.val();
                         var mdImageText = "![" + image.name + "](" + image.url + ")";
@@ -302,11 +356,16 @@ module.exports = function (ngApp, events) {
             }
         }
     }]);
-    
-    ngApp.directive('toolbox', [function() {
+
+    /**
+     * Page Editor Toolbox
+     * Controls all functionality for the sliding toolbox
+     * on the page edit view.
+     */
+    ngApp.directive('toolbox', [function () {
         return {
             restrict: 'A',
-            link: function(scope, elem, attrs) {
+            link: function (scope, elem, attrs) {
 
                 // Get common elements
                 const $buttons = elem.find('[tab-button]');
@@ -317,7 +376,7 @@ module.exports = function (ngApp, events) {
                 $toggle.click((e) => {
                     elem.toggleClass('open');
                 });
-                
+
                 // Set an active tab/content by name
                 function setActive(tabName, openToolbox) {
                     $buttons.removeClass('active');
@@ -331,7 +390,7 @@ module.exports = function (ngApp, events) {
                 setActive($content.first().attr('tab-content'), false);
 
                 // Handle tab button click
-                $buttons.click(function(e) {
+                $buttons.click(function (e) {
                     let name = $(this).attr('tab-button');
                     setActive(name, true);
                 });
@@ -339,11 +398,16 @@ module.exports = function (ngApp, events) {
         }
     }]);
 
-    ngApp.directive('autosuggestions', ['$http', function($http) {
+    /**
+     * Tag Autosuggestions
+     * Listens to child inputs and provides autosuggestions depending on field type
+     * and input. Suggestions provided by server.
+     */
+    ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
         return {
             restrict: 'A',
-            link: function(scope, elem, attrs) {
-                
+            link: function (scope, elem, attrs) {
+
                 // Local storage for quick caching.
                 const localCache = {};
 
@@ -360,38 +424,49 @@ module.exports = function (ngApp, events) {
                 let active = 0;
 
                 // Listen to input events on autosuggest fields
-                elem.on('input', '[autosuggest]', function(event) {
+                elem.on('input focus', '[autosuggest]', function (event) {
                     let $input = $(this);
                     let val = $input.val();
                     let url = $input.attr('autosuggest');
-                    // No suggestions until at least 3 chars
-                    if (val.length < 3) {
-                        if (isShowing) {
-                            $suggestionBox.hide();
-                            isShowing = false;
+                    let type = $input.attr('autosuggest-type');
+                    
+                    // Add name param to request if for a value
+                    if (type.toLowerCase() === 'value') {
+                        let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
+                        let nameVal = $nameInput.val();
+                        if (nameVal !== '') {
+                            url += '?name=' + encodeURIComponent(nameVal);
                         }
-                        return;
-                    };
+                    }
 
                     let suggestionPromise = getSuggestions(val.slice(0, 3), url);
-                    suggestionPromise.then((suggestions) => {
-                       if (val.length > 2) {
-                           suggestions = suggestions.filter((item) => {
-                               return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
-                           }).slice(0, 4);
-                           displaySuggestions($input, suggestions);
-                       }
+                    suggestionPromise.then(suggestions => {
+                        if (val.length === 0) {
+                            displaySuggestions($input, suggestions.slice(0, 6));
+                        } else  {
+                            suggestions = suggestions.filter(item => {
+                                return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
+                            }).slice(0, 4);
+                            displaySuggestions($input, suggestions);
+                        }
                     });
                 });
 
                 // Hide autosuggestions when input loses focus.
                 // Slight delay to allow clicks.
-                elem.on('blur', '[autosuggest]', function(event) {
+                let lastFocusTime = 0;
+                elem.on('blur', '[autosuggest]', function (event) {
+                    let startTime = Date.now();
                     setTimeout(() => {
-                        $suggestionBox.hide();
-                        isShowing = false;
+                        if (lastFocusTime < startTime) {
+                            $suggestionBox.hide();
+                            isShowing = false;
+                        }
                     }, 200)
                 });
+                elem.on('focus', '[autosuggest]', function (event) {
+                    lastFocusTime = Date.now();
+                });
 
                 elem.on('keydown', '[autosuggest]', function (event) {
                     if (!isShowing) return;
@@ -401,23 +476,25 @@ module.exports = function (ngApp, events) {
 
                     // Down arrow
                     if (event.keyCode === 40) {
-                        let newActive = (active === suggestCount-1) ? 0 : active + 1;
+                        let newActive = (active === suggestCount - 1) ? 0 : active + 1;
                         changeActiveTo(newActive, suggestionElems);
                     }
                     // Up arrow
                     else if (event.keyCode === 38) {
-                        let newActive = (active === 0) ? suggestCount-1 : active - 1;
+                        let newActive = (active === 0) ? suggestCount - 1 : active - 1;
                         changeActiveTo(newActive, suggestionElems);
                     }
-                    // Enter key
-                    else if (event.keyCode === 13) {
+                    // Enter or tab key
+                    else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
                         let text = suggestionElems[active].textContent;
                         currentInput[0].value = text;
                         currentInput.focus();
                         $suggestionBox.hide();
                         isShowing = false;
-                        event.preventDefault();
-                        return false;
+                        if (event.keyCode === 13) {
+                            event.preventDefault();
+                            return false;
+                        }
                     }
                 });
 
@@ -430,6 +507,7 @@ module.exports = function (ngApp, events) {
 
                 // Display suggestions on a field
                 let prevSuggestions = [];
+
                 function displaySuggestions($input, suggestions) {
 
                     // Hide if no suggestions
@@ -466,7 +544,8 @@ module.exports = function (ngApp, events) {
                         if (i === 0) {
                             suggestion.className = 'active'
                             active = 0;
-                        };
+                        }
+                        ;
                         $suggestionBox[0].appendChild(suggestion);
                     }
 
@@ -484,17 +563,18 @@ module.exports = function (ngApp, events) {
 
                 // Get suggestions & cache
                 function getSuggestions(input, url) {
-                    let searchUrl = url + '?search=' + encodeURIComponent(input);
+                    let hasQuery = url.indexOf('?') !== -1;
+                    let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
 
                     // Get from local cache if exists
-                    if (localCache[searchUrl]) {
+                    if (typeof localCache[searchUrl] !== 'undefined') {
                         return new Promise((resolve, reject) => {
-                            resolve(localCache[input]);
+                            resolve(localCache[searchUrl]);
                         });
                     }
 
-                    return $http.get(searchUrl).then((response) => {
-                        localCache[input] = response.data;
+                    return $http.get(searchUrl).then(response => {
+                        localCache[searchUrl] = response.data;
                         return response.data;
                     });
                 }
@@ -502,6 +582,67 @@ module.exports = function (ngApp, events) {
             }
         }
     }]);
+
+
+    ngApp.directive('entitySelector', ['$http', '$sce', function ($http, $sce) {
+        return {
+            restrict: 'A',
+            scope: true,
+            link: function (scope, element, attrs) {
+                scope.loading = true;
+                scope.entityResults = false;
+                scope.search = '';
+
+                // Add input for forms
+                const input = element.find('[entity-selector-input]').first();
+
+                // Listen to entity item clicks
+                element.on('click', '.entity-list a', function(event) {
+                    event.preventDefault();
+                    event.stopPropagation();
+                    let item = $(this).closest('[data-entity-type]');
+                    itemSelect(item);
+                });
+                element.on('click', '[data-entity-type]', function(event) {
+                    itemSelect($(this));
+                });
+
+                // Select entity action
+                function itemSelect(item) {
+                    let entityType = item.attr('data-entity-type');
+                    let entityId = item.attr('data-entity-id');
+                    let isSelected = !item.hasClass('selected');
+                    element.find('.selected').removeClass('selected').removeClass('primary-background');
+                    if (isSelected) item.addClass('selected').addClass('primary-background');
+                    let newVal = isSelected ? `${entityType}:${entityId}` : '';
+                    input.val(newVal);
+                }
+
+                // Get search url with correct types
+                function getSearchUrl() {
+                    let types = (attrs.entityTypes) ? encodeURIComponent(attrs.entityTypes) : encodeURIComponent('page,book,chapter');
+                    return `/ajax/search/entities?types=${types}`;
+                }
+
+                // Get initial contents
+                $http.get(getSearchUrl()).then(resp => {
+                    scope.entityResults = $sce.trustAsHtml(resp.data);
+                    scope.loading = false;
+                });
+
+                // Search when typing
+                scope.searchEntities = function() {
+                    scope.loading = true;
+                    input.val('');
+                    let url = getSearchUrl() + '&term=' + encodeURIComponent(scope.search);
+                    $http.get(url).then(resp => {
+                        scope.entityResults = $sce.trustAsHtml(resp.data);
+                        scope.loading = false;
+                    });
+                };
+            }
+        };
+    }]);
 };
 
 
index d4fe7020bcac72e697a1bb7970225afbf08e3cbf..44562abd03806b7742e49d29112c45e87d11ba40 100644 (file)
@@ -112,16 +112,11 @@ $(function () {
 
     // Common jQuery actions
     $('[data-action="expand-entity-list-details"]').click(function() {
-        $('.entity-list.compact').find('p').slideToggle(240);
+        $('.entity-list.compact').find('p').not('.empty-text').slideToggle(240);
     });
 
 
 });
 
-
-function elemExists(selector) {
-    return document.querySelector(selector) !== null;
-}
-
 // Page specific items
 require('./pages/page-show');
index 5617fa5be98b6ce8b8d2d448b1c1e1a151cfaccd..611d2e78298b0445b277aa29ef187462854db4bb 100644 (file)
@@ -1,7 +1,8 @@
 var mceOptions = module.exports = {
     selector: '#html-editor',
     content_css: [
-        '/css/styles.css'
+        '/css/styles.css',
+        '/libs/material-design-iconic-font/css/material-design-iconic-font.min.css'
     ],
     body_class: 'page-content',
     relative_urls: false,
@@ -19,11 +20,18 @@ var mceOptions = module.exports = {
         {title: "Header 1", format: "h1"},
         {title: "Header 2", format: "h2"},
         {title: "Header 3", format: "h3"},
-        {title: "Paragraph", format: "p"},
+        {title: "Paragraph", format: "p", exact: true, classes: ''},
         {title: "Blockquote", format: "blockquote"},
         {title: "Code Block", icon: "code", format: "pre"},
-        {title: "Inline Code", icon: "code", inline: "code"}
+        {title: "Inline Code", icon: "code", inline: "code"},
+        {title: "Callouts", items: [
+            {title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
+            {title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
+            {title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
+            {title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
+        ]}
     ],
+    style_formats_merge: false,
     formats: {
         alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
         aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
index 3ddece1b8dfbb182d39f907c927f7b4a9970e00f..b037612bec540354ff78ba9635e0f892d8f93fa1 100644 (file)
@@ -74,15 +74,15 @@ window.setupPageShow = module.exports = function (pageId) {
     // Make the book-tree sidebar stick in view on scroll
     var $window = $(window);
     var $bookTree = $(".book-tree");
+    var $bookTreeParent = $bookTree.parent();
     // Check the page is scrollable and the content is taller than the tree
     var pageScrollable = ($(document).height() > $window.height()) && ($bookTree.height() < $('.page-content').height());
     // Get current tree's width and header height
     var headerHeight = $("#header").height() + $(".toolbar").height();
     var isFixed = $window.scrollTop() > headerHeight;
-    var bookTreeWidth = $bookTree.width();
     // Function to fix the tree as a sidebar
     function stickTree() {
-        $bookTree.width(bookTreeWidth + 48 + 15);
+        $bookTree.width($bookTreeParent.width() + 15);
         $bookTree.addClass("fixed");
         isFixed = true;
     }
@@ -101,13 +101,27 @@ window.setupPageShow = module.exports = function (pageId) {
             unstickTree();
         }
     }
+    // The event ran when the window scrolls
+    function windowScrollEvent() {
+        checkTreeStickiness(false);
+    }
+
     // If the page is scrollable and the window is wide enough listen to scroll events
     // and evaluate tree stickiness.
     if (pageScrollable && $window.width() > 1000) {
-        $window.scroll(function() {
-            checkTreeStickiness(false);
-        });
+        $window.on('scroll', windowScrollEvent);
         checkTreeStickiness(true);
     }
 
+    // Handle window resizing and switch between desktop/mobile views
+    $window.on('resize', event => {
+        if (pageScrollable && $window.width() > 1000) {
+            $window.on('scroll', windowScrollEvent);
+            checkTreeStickiness(true);
+        } else {
+            $window.off('scroll', windowScrollEvent);
+            unstickTree();
+        }
+    });
+
 };
index bf23eb565ce6a364f4ebe31a7803f79767c694df..3c7f7490b0de89c0b83ad694e5f110ed3db486d2 100644 (file)
     margin-right: $-xl;
   }
 }
+
+
+/**
+ * Callouts
+ */
+
+.callout {
+  border-left: 3px solid #BBB;
+  background-color: #EEE;
+  padding: $-s;
+  &:before {
+    font-family: 'Material-Design-Iconic-Font';
+    padding-right: $-s;
+    display: inline-block;
+  }
+  &.success {
+    border-left-color: $positive;
+    background-color: lighten($positive, 45%);
+    color: darken($positive, 16%);
+  }
+  &.success:before {
+    content: '\f269';
+  }
+  &.danger {
+    border-left-color: $negative;
+    background-color: lighten($negative, 34%);
+    color: darken($negative, 20%);
+  }
+  &.danger:before {
+    content: '\f1f2';
+  }
+  &.info {
+    border-left-color: $info;
+    background-color: lighten($info, 50%);
+    color: darken($info, 16%);
+  }
+  &.info:before {
+    content: '\f1f8';
+  }
+  &.warning {
+    border-left-color: $warning;
+    background-color: lighten($warning, 36%);
+    color: darken($warning, 16%);
+  }
+  &.warning:before {
+    content: '\f1f1';
+  }
+}
\ No newline at end of file
index da015ec7cfed97fa80bc10bc6db51e2edc099fb1..4e643dcdaa5ab9de5ec6be9ec8ef4c6881d34c07 100644 (file)
@@ -20,6 +20,9 @@
   &.disabled, &[disabled] {
     background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
   }
+  &:focus {
+    outline: 0;
+  }
 }
 
 #html-editor {
index 388d5753da1fd0e186117228ca417a7c7faf5527..08f00677e9a716401cc15a50d03d1f3e7d64befa 100644 (file)
@@ -1,6 +1,7 @@
 .page-list {
   h3 {
-    margin: $-l 0 $-m 0;
+    margin: $-l 0 $-xs 0;
+    font-size: 1.666em;
   }
   a.chapter {
     color: $color-chapter;
@@ -8,7 +9,6 @@
   .inset-list {
     display: none;
     overflow: hidden;
-    // padding-left: $-m;
     margin-bottom: $-l;
   }
   h4 {
@@ -338,6 +338,10 @@ ul.pagination {
     padding-top: $-xs;
     margin: 0;
   }
+  > p.empty-text {
+    display: block;
+    font-size: $fs-m;
+  }
   hr {
     margin: 0;
   }
index e61e0c8232ad3664a90ddcd77086b2cfe41fcc0e..49b701dda9468e1b9a67c3322d1eda09fc60f365 100644 (file)
@@ -48,7 +48,7 @@
     max-width: 100%;
     height:auto;
   }
-  h1, h2, h3, h4, h5, h6 {
+  h1, h2, h3, h4, h5, h6, pre {
     clear: left;
   }
   hr {
index 0095b91cbf792adaa5764101732cfc8201c9f4bb..cd81bb4e21326d24e2589751438b0a8e510e217b 100644 (file)
@@ -3,7 +3,7 @@
  */
 
 h1 {
-  font-size: 3.625em;
+  font-size: 3.425em;
   line-height: 1.22222222em;
   margin-top: 0.48888889em;
   margin-bottom: 0.48888889em;
@@ -33,10 +33,10 @@ h1, h2, h3, h4 {
   display: block;
   color: #555;
   .subheader {
-    display: block;
+    //display: block;
     font-size: 0.5em;
     line-height: 1em;
-    color: lighten($text-dark, 16%);
+    color: lighten($text-dark, 32%);
   }
 }
 
@@ -225,6 +225,15 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
     color: $color-chapter;
   }
 }
+.faded .text-book:hover {
+  color: $color-book !important;
+}
+.faded .text-chapter:hover {
+  color: $color-chapter !important;
+}
+.faded .text-page:hover {
+  color: $color-page !important;
+}
 
 span.highlight {
   //background-color: rgba($primary, 0.2);
index 4f8ea0f08b6578a7c3aed71efcd3060ec0c53d66..23bf2b219542077c6acfd9238f9d6649a8f94f59 100644 (file)
@@ -38,6 +38,7 @@ $primary-dark: #0288D1;
 $secondary: #e27b41;
 $positive: #52A256;
 $negative: #E84F4F;
+$info: $primary;
 $warning: $secondary;
 $primary-faded: rgba(21, 101, 192, 0.15);
 
index 0a7da179b09c58d3ec01ca65db36050949a9bca4..a6c364018584b050d11d91b702d88dbed123f054 100644 (file)
@@ -207,3 +207,59 @@ $btt-size: 40px;
     color: #EEE;
   }
 }
+
+.entity-selector {
+  border: 1px solid #DDD;
+  border-radius: 3px;
+  overflow: hidden;
+  font-size: 0.8em;
+  input[type="text"] {
+    width: 100%;
+    display: block;
+    border-radius: 0;
+    border: 0;
+    border-bottom: 1px solid #DDD;
+    font-size: 16px;
+    padding: $-s $-m;
+  }
+  .entity-list {
+    overflow-y: scroll;
+    height: 400px;
+    background-color: #EEEEEE;
+  }
+  .loading {
+    height: 400px;
+    padding-top: $-l;
+  }
+  .entity-list > p {
+    text-align: center;
+    padding-top: $-l;
+    font-size: 1.333em;
+  }
+  .entity-list > div {
+    padding-left: $-m;
+    padding-right: $-m;
+    background-color: #FFF;
+    transition: all ease-in-out 120ms;
+    cursor: pointer;
+  }
+}
+
+.entity-list-item.selected {
+  h3, i, p ,a, span {
+    color: #EEE;
+  }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
index 8356d8302d950180a06d32de43ce95a680b80752..56af4ca07b3c9914c9158a9e6c33fa116d07c2b7 100644 (file)
@@ -4,7 +4,7 @@ return [
 
     /**
      * Activity text strings.
-     * Is used for all the text within activity logs.
+     * Is used for all the text within activity logs & notifications.
      */
 
     // Pages
@@ -16,6 +16,7 @@ return [
     'page_delete_notification'    => 'Page Successfully Deleted',
     'page_restore'                => 'restored page',
     'page_restore_notification'   => 'Page Successfully Restored',
+    'page_move'                   => 'moved page',
 
     // Chapters
     'chapter_create'              => 'created chapter',
@@ -24,6 +25,7 @@ return [
     'chapter_update_notification' => 'Chapter Successfully Updated',
     'chapter_delete'              => 'deleted chapter',
     'chapter_delete_notification' => 'Chapter Successfully Deleted',
+    'chapter_move'                => 'moved chapter',
 
     // Books
     'book_create'                 => 'created book',
index 5807bf4611edd8ba04086dee47f49a28736f4dae..945eb901563c1a9b64a8e02afe79ba4cf5278a30 100644 (file)
@@ -1,4 +1,4 @@
-<div class="book">
+<div class="book entity-list-item"  data-entity-type="book" data-entity-id="{{$book->id}}">
     <h3 class="text-book"><a class="text-book" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i>{{$book->name}}</a></h3>
     @if(isset($book->searchSnippet))
         <p class="text-muted">{!! $book->searchSnippet !!}</p>
index d5ac46338c772ff830865562c9378fdef633a87a..1567557d2cef48f6bdf8d77ce3047ba3f993d415 100644 (file)
@@ -1,5 +1,11 @@
-<div class="chapter">
+<div class="chapter entity-list-item" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
     <h3>
+        @if (isset($showPath) && $showPath)
+            <a href="{{ $chapter->book->getUrl() }}" class="text-book">
+                <i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
+            </a>
+            <span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
+        @endif
         <a href="{{ $chapter->getUrl() }}" class="text-chapter">
             <i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->name }}
         </a>
diff --git a/resources/views/chapters/move.blade.php b/resources/views/chapters/move.blade.php
new file mode 100644 (file)
index 0000000..c14dd69
--- /dev/null
@@ -0,0 +1,33 @@
+@extends('base')
+
+@section('content')
+
+    <div class="faded-small toolbar">
+        <div class="container">
+            <div class="row">
+                <div class="col-sm-12 faded">
+                    <div class="breadcrumbs">
+                        <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
+                        <span class="sep">&raquo;</span>
+                        <a href="{{$chapter->getUrl()}}" class="text-chapter text-button"><i class="zmdi zmdi-collection-bookmark"></i>{{ $chapter->getShortName() }}</a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="container">
+        <h1>Move Chapter <small class="subheader">{{$chapter->name}}</small></h1>
+
+        <form action="{{ $chapter->getUrl() }}/move" method="POST">
+            {!! csrf_field() !!}
+            <input type="hidden" name="_method" value="PUT">
+
+            @include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book'])
+
+            <a href="{{ $chapter->getUrl() }}" class="button muted">Cancel</a>
+            <button type="submit" class="button pos">Move Chapter</button>
+        </form>
+    </div>
+
+@stop
index 26935847150706d5eae65ccf9ecb8b91e36c2c7a..b79cd8415fffd8d2fd1e0d463b56fc1784d0d852 100644 (file)
@@ -2,15 +2,15 @@
 
 @section('content')
 
-    <div class="faded-small toolbar" ng-non-bindable>
+    <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
-                <div class="col-md-4 faded">
+                <div class="col-sm-8 faded" ng-non-bindable>
                     <div class="breadcrumbs">
                         <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
                     </div>
                 </div>
-                <div class="col-md-8 faded">
+                <div class="col-sm-4 faded">
                     <div class="action-buttons">
                         @if(userCan('page-create', $chapter))
                             <a href="{{$chapter->getUrl() . '/create-page'}}" class="text-pos text-button"><i class="zmdi zmdi-plus"></i>New Page</a>
                         @if(userCan('chapter-update', $chapter))
                             <a href="{{$chapter->getUrl() . '/edit'}}" class="text-primary text-button"><i class="zmdi zmdi-edit"></i>Edit</a>
                         @endif
-                        @if(userCan('restrictions-manage', $chapter))
-                            <a href="{{$chapter->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
-                        @endif
-                        @if(userCan('chapter-delete', $chapter))
-                            <a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
+                        @if(userCan('chapter-update', $chapter) || userCan('restrictions-manage', $chapter) || userCan('chapter-delete', $chapter))
+                            <div dropdown class="dropdown-container">
+                                <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
+                                <ul>
+                                    @if(userCan('chapter-update', $chapter))
+                                        <li><a href="{{$chapter->getUrl() . '/move'}}" class="text-primary"><i class="zmdi zmdi-folder"></i>Move</a></li>
+                                    @endif
+                                    @if(userCan('restrictions-manage', $chapter))
+                                        <li><a href="{{$chapter->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
+                                    @endif
+                                    @if(userCan('chapter-delete', $chapter))
+                                        <li><a href="{{$chapter->getUrl() . '/delete'}}" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
+                                    @endif
+                                </ul>
+                            </div>
                         @endif
                     </div>
                 </div>
index e0aa08568b747711717f7e6e7ee10c31dcba6f03..88319738e1630892121da89a241ede4aaef4df81 100644 (file)
                 @else
                     <h3>Recent Books</h3>
                 @endif
-                @include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
+                @include('partials/entity-list', [
+                'entities' => $recents,
+                'style' => 'compact',
+                'emptyText' => $signedIn ? 'You have not viewed any pages' : 'No books have been created'
+                ])
             </div>
 
             <div class="col-sm-4">
                 <h3><a class="no-color" href="/pages/recently-created">Recently Created Pages</a></h3>
                 <div id="recently-created-pages">
-                    @include('partials/entity-list', ['entities' => $recentlyCreatedPages, 'style' => 'compact'])
+                    @include('partials/entity-list', [
+                    'entities' => $recentlyCreatedPages,
+                    'style' => 'compact',
+                    'emptyText' => 'No pages have been recently created'
+                    ])
                 </div>
 
                 <h3><a class="no-color" href="/pages/recently-updated">Recently Updated Pages</a></h3>
                 <div id="recently-updated-pages">
-                    @include('partials/entity-list', ['entities' => $recentlyUpdatedPages, 'style' => 'compact'])
+                    @include('partials/entity-list', [
+                    'entities' => $recentlyUpdatedPages,
+                    'style' => 'compact',
+                    'emptyText' => 'No pages have been recently updated'
+                    ])
                 </div>
             </div>
 
index ae17045d109849b7bacdab2d339ed853f8b8c9e7..b3fcd7c13c29f8c9971a9f91095841b9c5e7a71c 100644 (file)
         <h4>Page Tags</h4>
         <div class="padded tags">
             <p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
-            <table class="no-style" autosuggestions style="width: 100%;">
+            <table class="no-style" tag-autosuggestions style="width: 100%;">
                 <tbody ui-sortable="sortOptions" ng-model="tags" >
                     <tr ng-repeat="tag in tags track by $index">
                         <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
-                        <td><input autosuggest="/ajax/tags/suggest/names" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
-                        <td><input autosuggest="/ajax/tags/suggest/values" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
+                        <td><input autosuggest="/ajax/tags/suggest/names" autosuggest-type="name" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag"></td>
+                        <td><input autosuggest="/ajax/tags/suggest/values" autosuggest-type="value" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="Tag Value (Optional)"></td>
                         <td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
                     </tr>
                 </tbody>
index aa05a90146d6adb57e6543b5c7e667395693fe96..4196e946fc8b8398e5c7074715270895740995b9 100644 (file)
@@ -61,7 +61,7 @@
                             <button class="text-button" type="button" data-action="insertImage"><i class="zmdi zmdi-image"></i>Insert Image</button>
                         </div>
                     </div>
-                    <textarea markdown-input md-change="editorChange" md-model="editContent"  name="markdown" rows="5"
+                    <textarea markdown-input md-change="editorChange" id="markdown-editor-input" md-model="editContent"  name="markdown" rows="5"
                               @if($errors->has('markdown')) class="neg" @endif>@if(isset($model) || old('markdown')){{htmlspecialchars( old('markdown') ? old('markdown') : ($model->markdown === '' ? $model->html : $model->markdown))}}@endif</textarea>
                 </div>
 
index 87a7eabe55c4608d51cbb74fab54cb5a327cf83e..a95870db044725453b3404daf20d94e6796b08ea 100644 (file)
@@ -1,4 +1,4 @@
-<div class="page {{$page->draft ? 'draft' : ''}}">
+<div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
     <h3>
         <a href="{{ $page->getUrl() }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ $page->name }}</a>
     </h3>
 
     @if(isset($style) && $style === 'detailed')
         <div class="row meta text-muted text-small">
-            <div class="col-md-4">
+            <div class="col-md-6">
                 Created {{$page->created_at->diffForHumans()}} @if($page->createdBy)by {{$page->createdBy->name}}@endif <br>
                 Last updated {{ $page->updated_at->diffForHumans() }} @if($page->updatedBy)by {{$page->updatedBy->name}} @endif
             </div>
-            <div class="col-md-8">
+            <div class="col-md-6">
                 <a class="text-book" href="{{ $page->book->getUrl() }}"><i class="zmdi zmdi-book"></i>{{ $page->book->getShortName(30) }}</a>
                 <br>
                 @if($page->chapter)
diff --git a/resources/views/pages/move.blade.php b/resources/views/pages/move.blade.php
new file mode 100644 (file)
index 0000000..27ee4cd
--- /dev/null
@@ -0,0 +1,40 @@
+@extends('base')
+
+@section('content')
+
+    <div class="faded-small toolbar">
+        <div class="container">
+            <div class="row">
+                <div class="col-sm-12 faded">
+                    <div class="breadcrumbs">
+                        <a href="{{$book->getUrl()}}" class="text-book text-button"><i class="zmdi zmdi-book"></i>{{ $book->getShortName() }}</a>
+                        @if($page->hasChapter())
+                            <span class="sep">&raquo;</span>
+                            <a href="{{ $page->chapter->getUrl() }}" class="text-chapter text-button">
+                                <i class="zmdi zmdi-collection-bookmark"></i>
+                                {{$page->chapter->getShortName()}}
+                            </a>
+                        @endif
+                        <span class="sep">&raquo;</span>
+                        <a href="{{$page->getUrl()}}" class="text-page text-button"><i class="zmdi zmdi-file-text"></i>{{ $page->getShortName() }}</a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="container">
+        <h1>Move Page <small class="subheader">{{$page->name}}</small></h1>
+
+        <form action="{{ $page->getUrl() }}/move" method="POST">
+            {!! csrf_field() !!}
+            <input type="hidden" name="_method" value="PUT">
+
+            @include('partials/entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter'])
+
+            <a href="{{ $page->getUrl() }}" class="button muted">Cancel</a>
+            <button type="submit" class="button pos">Move Page</button>
+        </form>
+    </div>
+
+@stop
index 4dbab0e29d864ecd1db41d1c3b4c2738d7617d2f..f12ba58c61321a9f69ec444b478d1a56a4bd2a4f 100644 (file)
                             </ul>
                         </span>
                         @if(userCan('page-update', $page))
-                            <a href="{{$page->getUrl()}}/revisions" class="text-primary text-button"><i class="zmdi zmdi-replay"></i>Revisions</a>
                             <a href="{{$page->getUrl()}}/edit" class="text-primary text-button" ><i class="zmdi zmdi-edit"></i>Edit</a>
                         @endif
-                        @if(userCan('restrictions-manage', $page))
-                            <a href="{{$page->getUrl()}}/permissions" class="text-primary text-button"><i class="zmdi zmdi-lock-outline"></i>Permissions</a>
-                        @endif
-                        @if(userCan('page-delete', $page))
-                            <a href="{{$page->getUrl()}}/delete" class="text-neg text-button"><i class="zmdi zmdi-delete"></i>Delete</a>
+                        @if(userCan('page-update', $page) || userCan('restrictions-manage', $page) || userCan('page-delete', $page))
+                            <div dropdown class="dropdown-container">
+                                <a dropdown-toggle class="text-primary text-button"><i class="zmdi zmdi-more-vert"></i></a>
+                                <ul>
+                                    @if(userCan('page-update', $page))
+                                        <li><a href="{{$page->getUrl()}}/move" class="text-primary" ><i class="zmdi zmdi-folder"></i>Move</a></li>
+                                        <li><a href="{{$page->getUrl()}}/revisions" class="text-primary"><i class="zmdi zmdi-replay"></i>Revisions</a></li>
+                                    @endif
+                                    @if(userCan('restrictions-manage', $page))
+                                        <li><a href="{{$page->getUrl()}}/permissions" class="text-primary"><i class="zmdi zmdi-lock-outline"></i>Permissions</a></li>
+                                    @endif
+                                    @if(userCan('page-delete', $page))
+                                        <li><a href="{{$page->getUrl()}}/delete" class="text-neg"><i class="zmdi zmdi-delete"></i>Delete</a></li>
+                                    @endif
+                                </ul>
+                            </div>
                         @endif
+
                     </div>
                 </div>
             </div>
index 7198e7cbb0683c4ae69defb4cb4af039e30bc30b..de72f5051e4644e4af7a0f75eff8782b7ebf4547 100644 (file)
@@ -1,6 +1,4 @@
 
-{{--Requires an entity to be passed with the name $entity--}}
-
 @if(count($activity) > 0)
     <div class="activity-list">
         @foreach($activity as $activityItem)
@@ -10,5 +8,5 @@
         @endforeach
     </div>
 @else
-    <p class="text-muted">New activity will show up here.</p>
+    <p class="text-muted">No activity to show</p>
 @endif
\ No newline at end of file
index 4d3d0a6ef7cdcafb8c3783346228f80a8584beae..2e23e267ba93fef4ae2b8389b52f9582caafb238 100644 (file)
@@ -1,22 +1,20 @@
-@if(Setting::get('app-color'))
-    <style>
-        header, #back-to-top, .primary-background {
-            background-color: {{ Setting::get('app-color') }};
-        }
-        .faded-small, .primary-background-light {
-            background-color: {{ Setting::get('app-color-light') }};
-        }
-        .button-base, .button, input[type="button"], input[type="submit"] {
-            background-color: {{ Setting::get('app-color') }};
-        }
-        .button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
-            background-color: {{ Setting::get('app-color') }};
-        }
-        .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, .text-button, .text-button:hover, .text-button:focus {
-            color: {{ Setting::get('app-color') }};
-        }
-    </style>
-@endif
\ No newline at end of file
+<style>
+    header, #back-to-top, .primary-background {
+        background-color: {{ Setting::get('app-color') }} !important;
+    }
+    .faded-small, .primary-background-light {
+        background-color: {{ Setting::get('app-color-light') }};
+    }
+    .button-base, .button, input[type="button"], input[type="submit"] {
+        background-color: {{ Setting::get('app-color') }};
+    }
+    .button-base:hover, .button:hover, input[type="button"]:hover, input[type="submit"]:hover, .button:focus {
+        background-color: {{ Setting::get('app-color') }};
+    }
+    .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, .text-button, .text-button:hover, .text-button:focus {
+        color: {{ Setting::get('app-color') }};
+    }
+</style>
\ No newline at end of file
index a52e5f01380e2f99df9e250d251991aef1b672ef..412d3be8f00ca981d1c36862a210c25693253070 100644 (file)
@@ -16,8 +16,8 @@
 
         @endforeach
     @else
-        <p class="text-muted">
-            No items available
+        <p class="text-muted empty-text">
+            {{ $emptyText or 'No items available' }}
         </p>
     @endif
 </div>
\ No newline at end of file
diff --git a/resources/views/partials/entity-selector.blade.php b/resources/views/partials/entity-selector.blade.php
new file mode 100644 (file)
index 0000000..59e1741
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="form-group">
+    <div entity-selector class="entity-selector {{$selectorSize or ''}}" entity-types="{{ $entityTypes or 'book,chapter,page' }}">
+        <input type="hidden" entity-selector-input name="{{$name}}" value="">
+        <input type="text" placeholder="Search" ng-model="search" ng-model-options="{debounce: 200}" ng-change="searchEntities()">
+        <div class="text-center loading" ng-show="loading">@include('partials/loading-icon')</div>
+        <div ng-show="!loading" ng-bind-html="entityResults"></div>
+    </div>
+</div>
\ No newline at end of file
index 183934c6655f8d775691deb261454721ebc5d6bc..c079080dbb1aedef4364cc5f8aa9a533cee047b7 100644 (file)
@@ -1,12 +1,12 @@
 
-<div class="notification anim pos" @if(!Session::has('success')) style="display:none;" @endif>
-    <i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(Session::get('success'))) !!}</span>
+<div class="notification anim pos" @if(!session()->has('success')) style="display:none;" @endif>
+    <i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
 </div>
 
-<div class="notification anim warning stopped" @if(!Session::has('warning')) style="display:none;" @endif>
-    <i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(Session::get('warning'))) !!}</span>
+<div class="notification anim warning stopped" @if(!session()->has('warning')) style="display:none;" @endif>
+    <i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
 </div>
 
-<div class="notification anim neg stopped" @if(!Session::has('error')) style="display:none;" @endif>
-    <i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(Session::get('error'))) !!}</span>
+<div class="notification anim neg stopped" @if(!session()->has('error')) style="display:none;" @endif>
+    <i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
 </div>
diff --git a/resources/views/search/entity-ajax-list.blade.php b/resources/views/search/entity-ajax-list.blade.php
new file mode 100644 (file)
index 0000000..97d5e4d
--- /dev/null
@@ -0,0 +1,22 @@
+<div class="entity-list @if(isset($style)){{ $style }}@endif" ng-non-bindable>
+    @if(count($entities) > 0)
+        @foreach($entities as $index => $entity)
+            @if($entity->isA('page'))
+                @include('pages/list-item', ['page' => $entity])
+            @elseif($entity->isA('book'))
+                @include('books/list-item', ['book' => $entity])
+            @elseif($entity->isA('chapter'))
+                @include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true, 'showPath' => true])
+            @endif
+
+            @if($index !== count($entities) - 1)
+                <hr>
+            @endif
+
+        @endforeach
+    @else
+        <p class="text-muted">
+            No items available
+        </p>
+    @endif
+</div>
\ No newline at end of file
index 6b313e7b82aa58d74a3f81f846cbd5bde6ac6856..a9d9bc04775c89f4b6ba3161c6bf09fd51c0ccea 100644 (file)
@@ -82,4 +82,14 @@ class EntitySearchTest extends TestCase
         $this->asAdmin()->visit('/search/books?term=' . $book->name)
             ->see('Book Search Results')->see('.entity-list', $book->name);
     }
+
+    public function test_ajax_entity_search()
+    {
+        $page = \BookStack\Page::all()->last();
+        $notVisitedPage = \BookStack\Page::first();
+        $this->visit($page->getUrl());
+        $this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name);
+        $this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name);
+        $this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name);
+    }
 }
index 8792a0a067c44b96f2db86f184e55c987c5f763e..80783912a46f79e9ef93a8aad43574bc1772e4a7 100644 (file)
@@ -22,4 +22,47 @@ class SortTest extends TestCase
             ->dontSee($draft->name);
     }
 
+    public function test_page_move()
+    {
+        $page = \BookStack\Page::first();
+        $currentBook = $page->book;
+        $newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
+        $this->asAdmin()->visit($page->getUrl() . '/move')
+            ->see('Move Page')->see($page->name)
+            ->type('book:' . $newBook->id, 'entity_selection')->press('Move Page');
+
+        $page = \BookStack\Page::find($page->id);
+        $this->seePageIs($page->getUrl());
+        $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book');
+
+        $this->visit($newBook->getUrl())
+            ->seeInNthElement('.activity-list-item', 0, 'moved page')
+            ->seeInNthElement('.activity-list-item', 0, $page->name);
+    }
+
+    public function test_chapter_move()
+    {
+        $chapter = \BookStack\Chapter::first();
+        $currentBook = $chapter->book;
+        $pageToCheck = $chapter->pages->first();
+        $newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first();
+
+        $this->asAdmin()->visit($chapter->getUrl() . '/move')
+            ->see('Move Chapter')->see($chapter->name)
+            ->type('book:' . $newBook->id, 'entity_selection')->press('Move Chapter');
+
+        $chapter = \BookStack\Chapter::find($chapter->id);
+        $this->seePageIs($chapter->getUrl());
+        $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book');
+
+        $this->visit($newBook->getUrl())
+            ->seeInNthElement('.activity-list-item', 0, 'moved chapter')
+            ->seeInNthElement('.activity-list-item', 0, $chapter->name);
+
+        $pageToCheck = \BookStack\Page::find($pageToCheck->id);
+        $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book');
+        $this->visit($pageToCheck->getUrl())
+            ->see($newBook->name);
+    }
+
 }
\ No newline at end of file
diff --git a/tests/ImageTest.php b/tests/ImageTest.php
new file mode 100644 (file)
index 0000000..806a36a
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+
+class ImageTest extends TestCase
+{
+
+    /**
+     * Get a test image that can be uploaded
+     * @param $fileName
+     * @return \Illuminate\Http\UploadedFile
+     */
+    protected function getTestImage($fileName)
+    {
+        return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
+    }
+
+    /**
+     * Get the path for a test image.
+     * @param $type
+     * @param $fileName
+     * @return string
+     */
+    protected function getTestImagePath($type, $fileName)
+    {
+        return '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/' . $fileName;
+    }
+
+    /**
+     * Uploads an image with the given name.
+     * @param $name
+     * @param int $uploadedTo
+     * @return string
+     */
+    protected function uploadImage($name, $uploadedTo = 0)
+    {
+        $file = $this->getTestImage($name);
+        $this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
+        return $this->getTestImagePath('gallery', $name);
+    }
+
+    /**
+     * Delete an uploaded image.
+     * @param $relPath
+     */
+    protected function deleteImage($relPath)
+    {
+        unlink(public_path($relPath));
+    }
+
+
+    public function test_image_upload()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+        $admin = $this->getAdmin();
+        $imageName = 'first-image.jpg';
+
+        $relPath = $this->uploadImage($imageName, $page->id);
+        $this->assertResponseOk();
+
+        $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image exists');
+
+        $this->seeInDatabase('images', [
+            'url' => $relPath,
+            'type' => 'gallery',
+            'uploaded_to' => $page->id,
+            'path' => $relPath,
+            'created_by' => $admin->id,
+            'updated_by' => $admin->id,
+            'name' => $imageName
+        ]);
+        
+        $this->deleteImage($relPath);
+    }
+
+    public function test_image_delete()
+    {
+        $page = \BookStack\Page::first();
+        $this->asAdmin();
+        $imageName = 'first-image.jpg';
+
+        $relPath = $this->uploadImage($imageName, $page->id);
+        $image = \BookStack\Image::first();
+
+        $this->call('DELETE', '/images/' . $image->id);
+        $this->assertResponseOk();
+
+        $this->dontSeeInDatabase('images', [
+            'url' => $relPath,
+            'type' => 'gallery'
+        ]);
+
+        $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has been deleted');
+    }
+
+}
\ No newline at end of file
index 4c2893f4e7d43ce8f0c85850ba078cce81658bc0..6a8c2d732b65dc123de08569c6f4c2da7c2ac3bc 100644 (file)
@@ -39,11 +39,19 @@ class TestCase extends Illuminate\Foundation\Testing\TestCase
      */
     public function asAdmin()
     {
+        return $this->actingAs($this->getAdmin());
+    }
+
+    /**
+     * Get the current admin user.
+     * @return mixed
+     */
+    public function getAdmin() {
         if($this->admin === null) {
             $adminRole = \BookStack\Role::getRole('admin');
             $this->admin = $adminRole->users->first();
         }
-        return $this->actingAs($this->admin);
+        return $this->admin;
     }
 
     /**
diff --git a/tests/test-image.jpg b/tests/test-image.jpg
new file mode 100644 (file)
index 0000000..fb8da91
Binary files /dev/null and b/tests/test-image.jpg differ