From 4948b443b6ce3088fbdf99dbdc1d7621d1eb73c4 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 24 Jun 2018 13:38:19 +0100
Subject: [PATCH 01/11] Started work on bookshelves

---
 app/Book.php                                  |  8 --
 app/BookShelf.php                             | 74 +++++++++++++++++++
 ..._06_24_115700_create_bookshelves_table.php | 70 ++++++++++++++++++
 resources/views/books/show.blade.php          |  2 +-
 4 files changed, 145 insertions(+), 9 deletions(-)
 create mode 100644 app/BookShelf.php
 create mode 100644 database/migrations/2018_06_24_115700_create_bookshelves_table.php

diff --git a/app/Book.php b/app/Book.php
index 51ea226b4..effd6ca45 100644
--- a/app/Book.php
+++ b/app/Book.php
@@ -48,14 +48,6 @@ class Book extends Entity
     {
         return $this->belongsTo(Image::class, 'image_id');
     }
-    /*
-     * Get the edit url for this book.
-     * @return string
-     */
-    public function getEditUrl()
-    {
-        return $this->getUrl() . '/edit';
-    }
 
     /**
      * Get all pages within this book.
diff --git a/app/BookShelf.php b/app/BookShelf.php
new file mode 100644
index 000000000..47f873bcd
--- /dev/null
+++ b/app/BookShelf.php
@@ -0,0 +1,74 @@
+<?php namespace BookStack;
+
+
+class BookShelf extends Entity
+{
+    protected $table = 'bookshelves';
+
+    public $searchFactor = 3;
+
+    protected $fillable = ['name', 'description', 'image_id'];
+
+    /**
+     * Get the url for this bookshelf.
+     * @param string|bool $path
+     * @return string
+     */
+    public function getUrl($path = false)
+    {
+        if ($path !== false) {
+            return baseUrl('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
+        }
+        return baseUrl('/shelves/' . urlencode($this->slug));
+    }
+
+    /**
+     * Returns BookShelf cover image, if cover does not exists return default cover image.
+     * @param int $width - Width of the image
+     * @param int $height - Height of the image
+     * @return string
+     */
+    public function getBookCover($width = 440, $height = 250)
+    {
+        $default = baseUrl('/book_default_cover.png');
+        if (!$this->image_id) {
+            return $default;
+        }
+
+        try {
+            $cover = $this->cover ? baseUrl($this->cover->getThumb($width, $height, false)) : $default;
+        } catch (\Exception $err) {
+            $cover = $default;
+        }
+        return $cover;
+    }
+
+    /**
+     * Get the cover image of the book
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function cover()
+    {
+        return $this->belongsTo(Image::class, 'image_id');
+    }
+
+    /**
+     * Get an excerpt of this book's description to the specified length or less.
+     * @param int $length
+     * @return string
+     */
+    public function getExcerpt($length = 100)
+    {
+        $description = $this->description;
+        return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
+    }
+
+    /**
+     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * @return string
+     */
+    public function entityRawQuery()
+    {
+        return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+    }
+}
diff --git a/database/migrations/2018_06_24_115700_create_bookshelves_table.php b/database/migrations/2018_06_24_115700_create_bookshelves_table.php
new file mode 100644
index 000000000..173e9214b
--- /dev/null
+++ b/database/migrations/2018_06_24_115700_create_bookshelves_table.php
@@ -0,0 +1,70 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateBookshelvesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('bookshelves', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name', 200);
+            $table->string('slug', 200);
+            $table->text('description');
+            $table->integer('created_by')->nullable()->default(null);
+            $table->integer('updated_by')->nullable()->default(null);
+            $table->boolean('restricted')->default(false);
+            $table->integer('image_id')->nullable()->default(null);
+            $table->timestamps();
+
+            $table->index('slug');
+            $table->index('created_by');
+            $table->index('updated_by');
+            $table->index('restricted');
+        });
+
+        // Get roles with permissions we need to change
+        $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
+        $editorRole = DB::table('roles')->where('name', '=', 'editor')->first();
+
+        // TODO - Copy existing role permissions from Books
+        $entity = 'BookShelf';
+        $ops = ['View All', 'View Own', 'Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
+        foreach ($ops as $op) {
+            $permId = DB::table('permissions')->insertGetId([
+                'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                'display_name' => $op . ' ' . 'BookShelves',
+                'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
+                'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            ]);
+            // Assign view permission to all current roles
+            DB::table('permission_role')->insert([
+                'role_id' => $adminRoleId,
+                'permission_id' => $permId
+            ]);
+            if ($editorRole !== null) {
+                DB::table('permission_role')->insert([
+                    'role_id' => $editorRole->id,
+                    'permission_id' => $permId
+                ]);
+            }
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('bookshelves');
+    }
+}
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php
index d0a2eb2f7..e5845b495 100644
--- a/resources/views/books/show.blade.php
+++ b/resources/views/books/show.blade.php
@@ -25,7 +25,7 @@
                     <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a>
                     <ul>
                         @if(userCan('book-update', $book))
-                            <li><a href="{{$book->getEditUrl()}}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li>
+                            <li><a href="{{ $book->getUrl('/edit') }}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li>
                             <li><a href="{{ $book->getUrl('/sort') }}" class="text-primary">@icon('sort'){{ trans('common.sort') }}</a></li>
                         @endif
                         @if(userCan('restrictions-manage', $book))

From c3986cedfc992abfe12c8ad596e949dbaf35a093 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sat, 4 Aug 2018 12:45:45 +0100
Subject: [PATCH 02/11] Added shelve icon, improved migration, added role
 permission

Icon is placeholder for now
Migration will now copy permissions from Books to apply to shelves.
Role view updated with visibility on shelve permission
---
 ...08_04_115700_create_bookshelves_table.php} | 45 +++++++++++--------
 resources/assets/icons/bookshelf.svg          |  2 +
 resources/lang/en/entities.php                |  6 +++
 resources/views/base.blade.php                |  1 +
 resources/views/settings/roles/form.blade.php | 18 ++++++++
 5 files changed, 54 insertions(+), 18 deletions(-)
 rename database/migrations/{2018_06_24_115700_create_bookshelves_table.php => 2018_08_04_115700_create_bookshelves_table.php} (53%)
 create mode 100644 resources/assets/icons/bookshelf.svg

diff --git a/database/migrations/2018_06_24_115700_create_bookshelves_table.php b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
similarity index 53%
rename from database/migrations/2018_06_24_115700_create_bookshelves_table.php
rename to database/migrations/2018_08_04_115700_create_bookshelves_table.php
index 173e9214b..f32a1cdfb 100644
--- a/database/migrations/2018_06_24_115700_create_bookshelves_table.php
+++ b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
@@ -30,31 +30,31 @@ class CreateBookshelvesTable extends Migration
             $table->index('restricted');
         });
 
-        // Get roles with permissions we need to change
-        $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
-        $editorRole = DB::table('roles')->where('name', '=', 'editor')->first();
-
-        // TODO - Copy existing role permissions from Books
-        $entity = 'BookShelf';
+        // Copy existing role permissions from Books
         $ops = ['View All', 'View Own', 'Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
         foreach ($ops as $op) {
-            $permId = DB::table('permissions')->insertGetId([
-                'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+            $dbOpName = strtolower(str_replace(' ', '-', $op));
+            $roleIdsWithBookPermission = DB::table('role_permissions')
+                ->leftJoin('permission_role', 'role_permissions.id', '=', 'permission_role.permission_id')
+                ->leftJoin('roles', 'roles.id', '=', 'permission_role.role_id')
+                ->where('role_permissions.name', '=', 'book-' . $dbOpName)->get(['roles.id'])->pluck('id');
+
+            $permId = DB::table('role_permissions')->insertGetId([
+                'name' => 'bookshelf-' . $dbOpName,
                 'display_name' => $op . ' ' . 'BookShelves',
                 'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
                 'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
             ]);
-            // Assign view permission to all current roles
-            DB::table('permission_role')->insert([
-                'role_id' => $adminRoleId,
-                'permission_id' => $permId
-            ]);
-            if ($editorRole !== null) {
-                DB::table('permission_role')->insert([
-                    'role_id' => $editorRole->id,
+
+            $rowsToInsert = $roleIdsWithBookPermission->map(function($roleId) use ($permId) {
+                return [
+                    'role_id' => $roleId,
                     'permission_id' => $permId
-                ]);
-            }
+                ];
+            })->toArray();
+
+            // Assign view permission to all current roles
+            DB::table('permission_role')->insert($rowsToInsert);
         }
     }
 
@@ -65,6 +65,15 @@ class CreateBookshelvesTable extends Migration
      */
     public function down()
     {
+        // Drop created permissions
+        $ops = ['bookshelf-create-all','bookshelf-create-own','bookshelf-delete-all','bookshelf-delete-own','bookshelf-update-all','bookshelf-update-own','bookshelf-view-all','bookshelf-view-own'];
+
+        $permissionIds = DB::table('role_permissions')->whereIn('name', $ops)
+            ->get(['id'])->pluck('id')->toArray();
+        DB::table('permission_role')->whereIn('permission_id', $permissionIds)->delete();
+        DB::table('role_permissions')->whereIn('id', $permissionIds)->delete();
+
+        // Drop shelves table
         Schema::dropIfExists('bookshelves');
     }
 }
diff --git a/resources/assets/icons/bookshelf.svg b/resources/assets/icons/bookshelf.svg
new file mode 100644
index 000000000..03da68f96
--- /dev/null
+++ b/resources/assets/icons/bookshelf.svg
@@ -0,0 +1,2 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M1.088 2.566h17.42v17.42H1.088z" fill="none"/><path d="M4 20.058h15.892V22H4z"/><path d="M2.902 1.477h17.42v17.42H2.903z" fill="none"/><g><path d="M6.658 3.643V18h-2.38V3.643zM11.326 3.643V18H8.947V3.643zM14.722 3.856l5.613 13.214-2.19.93-5.613-13.214z"/></g></svg>
+
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 93025ffd4..834b977e7 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -64,6 +64,12 @@ return [
     'search_set_date' => 'Set Date',
     'search_update' => 'Update Search',
 
+    /**
+     * Shelves
+     */
+    'shelves' => 'Shelves',
+    'shelves_long' => 'BookShelves',
+
     /**
      * Books
      */
diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php
index 8f6c2eb46..93517ef6f 100644
--- a/resources/views/base.blade.php
+++ b/resources/views/base.blade.php
@@ -52,6 +52,7 @@
                             </form>
                         </div>
                         <div class="links text-center">
+                            <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
                             <a href="{{ baseUrl('/books') }}">@icon('book'){{ trans('entities.books') }}</a>
                             @if(signedInUser() && userCan('settings-manage'))
                                 <a href="{{ baseUrl('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
index 6a8e27487..44cdbb3c0 100644
--- a/resources/views/settings/roles/form.blade.php
+++ b/resources/views/settings/roles/form.blade.php
@@ -44,6 +44,24 @@
                             <th width="20%">{{ trans('common.edit') }}</th>
                             <th width="20%">{{ trans('common.delete') }}</th>
                         </tr>
+                        <tr>
+                            <td>{{ trans('entities.shelves_long') }}</td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-create-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-view-own']) {{ trans('settings.role_own') }}</label>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-view-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-update-own']) {{ trans('settings.role_own') }}</label>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-update-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-delete-own']) {{ trans('settings.role_own') }}</label>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-delete-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                        </tr>
                         <tr>
                             <td>{{ trans('entities.books') }}</td>
                             <td>

From b89411c108f08248215421a34bbf66bdf072d049 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 27 Aug 2018 14:18:09 +0100
Subject: [PATCH 03/11] Copied book content, Added create routes

Added view control
Added pivot table for books relation
Added control to assign books
---
 app/{BookShelf.php => Bookshelf.php}          |  11 +-
 app/Http/Controllers/BookshelfController.php  | 341 ++++++++++++++++++
 app/Http/Controllers/UserController.php       |  25 +-
 app/Repos/EntityRepo.php                      |  30 ++
 app/Services/PermissionService.php            |   9 +-
 ..._08_04_115700_create_bookshelves_table.php |  13 +
 package-lock.json                             |  15 +
 package.json                                  |   1 +
 resources/assets/js/components/index.js       |   1 +
 resources/assets/js/components/shelf-sort.js  |  41 +++
 resources/assets/sass/_grid.scss              |  35 ++
 resources/assets/sass/_lists.scss             |  29 --
 resources/assets/sass/_text.scss              |   8 +
 resources/assets/sass/_variables.scss         |   1 +
 resources/assets/sass/styles.scss             |  20 +
 resources/lang/en/entities.php                |  11 +
 resources/views/base.blade.php                |   4 +-
 resources/views/books/list.blade.php          |   8 +-
 resources/views/books/view-toggle.blade.php   |   2 +-
 .../views/partials/entity-list.blade.php      |   2 +
 .../views/shelves/_breadcrumbs.blade.php      |   3 +
 resources/views/shelves/create.blade.php      |  31 ++
 resources/views/shelves/delete.blade.php      |  30 ++
 resources/views/shelves/edit.blade.php        |  24 ++
 resources/views/shelves/export.blade.php      |  80 ++++
 resources/views/shelves/form.blade.php        |  84 +++++
 resources/views/shelves/grid-item.blade.php   |  18 +
 resources/views/shelves/index.blade.php       |  48 +++
 resources/views/shelves/list-item.blade.php   |  10 +
 resources/views/shelves/list.blade.php        |  26 ++
 .../views/shelves/restrictions.blade.php      |  21 ++
 resources/views/shelves/show.blade.php        | 135 +++++++
 resources/views/shelves/view-toggle.blade.php |  10 +
 routes/web.php                                |   8 +
 34 files changed, 1092 insertions(+), 43 deletions(-)
 rename app/{BookShelf.php => Bookshelf.php} (88%)
 create mode 100644 app/Http/Controllers/BookshelfController.php
 create mode 100644 resources/assets/js/components/shelf-sort.js
 create mode 100644 resources/views/shelves/_breadcrumbs.blade.php
 create mode 100644 resources/views/shelves/create.blade.php
 create mode 100644 resources/views/shelves/delete.blade.php
 create mode 100644 resources/views/shelves/edit.blade.php
 create mode 100644 resources/views/shelves/export.blade.php
 create mode 100644 resources/views/shelves/form.blade.php
 create mode 100644 resources/views/shelves/grid-item.blade.php
 create mode 100644 resources/views/shelves/index.blade.php
 create mode 100644 resources/views/shelves/list-item.blade.php
 create mode 100644 resources/views/shelves/list.blade.php
 create mode 100644 resources/views/shelves/restrictions.blade.php
 create mode 100644 resources/views/shelves/show.blade.php
 create mode 100644 resources/views/shelves/view-toggle.blade.php

diff --git a/app/BookShelf.php b/app/Bookshelf.php
similarity index 88%
rename from app/BookShelf.php
rename to app/Bookshelf.php
index 47f873bcd..1e33e31f6 100644
--- a/app/BookShelf.php
+++ b/app/Bookshelf.php
@@ -1,7 +1,7 @@
 <?php namespace BookStack;
 
 
-class BookShelf extends Entity
+class Bookshelf extends Entity
 {
     protected $table = 'bookshelves';
 
@@ -9,6 +9,15 @@ class BookShelf extends Entity
 
     protected $fillable = ['name', 'description', 'image_id'];
 
+    /**
+     * Get the books in this shelf.
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     */
+    public function books()
+    {
+        return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id');
+    }
+
     /**
      * Get the url for this bookshelf.
      * @param string|bool $path
diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
new file mode 100644
index 000000000..a1c56f29a
--- /dev/null
+++ b/app/Http/Controllers/BookshelfController.php
@@ -0,0 +1,341 @@
+<?php namespace BookStack\Http\Controllers;
+
+use Activity;
+use BookStack\Book;
+use BookStack\Repos\EntityRepo;
+use BookStack\Repos\UserRepo;
+use BookStack\Services\ExportService;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Views;
+
+class BookshelfController extends Controller
+{
+
+    protected $entityRepo;
+    protected $userRepo;
+    protected $exportService;
+
+    /**
+     * BookController constructor.
+     * @param EntityRepo $entityRepo
+     * @param UserRepo $userRepo
+     * @param ExportService $exportService
+     */
+    public function __construct(EntityRepo $entityRepo, UserRepo $userRepo, ExportService $exportService)
+    {
+        $this->entityRepo = $entityRepo;
+        $this->userRepo = $userRepo;
+        $this->exportService = $exportService;
+        parent::__construct();
+    }
+
+    /**
+     * Display a listing of the book.
+     * @return Response
+     */
+    public function index()
+    {
+        $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18);
+        $recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('bookshelf', 4, 0) : false;
+        $popular = $this->entityRepo->getPopular('bookshelf', 4, 0);
+        $new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0);
+        $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+        $this->setPageTitle(trans('entities.shelves'));
+        return view('shelves/index', [
+            'shelves' => $shelves,
+            'recents' => $recents,
+            'popular' => $popular,
+            'new' => $new,
+            'shelvesViewType' => $shelvesViewType
+        ]);
+    }
+
+    /**
+     * Show the form for creating a new bookshelf.
+     * @return Response
+     */
+    public function create()
+    {
+        $this->checkPermission('bookshelf-create-all');
+        $this->setPageTitle(trans('entities.shelves_create'));
+        $books = $this->entityRepo->getAll('book', false, 'update');
+        return view('shelves/create', ['books' => $books]);
+    }
+
+    /**
+     * Store a newly created book in storage.
+     * @param  Request $request
+     * @return Response
+     */
+    public function store(Request $request)
+    {
+        $this->checkPermission('bookshelf-create-all');
+        $this->validate($request, [
+            'name' => 'required|string|max:255',
+            'description' => 'string|max:1000',
+        ]);
+
+        $bookshelf = $this->entityRepo->createFromInput('bookshelf', $request->all());
+        $this->entityRepo->updateShelfBooks($bookshelf, $request->get('books', ''));
+        Activity::add($bookshelf, 'bookshelf_create');
+
+        return redirect($bookshelf->getUrl());
+    }
+
+//
+//    /**
+//     * Display the specified book.
+//     * @param $slug
+//     * @return Response
+//     */
+//    public function show($slug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $slug);
+//        $this->checkOwnablePermission('book-view', $book);
+//        $bookChildren = $this->entityRepo->getBookChildren($book);
+//        Views::add($book);
+//        $this->setPageTitle($book->getShortName());
+//        return view('books/show', [
+//            'book' => $book,
+//            'current' => $book,
+//            'bookChildren' => $bookChildren,
+//            'activity' => Activity::entityActivity($book, 20, 0)
+//        ]);
+//    }
+//
+//    /**
+//     * Show the form for editing the specified book.
+//     * @param $slug
+//     * @return Response
+//     */
+//    public function edit($slug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $slug);
+//        $this->checkOwnablePermission('book-update', $book);
+//        $this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
+//        return view('books/edit', ['book' => $book, 'current' => $book]);
+//    }
+//
+//    /**
+//     * Update the specified book in storage.
+//     * @param  Request $request
+//     * @param          $slug
+//     * @return Response
+//     */
+//    public function update(Request $request, $slug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $slug);
+//        $this->checkOwnablePermission('book-update', $book);
+//        $this->validate($request, [
+//            'name' => 'required|string|max:255',
+//            'description' => 'string|max:1000'
+//        ]);
+//         $book = $this->entityRepo->updateFromInput('book', $book, $request->all());
+//         Activity::add($book, 'book_update', $book->id);
+//         return redirect($book->getUrl());
+//    }
+//
+//    /**
+//     * Shows the page to confirm deletion
+//     * @param $bookSlug
+//     * @return \Illuminate\View\View
+//     */
+//    public function showDelete($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $this->checkOwnablePermission('book-delete', $book);
+//        $this->setPageTitle(trans('entities.books_delete_named', ['bookName'=>$book->getShortName()]));
+//        return view('books/delete', ['book' => $book, 'current' => $book]);
+//    }
+//
+//    /**
+//     * Shows the view which allows pages to be re-ordered and sorted.
+//     * @param string $bookSlug
+//     * @return \Illuminate\View\View
+//     */
+//    public function sort($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $this->checkOwnablePermission('book-update', $book);
+//        $bookChildren = $this->entityRepo->getBookChildren($book, true);
+//        $books = $this->entityRepo->getAll('book', false, 'update');
+//        $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
+//        return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]);
+//    }
+//
+//    /**
+//     * Shows the sort box for a single book.
+//     * Used via AJAX when loading in extra books to a sort.
+//     * @param $bookSlug
+//     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+//     */
+//    public function getSortItem($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $bookChildren = $this->entityRepo->getBookChildren($book);
+//        return view('books/sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
+//    }
+//
+//    /**
+//     * Saves an array of sort mapping to pages and chapters.
+//     * @param  string $bookSlug
+//     * @param Request $request
+//     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+//     */
+//    public function saveSort($bookSlug, Request $request)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $this->checkOwnablePermission('book-update', $book);
+//
+//        // Return if no map sent
+//        if (!$request->filled('sort-tree')) {
+//            return redirect($book->getUrl());
+//        }
+//
+//        // Sort pages and chapters
+//        $sortMap = collect(json_decode($request->get('sort-tree')));
+//        $bookIdsInvolved = collect([$book->id]);
+//
+//        // Load models into map
+//        $sortMap->each(function ($mapItem) use ($bookIdsInvolved) {
+//            $mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter');
+//            $mapItem->model = $this->entityRepo->getById($mapItem->type, $mapItem->id);
+//            // Store source and target books
+//            $bookIdsInvolved->push(intval($mapItem->model->book_id));
+//            $bookIdsInvolved->push(intval($mapItem->book));
+//        });
+//
+//        // Get the books involved in the sort
+//        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
+//        $booksInvolved = $this->entityRepo->book->newQuery()->whereIn('id', $bookIdsInvolved)->get();
+//        // Throw permission error if invalid ids or inaccessible books given.
+//        if (count($bookIdsInvolved) !== count($booksInvolved)) {
+//            $this->showPermissionError();
+//        }
+//        // Check permissions of involved books
+//        $booksInvolved->each(function (Book $book) {
+//             $this->checkOwnablePermission('book-update', $book);
+//        });
+//
+//        // Perform the sort
+//        $sortMap->each(function ($mapItem) {
+//            $model = $mapItem->model;
+//
+//            $priorityChanged = intval($model->priority) !== intval($mapItem->sort);
+//            $bookChanged = intval($model->book_id) !== intval($mapItem->book);
+//            $chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter;
+//
+//            if ($bookChanged) {
+//                $this->entityRepo->changeBook($mapItem->type, $mapItem->book, $model);
+//            }
+//            if ($chapterChanged) {
+//                $model->chapter_id = intval($mapItem->parentChapter);
+//                $model->save();
+//            }
+//            if ($priorityChanged) {
+//                $model->priority = intval($mapItem->sort);
+//                $model->save();
+//            }
+//        });
+//
+//        // Rebuild permissions and add activity for involved books.
+//        $booksInvolved->each(function (Book $book) {
+//            $this->entityRepo->buildJointPermissionsForBook($book);
+//            Activity::add($book, 'book_sort', $book->id);
+//        });
+//
+//        return redirect($book->getUrl());
+//    }
+//
+//    /**
+//     * Remove the specified book from storage.
+//     * @param $bookSlug
+//     * @return Response
+//     */
+//    public function destroy($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $this->checkOwnablePermission('book-delete', $book);
+//        Activity::addMessage('book_delete', 0, $book->name);
+//        $this->entityRepo->destroyBook($book);
+//        return redirect('/books');
+//    }
+//
+//    /**
+//     * Show the Restrictions view.
+//     * @param $bookSlug
+//     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+//     */
+//    public function showRestrict($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $this->checkOwnablePermission('restrictions-manage', $book);
+//        $roles = $this->userRepo->getRestrictableRoles();
+//        return view('books/restrictions', [
+//            'book' => $book,
+//            'roles' => $roles
+//        ]);
+//    }
+//
+//    /**
+//     * Set the restrictions for this book.
+//     * @param $bookSlug
+//     * @param $bookSlug
+//     * @param Request $request
+//     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+//     */
+//    public function restrict($bookSlug, Request $request)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $this->checkOwnablePermission('restrictions-manage', $book);
+//        $this->entityRepo->updateEntityPermissionsFromRequest($request, $book);
+//        session()->flash('success', trans('entities.books_permissions_updated'));
+//        return redirect($book->getUrl());
+//    }
+//
+//    /**
+//     * Export a book as a PDF file.
+//     * @param string $bookSlug
+//     * @return mixed
+//     */
+//    public function exportPdf($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $pdfContent = $this->exportService->bookToPdf($book);
+//        return response()->make($pdfContent, 200, [
+//            'Content-Type'        => 'application/octet-stream',
+//            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.pdf'
+//        ]);
+//    }
+//
+//    /**
+//     * Export a book as a contained HTML file.
+//     * @param string $bookSlug
+//     * @return mixed
+//     */
+//    public function exportHtml($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $htmlContent = $this->exportService->bookToContainedHtml($book);
+//        return response()->make($htmlContent, 200, [
+//            'Content-Type'        => 'application/octet-stream',
+//            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.html'
+//        ]);
+//    }
+//
+//    /**
+//     * Export a book as a plain text file.
+//     * @param $bookSlug
+//     * @return mixed
+//     */
+//    public function exportPlainText($bookSlug)
+//    {
+//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
+//        $htmlContent = $this->exportService->bookToPlainText($book);
+//        return response()->make($htmlContent, 200, [
+//            'Content-Type'        => 'application/octet-stream',
+//            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.txt'
+//        ]);
+//    }
+}
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index d50baa86f..f6bd13e6f 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -252,7 +252,7 @@ class UserController extends Controller
             return $this->currentUser->id == $id;
         });
 
-        $viewType = $request->get('book_view_type');
+        $viewType = $request->get('view_type');
         if (!in_array($viewType, ['grid', 'list'])) {
             $viewType = 'list';
         }
@@ -262,4 +262,27 @@ class UserController extends Controller
 
         return redirect()->back(302, [], "/settings/users/$id");
     }
+
+    /**
+     * Update the user's preferred shelf-list display setting.
+     * @param $id
+     * @param Request $request
+     * @return \Illuminate\Http\RedirectResponse
+     */
+    public function switchShelfView($id, Request $request)
+    {
+        $this->checkPermissionOr('users-manage', function () use ($id) {
+            return $this->currentUser->id == $id;
+        });
+
+        $viewType = $request->get('view_type');
+        if (!in_array($viewType, ['grid', 'list'])) {
+            $viewType = 'list';
+        }
+
+        $user = $this->user->findOrFail($id);
+        setting()->putUser($user, 'bookshelves_view_type', $viewType);
+
+        return redirect()->back(302, [], "/settings/users/$id");
+    }
 }
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index bdd1e37b1..ea7fc4882 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Repos;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\Exceptions\NotFoundException;
@@ -18,6 +19,10 @@ use Illuminate\Support\Collection;
 
 class EntityRepo
 {
+    /**
+     * @var Bookshelf
+     */
+    public $bookshelf;
 
     /**
      * @var Book $book
@@ -67,6 +72,7 @@ class EntityRepo
 
     /**
      * EntityRepo constructor.
+     * @param Bookshelf $bookshelf
      * @param Book $book
      * @param Chapter $chapter
      * @param Page $page
@@ -77,6 +83,7 @@ class EntityRepo
      * @param SearchService $searchService
      */
     public function __construct(
+        Bookshelf $bookshelf,
         Book $book,
         Chapter $chapter,
         Page $page,
@@ -86,11 +93,13 @@ class EntityRepo
         TagRepo $tagRepo,
         SearchService $searchService
     ) {
+        $this->bookshelf = $bookshelf;
         $this->book = $book;
         $this->chapter = $chapter;
         $this->page = $page;
         $this->pageRevision = $pageRevision;
         $this->entities = [
+            'bookshelf' => $this->bookshelf,
             'page' => $this->page,
             'chapter' => $this->chapter,
             'book' => $this->book
@@ -533,6 +542,23 @@ class EntityRepo
         return $entityModel;
     }
 
+    /**
+     * Sync the books assigned to a shelf from a comma-separated list
+     * of book IDs.
+     * @param Bookshelf $shelf
+     * @param string $books
+     */
+    public function updateShelfBooks(Bookshelf $shelf, string $books)
+    {
+        $ids = explode(',', $books);
+        if (count($ids) === 0) {
+            return;
+        }
+
+        $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
+        $shelf->books()->sync($bookIds);
+    }
+
     /**
      * Change the book that an entity belongs to.
      * @param string $type
@@ -1157,6 +1183,8 @@ class EntityRepo
     /**
      * Destroy the provided book and all its child entities.
      * @param Book $book
+     * @throws NotifyException
+     * @throws \Throwable
      */
     public function destroyBook(Book $book)
     {
@@ -1177,6 +1205,7 @@ class EntityRepo
     /**
      * Destroy a chapter and its relations.
      * @param Chapter $chapter
+     * @throws \Throwable
      */
     public function destroyChapter(Chapter $chapter)
     {
@@ -1198,6 +1227,7 @@ class EntityRepo
      * Destroy a given page along with its dependencies.
      * @param Page $page
      * @throws NotifyException
+     * @throws \Throwable
      */
     public function destroyPage(Page $page)
     {
diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php
index 0dd316b34..428cb895f 100644
--- a/app/Services/PermissionService.php
+++ b/app/Services/PermissionService.php
@@ -204,6 +204,7 @@ class PermissionService
     /**
      * Rebuild the entity jointPermissions for a particular entity.
      * @param Entity $entity
+     * @throws \Throwable
      */
     public function buildJointPermissionsForEntity(Entity $entity)
     {
@@ -214,7 +215,9 @@ class PermissionService
             return;
         }
 
-        $entities[] = $entity->book;
+        if ($entity->book) {
+            $entities[] = $entity->book;
+        }
 
         if ($entity->isA('page') && $entity->chapter_id) {
             $entities[] = $entity->chapter;
@@ -226,13 +229,13 @@ class PermissionService
             }
         }
 
-        $this->deleteManyJointPermissionsForEntities($entities);
         $this->buildJointPermissionsForEntities(collect($entities));
     }
 
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
      * @param Collection $entities
+     * @throws \Throwable
      */
     public function buildJointPermissionsForEntities(Collection $entities)
     {
@@ -412,7 +415,7 @@ class PermissionService
             return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
         }
 
-        if ($entity->isA('book')) {
+        if ($entity->isA('book') || $entity->isA('bookshelf')) {
             return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
         }
 
diff --git a/database/migrations/2018_08_04_115700_create_bookshelves_table.php b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
index f32a1cdfb..c7840e1a1 100644
--- a/database/migrations/2018_08_04_115700_create_bookshelves_table.php
+++ b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
@@ -30,6 +30,18 @@ class CreateBookshelvesTable extends Migration
             $table->index('restricted');
         });
 
+        Schema::create('bookshelves_books', function (Blueprint $table) {
+            $table->integer('bookshelf_id')->unsigned();
+            $table->integer('book_id')->unsigned();
+
+            $table->foreign('bookshelf_id')->references('id')->on('bookshelves')
+                ->onUpdate('cascade')->onDelete('cascade');
+            $table->foreign('book_id')->references('id')->on('books')
+                ->onUpdate('cascade')->onDelete('cascade');
+
+            $table->primary(['bookshelf_id', 'book_id']);
+        });
+
         // Copy existing role permissions from Books
         $ops = ['View All', 'View Own', 'Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
         foreach ($ops as $op) {
@@ -75,5 +87,6 @@ class CreateBookshelvesTable extends Migration
 
         // Drop shelves table
         Schema::dropIfExists('bookshelves');
+        Schema::dropIfExists('bookshelves_books');
     }
 }
diff --git a/package-lock.json b/package-lock.json
index ec4da5ce2..f8c43993b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5857,6 +5857,21 @@
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz",
       "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg=="
     },
+    "jquery-sortable": {
+      "version": "0.9.13",
+      "resolved": "https://registry.npmjs.org/jquery-sortable/-/jquery-sortable-0.9.13.tgz",
+      "integrity": "sha1-HL+2VQE6B0c3BXHwbiL1JKAP+6I=",
+      "requires": {
+        "jquery": "^2.1.2"
+      },
+      "dependencies": {
+        "jquery": {
+          "version": "2.2.4",
+          "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",
+          "integrity": "sha1-LInWiJterFIqfuoywUUhVZxsvwI="
+        }
+      }
+    },
     "js-base64": {
       "version": "2.4.3",
       "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.3.tgz",
diff --git a/package.json b/package.json
index 12d972cf9..58f2dad5e 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
     "codemirror": "^5.26.0",
     "dropzone": "^5.4.0",
     "jquery": "^3.3.1",
+    "jquery-sortable": "^0.9.13",
     "markdown-it": "^8.3.1",
     "markdown-it-task-lists": "^2.0.0",
     "vue": "^2.2.6",
diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js
index aa69f3265..e1aef032c 100644
--- a/resources/assets/js/components/index.js
+++ b/resources/assets/js/components/index.js
@@ -18,6 +18,7 @@ let componentMapping = {
     'collapsible': require('./collapsible'),
     'toggle-switch': require('./toggle-switch'),
     'page-display': require('./page-display'),
+    'shelf-sort': require('./shelf-sort'),
 };
 
 window.components = {};
diff --git a/resources/assets/js/components/shelf-sort.js b/resources/assets/js/components/shelf-sort.js
new file mode 100644
index 000000000..91713ab41
--- /dev/null
+++ b/resources/assets/js/components/shelf-sort.js
@@ -0,0 +1,41 @@
+
+class ShelfSort {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.sortGroup = this.initSortable();
+        this.input = document.getElementById('books-input');
+    }
+
+    initSortable() {
+        const sortable = require('jquery-sortable');
+        const placeHolderContent = this.getPlaceholderHTML();
+
+        return $('.scroll-box').sortable({
+            group: 'shelf-books',
+            exclude: '.instruction,.scroll-box-placeholder',
+            containerSelector: 'div.scroll-box',
+            itemSelector: '.scroll-box-item',
+            placeholder: placeHolderContent,
+            onDrop: this.onDrop.bind(this)
+        });
+    }
+
+    onDrop($item, container, _super) {
+        const data = this.sortGroup.sortable('serialize').get();
+        this.input.value = data[0].map(item => item.id).join(',');
+        _super($item, container);
+    }
+
+    getPlaceholderHTML() {
+        const placeHolder = document.querySelector('.scroll-box-placeholder');
+        placeHolder.style.display = 'block';
+        const placeHolderContent = placeHolder.outerHTML;
+        placeHolder.style.display = 'none';
+        return placeHolderContent;
+    }
+
+
+}
+
+module.exports = ShelfSort;
\ No newline at end of file
diff --git a/resources/assets/sass/_grid.scss b/resources/assets/sass/_grid.scss
index 8f15153b5..0e1f85ce6 100644
--- a/resources/assets/sass/_grid.scss
+++ b/resources/assets/sass/_grid.scss
@@ -192,8 +192,26 @@ div[class^="col-"] img {
   flex-direction: column;
   border: 1px solid #ddd;
   min-width: 100px;
+  h2 {
+    width: 100%;
+    font-size: 1.5em;
+    margin: 0 0 10px;
+  }
+  h2 a {
+    display: block;
+    width: 100%;
+    line-height: 1.2;
+    text-decoration: none;
+  }
+  p {
+    font-size: .85em;
+    margin: 0;
+    line-height: 1.6em;
+  }
   .grid-card-content {
     flex: 1;
+    border-top: 0;
+    border-bottom-width: 2px;
   }
   .grid-card-content, .grid-card-footer {
     padding: $-l;
@@ -203,6 +221,23 @@ div[class^="col-"] img {
   }
 }
 
+.book-grid-item .grid-card-content h2 a  {
+    color: $color-book;
+    fill: $color-book;
+}
+
+.bookshelf-grid-item .grid-card-content h2 a  {
+  color: $color-bookshelf;
+  fill: $color-bookshelf;
+}
+
+.book-grid-item .grid-card-footer {
+  p.small {
+    font-size: .8em;
+    margin: 0;
+  }
+}
+
 @include smaller-than($m) {
   .grid.third {
     grid-template-columns: 1fr 1fr;
diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss
index 3338b3938..0afed4b36 100644
--- a/resources/assets/sass/_lists.scss
+++ b/resources/assets/sass/_lists.scss
@@ -408,32 +408,3 @@ ul.pagination {
   }
 }
 
-.book-grid-item .grid-card-content {
-  border-top: 0;
-  border-bottom-width: 2px;
-  h2 {
-    width: 100%;
-    font-size: 1.5em;
-    margin: 0 0 10px;
-  }
-  h2 a {
-    display: block;
-    width: 100%;
-    line-height: 1.2;
-    color: #009688;;
-    fill: #009688;;
-    text-decoration: none;
-  }
-  p {
-    font-size: .85em;
-    margin: 0;
-    line-height: 1.6em;
-  }
-}
-
-.book-grid-item .grid-card-footer {
-  p.small {
-    font-size: .8em;
-    margin: 0;
-  }
-}
\ No newline at end of file
diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss
index da11846d8..63a91c968 100644
--- a/resources/assets/sass/_text.scss
+++ b/resources/assets/sass/_text.scss
@@ -281,6 +281,14 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
   }
 }
 
+.text-bookshelf {
+  color: $color-bookshelf;
+  fill: $color-bookshelf;
+  &:hover {
+    color: $color-bookshelf;
+    fill: $color-bookshelf;
+  }
+}
 .text-book {
   color: $color-book;
   fill: $color-book;
diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss
index e62d37efe..006d1b3f0 100644
--- a/resources/assets/sass/_variables.scss
+++ b/resources/assets/sass/_variables.scss
@@ -47,6 +47,7 @@ $warning: $secondary;
 $primary-faded: rgba(21, 101, 192, 0.15);
 
 // Item Colors
+$color-bookshelf: #af5a5a;
 $color-book: #009688;
 $color-chapter: #ef7c3c;
 $color-page: $primary;
diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss
index 0b2dfbf75..ab5972cbd 100644
--- a/resources/assets/sass/styles.scss
+++ b/resources/assets/sass/styles.scss
@@ -206,6 +206,12 @@ $btt-size: 40px;
     transition: all ease-in-out 120ms;
     cursor: pointer;
   }
+  &.compact {
+    font-size: 10px;
+    .entity-item-snippet {
+      display: none;
+    }
+  }
 }
 
 .entity-list-item.selected {
@@ -214,6 +220,20 @@ $btt-size: 40px;
   }
 }
 
+.scroll-box {
+  max-height: 250px;
+  overflow-y: scroll;
+  border: 1px solid #DDD;
+  border-radius: 3px;
+  .scroll-box-item {
+    padding: $-xs $-m;
+    border-bottom: 1px solid #DDD;
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
 .center-box {
   margin: $-xxl auto 0 auto;
   width: 420px;
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 834b977e7..c744c5557 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -69,6 +69,16 @@ return [
      */
     'shelves' => 'Shelves',
     'shelves_long' => 'BookShelves',
+    'shelves_empty' => 'No shelves have been created',
+    'shelves_create' => 'Create New Shelf',
+    'shelves_popular' => 'Popular Shelves',
+    'shelves_new' => 'New Shelves',
+    'shelves_popular_empty' => 'The most popular shelves will appear here.',
+    'shelves_new_empty' => 'The most recently created shelves will appear here.',
+    'shelves_save' => 'Save Shelf',
+    'shelves_books' => 'Books on this shelf',
+    'shelves_add_books' => 'Add books to this shelf',
+    'shelves_drag_books' => 'Drag books here to add them to this shelf',
 
     /**
      * Books
@@ -212,6 +222,7 @@ return [
     'page_tags' => 'Page Tags',
     'chapter_tags' => 'Chapter Tags',
     'book_tags' => 'Book Tags',
+    'shelf_tags' => 'Shelf Tags',
     'tag' => 'Tag',
     'tags' =>  'Tags',
     'tag_value' => 'Tag Value (Optional)',
diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php
index 93517ef6f..93ee6cdc6 100644
--- a/resources/views/base.blade.php
+++ b/resources/views/base.blade.php
@@ -33,7 +33,7 @@
     <header id="header">
         <div class="container fluid">
             <div class="row">
-                <div class="col-sm-4">
+                <div class="col-sm-4 col-md-3">
                     <a href="{{ baseUrl('/') }}" class="logo">
                         @if(setting('app-logo', '') !== 'none')
                             <img class="logo-image" src="{{ setting('app-logo', '') === '' ? baseUrl('/logo.png') : baseUrl(setting('app-logo', '')) }}" alt="Logo">
@@ -43,7 +43,7 @@
                         @endif
                     </a>
                 </div>
-                <div class="col-sm-8">
+                <div class="col-sm-8 col-md-9">
                     <div class="float right">
                         <div class="header-search">
                             <form action="{{ baseUrl('/search') }}" method="GET" class="search-box">
diff --git a/resources/views/books/list.blade.php b/resources/views/books/list.blade.php
index 1c2056a79..9459cc008 100644
--- a/resources/views/books/list.blade.php
+++ b/resources/views/books/list.blade.php
@@ -1,9 +1,5 @@
 
-@if($booksViewType === 'list')
-    <div class="container small">
-@else
-    <div class="container">
-@endif
+<div class="container{{ $booksViewType === 'list' ? ' small' : '' }}">
     <h1>{{ trans('entities.books') }}</h1>
     @if(count($books) > 0)
         @if($booksViewType === 'list')
@@ -25,7 +21,7 @@
     @else
         <p class="text-muted">{{ trans('entities.books_empty') }}</p>
         @if(userCan('books-create-all'))
-            <a href="{{ baseUrl("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_one_now') }}</a>
+            <a href="{{ baseUrl("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_now') }}</a>
         @endif
     @endif
 </div>
\ No newline at end of file
diff --git a/resources/views/books/view-toggle.blade.php b/resources/views/books/view-toggle.blade.php
index 61df7ab8d..63eb9b9d3 100644
--- a/resources/views/books/view-toggle.blade.php
+++ b/resources/views/books/view-toggle.blade.php
@@ -1,7 +1,7 @@
 <form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-book-view") }}" method="POST" class="inline">
     {!! csrf_field() !!}
     {!! method_field('PATCH') !!}
-    <input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="book_view_type">
+    <input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="view_type">
     @if ($booksViewType === 'list')
         <button type="submit" class="text-pos text-button">@icon('grid'){{ trans('common.grid_view') }}</button>
     @else
diff --git a/resources/views/partials/entity-list.blade.php b/resources/views/partials/entity-list.blade.php
index c90b953ea..371f38d71 100644
--- a/resources/views/partials/entity-list.blade.php
+++ b/resources/views/partials/entity-list.blade.php
@@ -8,6 +8,8 @@
                 @include('books/list-item', ['book' => $entity])
             @elseif($entity->isA('chapter'))
                 @include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true])
+            @elseif($entity->isA('bookshelf'))
+                @include('shelves/list-item', ['bookshelf' => $entity])
             @endif
 
             @if($index !== count($entities) - 1)
diff --git a/resources/views/shelves/_breadcrumbs.blade.php b/resources/views/shelves/_breadcrumbs.blade.php
new file mode 100644
index 000000000..e4ecc36c6
--- /dev/null
+++ b/resources/views/shelves/_breadcrumbs.blade.php
@@ -0,0 +1,3 @@
+<div class="breadcrumbs">
+    <a href="{{$book->getUrl()}}" class="text-book text-button">@icon('book'){{ $book->getShortName() }}</a>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/create.blade.php b/resources/views/shelves/create.blade.php
new file mode 100644
index 000000000..aaff08974
--- /dev/null
+++ b/resources/views/shelves/create.blade.php
@@ -0,0 +1,31 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-8 faded">
+        <div class="breadcrumbs">
+            <a href="{{ baseUrl('/shelves') }}" class="text-button">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
+            <span class="sep">&raquo;</span>
+            <a href="{{ baseUrl('/create-shelf') }}" class="text-button">@icon('add'){{ trans('entities.shelves_create') }}</a>
+        </div>
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('add') {{ trans('entities.shelves_create') }}</h3>
+            <div class="body">
+                <form action="{{ baseUrl("/shelves") }}" method="POST" enctype="multipart/form-data">
+                    @include('shelves/form')
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <p class="margin-top large"><br></p>
+
+    @include('components.image-manager', ['imageType' => 'cover'])
+
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/delete.blade.php b/resources/views/shelves/delete.blade.php
new file mode 100644
index 000000000..0ac98e895
--- /dev/null
+++ b/resources/views/shelves/delete.blade.php
@@ -0,0 +1,30 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-12 faded">
+        @include('books._breadcrumbs', ['book' => $book])
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('delete') {{ trans('entities.books_delete') }}</h3>
+            <div class="body">
+                <p>{{ trans('entities.books_delete_explain', ['bookName' => $book->name]) }}</p>
+                <p class="text-neg">{{ trans('entities.books_delete_confirmation') }}</p>
+
+                <form action="{{$book->getUrl()}}" method="POST">
+                    {!! csrf_field() !!}
+                    <input type="hidden" name="_method" value="DELETE">
+                    <a href="{{$book->getUrl()}}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button neg">{{ trans('common.confirm') }}</button>
+                </form>
+            </div>
+        </div>
+
+    </div>
+
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/edit.blade.php b/resources/views/shelves/edit.blade.php
new file mode 100644
index 000000000..cb1ffc461
--- /dev/null
+++ b/resources/views/shelves/edit.blade.php
@@ -0,0 +1,24 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-12 faded">
+        @include('books._breadcrumbs', ['book' => $book])
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('edit') {{ trans('entities.books_edit') }}</h3>
+            <div class="body">
+                <form action="{{ $book->getUrl() }}" method="POST">
+                    <input type="hidden" name="_method" value="PUT">
+                    @include('books/form', ['model' => $book])
+                </form>
+            </div>
+        </div>
+    </div>
+@include('components.image-manager', ['imageType' => 'cover'])
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/export.blade.php b/resources/views/shelves/export.blade.php
new file mode 100644
index 000000000..462ad7991
--- /dev/null
+++ b/resources/views/shelves/export.blade.php
@@ -0,0 +1,80 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+    <title>{{ $book->name }}</title>
+
+    <style>
+        @if (!app()->environment('testing'))
+        {!! file_get_contents(public_path('/dist/export-styles.css')) !!}
+        @endif
+        .page-break {
+            page-break-after: always;
+        }
+        .chapter-hint {
+            color: #888;
+            margin-top: 32px;
+        }
+        .chapter-hint + h1 {
+            margin-top: 0;
+        }
+        ul.contents ul li {
+            list-style: circle;
+        }
+        @media screen {
+            .page-break {
+                border-top: 1px solid #DDD;
+            }
+        }
+    </style>
+    @yield('head')
+</head>
+<body>
+<div class="container">
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <div class="page-content">
+
+                <h1 style="font-size: 4.8em">{{$book->name}}</h1>
+
+                <p>{{ $book->description }}</p>
+
+                @if(count($bookChildren) > 0)
+                <ul class="contents">
+                    @foreach($bookChildren as $bookChild)
+                        <li><a href="#{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</a></li>
+                        @if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
+                            <ul>
+                                @foreach($bookChild->pages as $page)
+                                    <li><a href="#page-{{$page->id}}">{{ $page->name }}</a></li>
+                                @endforeach
+                            </ul>
+                        @endif
+                    @endforeach
+                </ul>
+                @endif
+
+                @foreach($bookChildren as $bookChild)
+                    <div class="page-break"></div>
+                    <h1 id="{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</h1>
+                    @if($bookChild->isA('chapter'))
+                        <p>{{ $bookChild->description }}</p>
+                        @if(count($bookChild->pages) > 0)
+                            @foreach($bookChild->pages as $page)
+                                <div class="page-break"></div>
+                                <div class="chapter-hint">{{$bookChild->name}}</div>
+                                <h1 id="page-{{$page->id}}">{{ $page->name }}</h1>
+                                {!! $page->html !!}
+                            @endforeach
+                        @endif
+                    @else
+                        {!! $bookChild->html !!}
+                    @endif
+                @endforeach
+
+            </div>
+        </div>
+    </div>
+</div>
+</body>
+</html>
diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php
new file mode 100644
index 000000000..2a4b38b8b
--- /dev/null
+++ b/resources/views/shelves/form.blade.php
@@ -0,0 +1,84 @@
+
+{{ csrf_field() }}
+<div class="form-group title-input">
+    <label for="name">{{ trans('common.name') }}</label>
+    @include('form/text', ['name' => 'name'])
+</div>
+
+<div class="form-group description-input">
+    <label for="description">{{ trans('common.description') }}</label>
+    @include('form/textarea', ['name' => 'description'])
+</div>
+
+<div class="row">
+    <div class="col-md-6">
+        <div shelf-sort class="form-group">
+            <label for="books">{{ trans('entities.shelves_books') }}</label>
+            <input type="hidden" id="books-input" name="books"
+                   value="{{ isset($shelf) ? $shelf->books->implode('id', ',') : '' }}">
+            <div class="scroll-box">
+                <div class="scroll-box-item text-small text-muted instruction">
+                    {{ trans('entities.shelves_drag_books') }}
+                </div>
+                <div class="scroll-box-item scroll-box-placeholder" style="display: none;">
+                    <a href="#" class="text-muted">@icon('book') ...</a>
+                </div>
+                @if (isset($shelf) && count($shelf->books) > 0)
+                    @foreach ($shelf->books as $book)
+                        <div data-id="{{ $book->id }}" class="scroll-box-item">
+                            <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
+                        </div>
+                    @endforeach
+                @endif
+            </div>
+        </div>
+    </div>
+    <div class="col-md-6">
+        <div class="form-group">
+            <label for="books">{{ trans('entities.shelves_add_books') }}</label>
+            <div class="scroll-box">
+                @foreach ($books as $book)
+                    <div data-id="{{ $book->id }}" class="scroll-box-item">
+                        <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
+                    </div>
+                @endforeach
+            </div>
+        </div>
+    </div>
+</div>
+
+
+
+<div class="form-group" collapsible id="logo-control">
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="user-avatar">{{ trans('common.cover_image') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        <p class="small">{{ trans('common.cover_image_description') }}</p>
+
+        @include('components.image-picker', [
+            'resizeHeight' => '512',
+            'resizeWidth' => '512',
+            'showRemove' => false,
+            'defaultImage' => baseUrl('/book_default_cover.png'),
+            'currentImage' => isset($shelf) ? $shelf->getBookCover() : baseUrl('/book_default_cover.png') ,
+            'currentId' => isset($shelf) && $shelf->image_id ? $shelf->image_id : 0,
+            'name' => 'image_id',
+            'imageClass' => 'cover'
+        ])
+    </div>
+</div>
+
+<div class="form-group" collapsible id="tags-control">
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        @include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf'])
+    </div>
+</div>
+
+<div class="form-group text-right">
+    <a href="{{ isset($shelf) ? $shelf->getUrl() : baseUrl('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
+    <button type="submit" class="button pos">{{ trans('entities.shelves_save') }}</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/grid-item.blade.php b/resources/views/shelves/grid-item.blade.php
new file mode 100644
index 000000000..b70b5166e
--- /dev/null
+++ b/resources/views/shelves/grid-item.blade.php
@@ -0,0 +1,18 @@
+<div class="bookshelf-grid-item grid-card"  data-entity-type="bookshelf" data-entity-id="{{$bookshelf->id}}">
+    <div class="featured-image-container">
+        <a href="{{$bookshelf->getUrl()}}" title="{{$bookshelf->name}}">
+            <img src="{{$bookshelf->getBookCover()}}" alt="{{$bookshelf->name}}">
+        </a>
+    </div>
+    <div class="grid-card-content">
+        <h2><a class="break-text" href="{{$bookshelf->getUrl()}}" title="{{$bookshelf->name}}">{{$bookshelf->getShortName(35)}}</a></h2>
+        @if(isset($bookshelf->searchSnippet))
+            <p >{!! $bookshelf->searchSnippet !!}</p>
+        @else
+            <p >{{ $bookshelf->getExcerpt(130) }}</p>
+        @endif
+    </div>
+    <div class="grid-card-footer text-muted text-small">
+        <span>@include('partials.entity-meta', ['entity' => $bookshelf])</span>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php
new file mode 100644
index 000000000..a887a843e
--- /dev/null
+++ b/resources/views/shelves/index.blade.php
@@ -0,0 +1,48 @@
+@extends('sidebar-layout')
+
+@section('toolbar')
+    <div class="col-xs-6 faded">
+        <div class="action-buttons text-left">
+            @include('shelves/view-toggle', ['shelvesViewType' => $shelvesViewType])
+        </div>
+    </div>
+    <div class="col-xs-6 faded">
+        <div class="action-buttons">
+            @if($currentUser->can('bookshelf-create-all'))
+                <a href="{{ baseUrl("/create-shelf") }}" class="text-pos text-button">@icon('add'){{ trans('entities.shelves_create') }}</a>
+            @endif
+        </div>
+    </div>
+@stop
+
+@section('sidebar')
+    @if($recents)
+        <div id="recents" class="card">
+            <h3>@icon('view') {{ trans('entities.recently_viewed') }}</h3>
+            @include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
+        </div>
+    @endif
+
+    <div id="popular" class="card">
+        <h3>@icon('popular') {{ trans('entities.shelves_popular') }}</h3>
+        @if(count($popular) > 0)
+            @include('partials/entity-list', ['entities' => $popular, 'style' => 'compact'])
+        @else
+            <div class="body text-muted">{{ trans('entities.shelves_popular_empty') }}</div>
+        @endif
+    </div>
+
+    <div id="new" class="card">
+        <h3>@icon('star-circle') {{ trans('entities.shelves_new') }}</h3>
+        @if(count($new) > 0)
+            @include('partials/entity-list', ['entities' => $new, 'style' => 'compact'])
+        @else
+            <div class="body text-muted">{{ trans('entities.shelves_new_empty') }}</div>
+        @endif
+    </div>
+@stop
+
+@section('body')
+    @include('shelves/list', ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType])
+    <p><br></p>
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/list-item.blade.php b/resources/views/shelves/list-item.blade.php
new file mode 100644
index 000000000..0b8e79fe5
--- /dev/null
+++ b/resources/views/shelves/list-item.blade.php
@@ -0,0 +1,10 @@
+<div class="shelf entity-list-item"  data-entity-type="bookshelf" data-entity-id="{{$bookshelf->id}}">
+    <h4 class="text-shelf"><a class="text-bookshelf entity-list-item-link" href="{{$bookshelf->getUrl()}}">@icon('bookshelf')<span class="entity-list-item-name break-text">{{$bookshelf->name}}</span></a></h4>
+    <div class="entity-item-snippet">
+        @if(isset($bookshelf->searchSnippet))
+            <p class="text-muted break-text">{!! $bookshelf->searchSnippet !!}</p>
+        @else
+            <p class="text-muted break-text">{{ $bookshelf->getExcerpt() }}</p>
+        @endif
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/list.blade.php b/resources/views/shelves/list.blade.php
new file mode 100644
index 000000000..ff11d2d67
--- /dev/null
+++ b/resources/views/shelves/list.blade.php
@@ -0,0 +1,26 @@
+
+<div class="container{{ $shelvesViewType === 'list' ? ' small' : '' }}">
+    <h1>{{ trans('entities.shelves') }}</h1>
+    @if(count($shelves) > 0)
+        @if($shelvesViewType === 'grid')
+            <div class="grid third">
+                @foreach($shelves as $key => $shelf)
+                    @include('shelves/grid-item', ['bookshelf' => $shelf])
+                @endforeach
+            </div>
+        @else
+            @foreach($shelves as $shelf)
+                @include('shelves/list-item', ['bookshelf' => $shelf])
+                <hr>
+            @endforeach
+        @endif
+        <div>
+            {!! $shelves->render() !!}
+        </div>
+    @else
+        <p class="text-muted">{{ trans('entities.shelves_empty') }}</p>
+        @if(userCan('bookshelf-create-all'))
+            <a href="{{ baseUrl("/create-shelf") }}" class="button outline">@icon('edit'){{ trans('entities.create_now') }}</a>
+        @endif
+    @endif
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/restrictions.blade.php b/resources/views/shelves/restrictions.blade.php
new file mode 100644
index 000000000..2a6eb0bea
--- /dev/null
+++ b/resources/views/shelves/restrictions.blade.php
@@ -0,0 +1,21 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-12 faded">
+        @include('books._breadcrumbs', ['book' => $book])
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('lock') {{ trans('entities.books_permissions') }}</h3>
+            <div class="body">
+                @include('form/restriction-form', ['model' => $book])
+            </div>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
new file mode 100644
index 000000000..e5845b495
--- /dev/null
+++ b/resources/views/shelves/show.blade.php
@@ -0,0 +1,135 @@
+@extends('sidebar-layout')
+
+@section('toolbar')
+    <div class="col-sm-6 col-xs-1  faded">
+        @include('books._breadcrumbs', ['book' => $book])
+    </div>
+    <div class="col-sm-6 col-xs-11">
+        <div class="action-buttons faded">
+            <span dropdown class="dropdown-container">
+                <div dropdown-toggle class="text-button text-primary">@icon('export'){{ trans('entities.export') }}</div>
+                <ul class="wide">
+                    <li><a href="{{ $book->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
+                    <li><a href="{{ $book->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
+                    <li><a href="{{ $book->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
+                </ul>
+            </span>
+            @if(userCan('page-create', $book))
+                <a href="{{ $book->getUrl('/create-page') }}" class="text-pos text-button">@icon('add'){{ trans('entities.pages_new') }}</a>
+            @endif
+            @if(userCan('chapter-create', $book))
+                <a href="{{ $book->getUrl('/create-chapter') }}" class="text-pos text-button">@icon('add'){{ trans('entities.chapters_new') }}</a>
+            @endif
+            @if(userCan('book-update', $book) || userCan('restrictions-manage', $book) || userCan('book-delete', $book))
+                <div dropdown class="dropdown-container">
+                    <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a>
+                    <ul>
+                        @if(userCan('book-update', $book))
+                            <li><a href="{{ $book->getUrl('/edit') }}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li>
+                            <li><a href="{{ $book->getUrl('/sort') }}" class="text-primary">@icon('sort'){{ trans('common.sort') }}</a></li>
+                        @endif
+                        @if(userCan('restrictions-manage', $book))
+                            <li><a href="{{ $book->getUrl('/permissions') }}" class="text-primary">@icon('lock'){{ trans('entities.permissions') }}</a></li>
+                        @endif
+                        @if(userCan('book-delete', $book))
+                            <li><a href="{{ $book->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('common.delete') }}</a></li>
+                        @endif
+                    </ul>
+                </div>
+            @endif
+        </div>
+    </div>
+@stop
+
+@section('sidebar')
+
+    @if($book->tags->count() > 0)
+        <section>
+            @include('components.tag-list', ['entity' => $book])
+        </section>
+    @endif
+
+    <div class="card">
+        <div class="body">
+            <form v-on:submit.prevent="searchBook" class="search-box">
+                <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}">
+                <button type="submit">@icon('search')</button>
+                <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button">@icon('close')</button>
+            </form>
+        </div>
+    </div>
+
+    <div class="card entity-details">
+        <h3>@icon('info') {{ trans('common.details') }}</h3>
+        <div class="body text-small text-muted blended-links">
+            @include('partials.entity-meta', ['entity' => $book])
+            @if($book->restricted)
+                <div class="active-restriction">
+                    @if(userCan('restrictions-manage', $book))
+                        <a href="{{ $book->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.books_permissions_active') }}</a>
+                    @else
+                        @icon('lock'){{ trans('entities.books_permissions_active') }}
+                    @endif
+                </div>
+            @endif
+        </div>
+    </div>
+
+    @if(count($activity) > 0)
+        <div class="activity card">
+            <h3>@icon('time') {{ trans('entities.recent_activity') }}</h3>
+            @include('partials/activity-list', ['activity' => $activity])
+        </div>
+    @endif
+@stop
+
+@section('container-attrs')
+    id="entity-dashboard"
+    entity-id="{{ $book->id }}"
+    entity-type="book"
+@stop
+
+@section('body')
+
+    <div class="container small nopad">
+        <h1 class="break-text" v-pre>{{$book->name}}</h1>
+        <div class="book-content" v-show="!searching">
+            <p class="text-muted" v-pre>{!! nl2br(e($book->description)) !!}</p>
+            @if(count($bookChildren) > 0)
+            <div class="page-list" v-pre>
+                <hr>
+                @foreach($bookChildren as $childElement)
+                    @if($childElement->isA('chapter'))
+                        @include('chapters/list-item', ['chapter' => $childElement])
+                    @else
+                        @include('pages/list-item', ['page' => $childElement])
+                    @endif
+                    <hr>
+                @endforeach
+            </div>
+            @else
+                <div class="well">
+                    <p class="text-muted italic">{{ trans('entities.books_empty_contents') }}</p>
+                        @if(userCan('page-create', $book))
+                            <a href="{{ $book->getUrl('/create-page') }}" class="button outline page">@icon('page'){{ trans('entities.books_empty_create_page') }}</a>
+                        @endif
+                        @if(userCan('page-create', $book) && userCan('chapter-create', $book))
+                            &nbsp;&nbsp;<em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em>&nbsp;&nbsp;&nbsp;
+                        @endif
+                        @if(userCan('chapter-create', $book))
+                            <a href="{{ $book->getUrl('/create-chapter') }}" class="button outline chapter">@icon('chapter'){{ trans('entities.books_empty_add_chapter') }}</a>
+                        @endif
+                </div>
+            @endif
+
+        </div>
+        <div class="search-results" v-cloak v-show="searching">
+            <h3 class="text-muted">{{ trans('entities.search_results') }} <a v-if="searching" v-on:click="clearSearch()" class="text-small">@icon('close'){{ trans('entities.search_clear') }}</a></h3>
+            <div v-if="!searchResults">
+                @include('partials/loading-icon')
+            </div>
+            <div v-html="searchResults"></div>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/shelves/view-toggle.blade.php b/resources/views/shelves/view-toggle.blade.php
new file mode 100644
index 000000000..785e8cac1
--- /dev/null
+++ b/resources/views/shelves/view-toggle.blade.php
@@ -0,0 +1,10 @@
+<form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-shelf-view") }}" method="POST" class="inline">
+    {!! csrf_field() !!}
+    {!! method_field('PATCH') !!}
+    <input type="hidden" value="{{ $shelvesViewType === 'list'? 'grid' : 'list' }}" name="view_type">
+    @if ($shelvesViewType === 'list')
+        <button type="submit" class="text-pos text-button">@icon('grid'){{ trans('common.grid_view') }}</button>
+    @else
+        <button type="submit" class="text-pos text-button">@icon('list'){{ trans('common.list_view') }}</button>
+    @endif
+</form>
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index c4e7469fe..7eae4a3fa 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -14,6 +14,13 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/recently-updated', 'PageController@showRecentlyUpdated');
     });
 
+    // Shelves
+    Route::get('/create-shelf', 'BookshelfController@create');
+    Route::group(['prefix' => 'shelves'], function() {
+        Route::get('/', 'BookshelfController@index');
+        Route::post('/', 'BookshelfController@store');
+    });
+
     Route::get('/create-book', 'BookController@create');
     Route::group(['prefix' => 'books'], function () {
 
@@ -160,6 +167,7 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/users/create', 'UserController@create');
         Route::get('/users/{id}/delete', 'UserController@delete');
         Route::patch('/users/{id}/switch-book-view', 'UserController@switchBookView');
+        Route::patch('/users/{id}/switch-shelf-view', 'UserController@switchShelfView');
         Route::post('/users/create', 'UserController@store');
         Route::get('/users/{id}', 'UserController@edit');
         Route::put('/users/{id}', 'UserController@update');

From f455b317ec1a562363b6ec55f63532246def931b Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 16 Sep 2018 16:59:01 +0100
Subject: [PATCH 04/11] Added ability to click books in shelf-sort

---
 resources/assets/js/components/shelf-sort.js | 32 +++++++++++++++++++-
 resources/views/shelves/form.blade.php       |  4 +--
 2 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/resources/assets/js/components/shelf-sort.js b/resources/assets/js/components/shelf-sort.js
index 91713ab41..59ac712a4 100644
--- a/resources/assets/js/components/shelf-sort.js
+++ b/resources/assets/js/components/shelf-sort.js
@@ -5,6 +5,7 @@ class ShelfSort {
         this.elem = elem;
         this.sortGroup = this.initSortable();
         this.input = document.getElementById('books-input');
+        this.setupListeners();
     }
 
     initSortable() {
@@ -21,10 +22,39 @@ class ShelfSort {
         });
     }
 
+    setupListeners() {
+        this.elem.addEventListener('click', event => {
+            const sortItem = event.target.closest('.scroll-box-item:not(.instruction)');
+            if (sortItem) {
+                event.preventDefault();
+                this.sortItemClick(sortItem);
+            }
+        });
+    }
+
+    /**
+     * Called when a sort item is clicked.
+     * @param {Element} sortItem
+     */
+    sortItemClick(sortItem) {
+        const lists = this.elem.querySelectorAll('.scroll-box');
+        const newList = Array.from(lists).filter(list => sortItem.parentElement !== list);
+        if (newList.length > 0) {
+            newList[0].appendChild(sortItem);
+        }
+        this.onChange();
+    }
+
     onDrop($item, container, _super) {
+        this.onChange();
+        _super($item, container);
+    }
+
+    onChange() {
         const data = this.sortGroup.sortable('serialize').get();
         this.input.value = data[0].map(item => item.id).join(',');
-        _super($item, container);
+        const instruction = this.elem.querySelector('.scroll-box-item.instruction');
+        instruction.parentNode.insertBefore(instruction, instruction.parentNode.children[0]);
     }
 
     getPlaceholderHTML() {
diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php
index 2a4b38b8b..142e5c69e 100644
--- a/resources/views/shelves/form.blade.php
+++ b/resources/views/shelves/form.blade.php
@@ -10,9 +10,9 @@
     @include('form/textarea', ['name' => 'description'])
 </div>
 
-<div class="row">
+<div shelf-sort class="row">
     <div class="col-md-6">
-        <div shelf-sort class="form-group">
+        <div  class="form-group">
             <label for="books">{{ trans('entities.shelves_books') }}</label>
             <input type="hidden" id="books-input" name="books"
                    value="{{ isset($shelf) ? $shelf->books->implode('id', ',') : '' }}">

From 47b08888ba053375541d503682d503d2fd62ecee Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 16 Sep 2018 19:34:09 +0100
Subject: [PATCH 05/11] Added bookshelf view, update, delete

- Enabled proper ordering of Books in a shelf.
- Improved related item destroy for all entities.
---
 app/Bookshelf.php                             |   3 +-
 app/Entity.php                                |   2 +-
 app/Http/Controllers/BookshelfController.php  | 334 ++++++------------
 app/Repos/EntityRepo.php                      |  71 ++--
 ..._08_04_115700_create_bookshelves_table.php |  11 +-
 resources/lang/en/activities.php              |   8 +
 resources/lang/en/entities.php                |   8 +
 .../views/shelves/_breadcrumbs.blade.php      |   2 +-
 resources/views/shelves/create.blade.php      |   2 +-
 resources/views/shelves/delete.blade.php      |  16 +-
 resources/views/shelves/edit.blade.php        |   8 +-
 resources/views/shelves/form.blade.php        |   4 +-
 resources/views/shelves/show.blade.php        | 107 ++----
 routes/web.php                                |   5 +
 14 files changed, 239 insertions(+), 342 deletions(-)

diff --git a/app/Bookshelf.php b/app/Bookshelf.php
index 1e33e31f6..ce2acbf0c 100644
--- a/app/Bookshelf.php
+++ b/app/Bookshelf.php
@@ -11,11 +11,12 @@ class Bookshelf extends Entity
 
     /**
      * Get the books in this shelf.
+     * Should not be used directly since does not take into account permissions.
      * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
      */
     public function books()
     {
-        return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id');
+        return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')->orderBy('order', 'asc');
     }
 
     /**
diff --git a/app/Entity.php b/app/Entity.php
index 5d4449f2b..fb1c6d48b 100644
--- a/app/Entity.php
+++ b/app/Entity.php
@@ -152,7 +152,7 @@ class Entity extends Ownable
      */
     public static function getEntityInstance($type)
     {
-        $types = ['Page', 'Book', 'Chapter'];
+        $types = ['Page', 'Book', 'Chapter', 'Bookshelf'];
         $className = str_replace([' ', '-', '_'], '', ucwords($type));
         if (!in_array($className, $types)) {
             return null;
diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
index a1c56f29a..02b6299ce 100644
--- a/app/Http/Controllers/BookshelfController.php
+++ b/app/Http/Controllers/BookshelfController.php
@@ -2,6 +2,7 @@
 
 use Activity;
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Repos\EntityRepo;
 use BookStack\Repos\UserRepo;
 use BookStack\Services\ExportService;
@@ -41,6 +42,7 @@ class BookshelfController extends Controller
         $popular = $this->entityRepo->getPopular('bookshelf', 4, 0);
         $new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0);
         $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+
         $this->setPageTitle(trans('entities.shelves'));
         return view('shelves/index', [
             'shelves' => $shelves,
@@ -58,13 +60,13 @@ class BookshelfController extends Controller
     public function create()
     {
         $this->checkPermission('bookshelf-create-all');
-        $this->setPageTitle(trans('entities.shelves_create'));
         $books = $this->entityRepo->getAll('book', false, 'update');
+        $this->setPageTitle(trans('entities.shelves_create'));
         return view('shelves/create', ['books' => $books]);
     }
 
     /**
-     * Store a newly created book in storage.
+     * Store a newly created bookshelf in storage.
      * @param  Request $request
      * @return Response
      */
@@ -83,184 +85,110 @@ class BookshelfController extends Controller
         return redirect($bookshelf->getUrl());
     }
 
-//
-//    /**
-//     * Display the specified book.
-//     * @param $slug
-//     * @return Response
-//     */
-//    public function show($slug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $slug);
-//        $this->checkOwnablePermission('book-view', $book);
-//        $bookChildren = $this->entityRepo->getBookChildren($book);
-//        Views::add($book);
-//        $this->setPageTitle($book->getShortName());
-//        return view('books/show', [
-//            'book' => $book,
-//            'current' => $book,
-//            'bookChildren' => $bookChildren,
-//            'activity' => Activity::entityActivity($book, 20, 0)
-//        ]);
-//    }
-//
-//    /**
-//     * Show the form for editing the specified book.
-//     * @param $slug
-//     * @return Response
-//     */
-//    public function edit($slug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $slug);
-//        $this->checkOwnablePermission('book-update', $book);
-//        $this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
-//        return view('books/edit', ['book' => $book, 'current' => $book]);
-//    }
-//
-//    /**
-//     * Update the specified book in storage.
-//     * @param  Request $request
-//     * @param          $slug
-//     * @return Response
-//     */
-//    public function update(Request $request, $slug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $slug);
-//        $this->checkOwnablePermission('book-update', $book);
-//        $this->validate($request, [
-//            'name' => 'required|string|max:255',
-//            'description' => 'string|max:1000'
-//        ]);
-//         $book = $this->entityRepo->updateFromInput('book', $book, $request->all());
-//         Activity::add($book, 'book_update', $book->id);
-//         return redirect($book->getUrl());
-//    }
-//
-//    /**
-//     * Shows the page to confirm deletion
-//     * @param $bookSlug
-//     * @return \Illuminate\View\View
-//     */
-//    public function showDelete($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $this->checkOwnablePermission('book-delete', $book);
-//        $this->setPageTitle(trans('entities.books_delete_named', ['bookName'=>$book->getShortName()]));
-//        return view('books/delete', ['book' => $book, 'current' => $book]);
-//    }
-//
-//    /**
-//     * Shows the view which allows pages to be re-ordered and sorted.
-//     * @param string $bookSlug
-//     * @return \Illuminate\View\View
-//     */
-//    public function sort($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $this->checkOwnablePermission('book-update', $book);
-//        $bookChildren = $this->entityRepo->getBookChildren($book, true);
-//        $books = $this->entityRepo->getAll('book', false, 'update');
-//        $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
-//        return view('books/sort', ['book' => $book, 'current' => $book, 'books' => $books, 'bookChildren' => $bookChildren]);
-//    }
-//
-//    /**
-//     * Shows the sort box for a single book.
-//     * Used via AJAX when loading in extra books to a sort.
-//     * @param $bookSlug
-//     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
-//     */
-//    public function getSortItem($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $bookChildren = $this->entityRepo->getBookChildren($book);
-//        return view('books/sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
-//    }
-//
-//    /**
-//     * Saves an array of sort mapping to pages and chapters.
-//     * @param  string $bookSlug
-//     * @param Request $request
-//     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-//     */
-//    public function saveSort($bookSlug, Request $request)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $this->checkOwnablePermission('book-update', $book);
-//
-//        // Return if no map sent
-//        if (!$request->filled('sort-tree')) {
-//            return redirect($book->getUrl());
-//        }
-//
-//        // Sort pages and chapters
-//        $sortMap = collect(json_decode($request->get('sort-tree')));
-//        $bookIdsInvolved = collect([$book->id]);
-//
-//        // Load models into map
-//        $sortMap->each(function ($mapItem) use ($bookIdsInvolved) {
-//            $mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter');
-//            $mapItem->model = $this->entityRepo->getById($mapItem->type, $mapItem->id);
-//            // Store source and target books
-//            $bookIdsInvolved->push(intval($mapItem->model->book_id));
-//            $bookIdsInvolved->push(intval($mapItem->book));
-//        });
-//
-//        // Get the books involved in the sort
-//        $bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
-//        $booksInvolved = $this->entityRepo->book->newQuery()->whereIn('id', $bookIdsInvolved)->get();
-//        // Throw permission error if invalid ids or inaccessible books given.
-//        if (count($bookIdsInvolved) !== count($booksInvolved)) {
-//            $this->showPermissionError();
-//        }
-//        // Check permissions of involved books
-//        $booksInvolved->each(function (Book $book) {
-//             $this->checkOwnablePermission('book-update', $book);
-//        });
-//
-//        // Perform the sort
-//        $sortMap->each(function ($mapItem) {
-//            $model = $mapItem->model;
-//
-//            $priorityChanged = intval($model->priority) !== intval($mapItem->sort);
-//            $bookChanged = intval($model->book_id) !== intval($mapItem->book);
-//            $chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter;
-//
-//            if ($bookChanged) {
-//                $this->entityRepo->changeBook($mapItem->type, $mapItem->book, $model);
-//            }
-//            if ($chapterChanged) {
-//                $model->chapter_id = intval($mapItem->parentChapter);
-//                $model->save();
-//            }
-//            if ($priorityChanged) {
-//                $model->priority = intval($mapItem->sort);
-//                $model->save();
-//            }
-//        });
-//
-//        // Rebuild permissions and add activity for involved books.
-//        $booksInvolved->each(function (Book $book) {
-//            $this->entityRepo->buildJointPermissionsForBook($book);
-//            Activity::add($book, 'book_sort', $book->id);
-//        });
-//
-//        return redirect($book->getUrl());
-//    }
-//
-//    /**
-//     * Remove the specified book from storage.
-//     * @param $bookSlug
-//     * @return Response
-//     */
-//    public function destroy($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $this->checkOwnablePermission('book-delete', $book);
-//        Activity::addMessage('book_delete', 0, $book->name);
-//        $this->entityRepo->destroyBook($book);
-//        return redirect('/books');
-//    }
+
+    /**
+     * Display the specified bookshelf.
+     * @param String $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function show(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('book-view', $bookshelf);
+
+        $books = $this->entityRepo->getBookshelfChildren($bookshelf);
+        Views::add($bookshelf);
+
+        $this->setPageTitle($bookshelf->getShortName());
+        return view('shelves/show', [
+            'shelf' => $bookshelf,
+            'books' => $books,
+            'activity' => Activity::entityActivity($bookshelf, 20, 0)
+        ]);
+    }
+
+    /**
+     * Show the form for editing the specified bookshelf.
+     * @param $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function edit(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-update', $bookshelf);
+
+        $shelfBooks = $this->entityRepo->getBookshelfChildren($bookshelf);
+        $shelfBookIds = $shelfBooks->pluck('id');
+        $books = $this->entityRepo->getAll('book', false, 'update');
+        $books = $books->filter(function ($book) use ($shelfBookIds) {
+             return !$shelfBookIds->contains($book->id);
+        });
+
+        $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $bookshelf->getShortName()]));
+        return view('shelves/edit', [
+            'shelf' => $bookshelf,
+            'books' => $books,
+            'shelfBooks' => $shelfBooks,
+        ]);
+    }
+
+
+    /**
+     * Update the specified bookshelf in storage.
+     * @param  Request $request
+     * @param string $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function update(Request $request, string $slug)
+    {
+        $shelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-update', $shelf);
+        $this->validate($request, [
+            'name' => 'required|string|max:255',
+            'description' => 'string|max:1000',
+        ]);
+
+         $shelf = $this->entityRepo->updateFromInput('bookshelf', $shelf, $request->all());
+         $this->entityRepo->updateShelfBooks($shelf, $request->get('books', ''));
+         Activity::add($shelf, 'bookshelf_update');
+
+         return redirect($shelf->getUrl());
+    }
+
+
+    /**
+     * Shows the page to confirm deletion
+     * @param $slug
+     * @return \Illuminate\View\View
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function showDelete(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-delete', $bookshelf);
+
+        $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $bookshelf->getShortName()]));
+        return view('shelves/delete', ['shelf' => $bookshelf]);
+    }
+
+    /**
+     * Remove the specified bookshelf from storage.
+     * @param string $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     * @throws \Throwable
+     */
+    public function destroy(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-delete', $bookshelf);
+        Activity::addMessage('bookshelf_delete', 0, $bookshelf->name);
+        $this->entityRepo->destroyBookshelf($bookshelf);
+        return redirect('/shelves');
+    }
 //
 //    /**
 //     * Show the Restrictions view.
@@ -293,49 +221,5 @@ class BookshelfController extends Controller
 //        session()->flash('success', trans('entities.books_permissions_updated'));
 //        return redirect($book->getUrl());
 //    }
-//
-//    /**
-//     * Export a book as a PDF file.
-//     * @param string $bookSlug
-//     * @return mixed
-//     */
-//    public function exportPdf($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $pdfContent = $this->exportService->bookToPdf($book);
-//        return response()->make($pdfContent, 200, [
-//            'Content-Type'        => 'application/octet-stream',
-//            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.pdf'
-//        ]);
-//    }
-//
-//    /**
-//     * Export a book as a contained HTML file.
-//     * @param string $bookSlug
-//     * @return mixed
-//     */
-//    public function exportHtml($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $htmlContent = $this->exportService->bookToContainedHtml($book);
-//        return response()->make($htmlContent, 200, [
-//            'Content-Type'        => 'application/octet-stream',
-//            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.html'
-//        ]);
-//    }
-//
-//    /**
-//     * Export a book as a plain text file.
-//     * @param $bookSlug
-//     * @return mixed
-//     */
-//    public function exportPlainText($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $htmlContent = $this->exportService->bookToPlainText($book);
-//        return response()->make($htmlContent, 200, [
-//            'Content-Type'        => 'application/octet-stream',
-//            'Content-Disposition' => 'attachment; filename="' . $bookSlug . '.txt'
-//        ]);
-//    }
+
 }
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index ea7fc4882..ab4b7cc04 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -340,6 +340,17 @@ class EntityRepo
             ->skip($count * $page)->take($count)->get();
     }
 
+    /**
+     * Get the child items for a chapter sorted by priority but
+     * with draft items floated to the top.
+     * @param Bookshelf $bookshelf
+     * @return \Illuminate\Database\Eloquent\Collection|static[]
+     */
+    public function getBookshelfChildren(Bookshelf $bookshelf)
+    {
+        return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
+    }
+
     /**
      * Get all child objects of a book.
      * Returns a sorted collection of Pages and Chapters.
@@ -551,12 +562,17 @@ class EntityRepo
     public function updateShelfBooks(Bookshelf $shelf, string $books)
     {
         $ids = explode(',', $books);
-        if (count($ids) === 0) {
-            return;
+
+        // Check books exist and match ordering
+        $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
+        $syncData = [];
+        foreach ($ids as $index => $id) {
+            if ($bookIds->contains($id)) {
+                $syncData[$id] = ['order' => $index];
+            }
         }
 
-        $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
-        $shelf->books()->sync($bookIds);
+        $shelf->books()->sync($syncData);
     }
 
     /**
@@ -1180,6 +1196,17 @@ class EntityRepo
         $this->permissionService->buildJointPermissionsForEntity($book);
     }
 
+    /**
+     * Destroy a bookshelf instance
+     * @param Bookshelf $shelf
+     * @throws \Throwable
+     */
+    public function destroyBookshelf(Bookshelf $shelf)
+    {
+        $this->destroyEntityCommonRelations($shelf);
+        $shelf->delete();
+    }
+
     /**
      * Destroy the provided book and all its child entities.
      * @param Book $book
@@ -1194,11 +1221,7 @@ class EntityRepo
         foreach ($book->chapters as $chapter) {
             $this->destroyChapter($chapter);
         }
-        \Activity::removeEntity($book);
-        $book->views()->delete();
-        $book->permissions()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($book);
-        $this->searchService->deleteEntityTerms($book);
+        $this->destroyEntityCommonRelations($book);
         $book->delete();
     }
 
@@ -1215,11 +1238,7 @@ class EntityRepo
                 $page->save();
             }
         }
-        \Activity::removeEntity($chapter);
-        $chapter->views()->delete();
-        $chapter->permissions()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($chapter);
-        $this->searchService->deleteEntityTerms($chapter);
+        $this->destroyEntityCommonRelations($chapter);
         $chapter->delete();
     }
 
@@ -1231,13 +1250,7 @@ class EntityRepo
      */
     public function destroyPage(Page $page)
     {
-        \Activity::removeEntity($page);
-        $page->views()->delete();
-        $page->tags()->delete();
-        $page->revisions()->delete();
-        $page->permissions()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($page);
-        $this->searchService->deleteEntityTerms($page);
+        $this->destroyEntityCommonRelations($page);
 
         // Check if set as custom homepage
         $customHome = setting('app-homepage', '0:');
@@ -1253,4 +1266,20 @@ class EntityRepo
 
         $page->delete();
     }
+
+    /**
+     * Destroy or handle the common relations connected to an entity.
+     * @param Entity $entity
+     * @throws \Throwable
+     */
+    protected function destroyEntityCommonRelations(Entity $entity)
+    {
+        \Activity::removeEntity($entity);
+        $entity->views()->delete();
+        $entity->permissions()->delete();
+        $entity->tags()->delete();
+        $entity->comments()->delete();
+        $this->permissionService->deleteJointPermissionsForEntity($entity);
+        $this->searchService->deleteEntityTerms($entity);
+    }
 }
diff --git a/database/migrations/2018_08_04_115700_create_bookshelves_table.php b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
index c7840e1a1..e92b0edef 100644
--- a/database/migrations/2018_08_04_115700_create_bookshelves_table.php
+++ b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
@@ -33,6 +33,7 @@ class CreateBookshelvesTable extends Migration
         Schema::create('bookshelves_books', function (Blueprint $table) {
             $table->integer('bookshelf_id')->unsigned();
             $table->integer('book_id')->unsigned();
+            $table->integer('order')->unsigned();
 
             $table->foreign('bookshelf_id')->references('id')->on('bookshelves')
                 ->onUpdate('cascade')->onDelete('cascade');
@@ -86,7 +87,15 @@ class CreateBookshelvesTable extends Migration
         DB::table('role_permissions')->whereIn('id', $permissionIds)->delete();
 
         // Drop shelves table
-        Schema::dropIfExists('bookshelves');
         Schema::dropIfExists('bookshelves_books');
+        Schema::dropIfExists('bookshelves');
+
+        // Drop related polymorphic items
+        DB::table('activities')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('views')->where('viewable_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('tags')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('search_terms')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('comments')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
     }
 }
diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php
index 187fe1e53..153ae33f0 100644
--- a/resources/lang/en/activities.php
+++ b/resources/lang/en/activities.php
@@ -37,6 +37,14 @@ return [
     'book_sort'                   => 'sorted book',
     'book_sort_notification'      => 'Book Successfully Re-sorted',
 
+    // Bookshelves
+    'bookshelf_create'            => 'created Bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf Successfully Created',
+    'bookshelf_update'                 => 'updated bookshelf',
+    'bookshelf_update_notification'    => 'Bookshelf Successfully Updated',
+    'bookshelf_delete'                 => 'deleted bookshelf',
+    'bookshelf_delete_notification'    => 'Bookshelf Successfully Deleted',
+
     // Other
     'commented_on'                => 'commented on',
 ];
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index c744c5557..8d1363240 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -79,6 +79,14 @@ return [
     'shelves_books' => 'Books on this shelf',
     'shelves_add_books' => 'Add books to this shelf',
     'shelves_drag_books' => 'Drag books here to add them to this shelf',
+    'shelves_empty_contents' => 'This shelf has no books assigned to it',
+    'shelves_edit_and_assign' => 'Edit shelf to assign books',
+    'shelves_edit_named' => 'Edit Bookshelf :name',
+    'shelves_edit' => 'Edit Bookshelf',
+    'shelves_delete' => 'Delete Bookshelf',
+    'shelves_delete_named' => 'Delete Bookshelf :name',
+    'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
+    'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
 
     /**
      * Books
diff --git a/resources/views/shelves/_breadcrumbs.blade.php b/resources/views/shelves/_breadcrumbs.blade.php
index e4ecc36c6..91b4252ef 100644
--- a/resources/views/shelves/_breadcrumbs.blade.php
+++ b/resources/views/shelves/_breadcrumbs.blade.php
@@ -1,3 +1,3 @@
 <div class="breadcrumbs">
-    <a href="{{$book->getUrl()}}" class="text-book text-button">@icon('book'){{ $book->getShortName() }}</a>
+    <a href="{{$shelf->getUrl()}}" class="text-bookshelf text-button">@icon('bookshelf'){{ $shelf->getShortName() }}</a>
 </div>
\ No newline at end of file
diff --git a/resources/views/shelves/create.blade.php b/resources/views/shelves/create.blade.php
index aaff08974..32e40a4ae 100644
--- a/resources/views/shelves/create.blade.php
+++ b/resources/views/shelves/create.blade.php
@@ -18,7 +18,7 @@
             <h3>@icon('add') {{ trans('entities.shelves_create') }}</h3>
             <div class="body">
                 <form action="{{ baseUrl("/shelves") }}" method="POST" enctype="multipart/form-data">
-                    @include('shelves/form')
+                    @include('shelves/form', ['shelf' => null, 'books' => $books])
                 </form>
             </div>
         </div>
diff --git a/resources/views/shelves/delete.blade.php b/resources/views/shelves/delete.blade.php
index 0ac98e895..f3ad62456 100644
--- a/resources/views/shelves/delete.blade.php
+++ b/resources/views/shelves/delete.blade.php
@@ -2,7 +2,7 @@
 
 @section('toolbar')
     <div class="col-sm-12 faded">
-        @include('books._breadcrumbs', ['book' => $book])
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
     </div>
 @stop
 
@@ -11,20 +11,20 @@
     <div class="container small">
         <p>&nbsp;</p>
         <div class="card">
-            <h3>@icon('delete') {{ trans('entities.books_delete') }}</h3>
+            <h3>@icon('delete') {{ trans('entities.shelves_delete') }}</h3>
             <div class="body">
-                <p>{{ trans('entities.books_delete_explain', ['bookName' => $book->name]) }}</p>
-                <p class="text-neg">{{ trans('entities.books_delete_confirmation') }}</p>
+                <p>{{ trans('entities.shelves_delete_explain', ['name' => $shelf->name]) }}</p>
+                <p class="text-neg">{{ trans('entities.shelves_delete_confirmation') }}</p>
 
-                <form action="{{$book->getUrl()}}" method="POST">
+                <form action="{{ $shelf->getUrl() }}" method="POST">
                     {!! csrf_field() !!}
                     <input type="hidden" name="_method" value="DELETE">
-                    <a href="{{$book->getUrl()}}" class="button outline">{{ trans('common.cancel') }}</a>
-                    <button type="submit" class="button neg">{{ trans('common.confirm') }}</button>
+
+                    <a href="{{ $shelf->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('common.confirm') }}</button>
                 </form>
             </div>
         </div>
-
     </div>
 
 @stop
\ No newline at end of file
diff --git a/resources/views/shelves/edit.blade.php b/resources/views/shelves/edit.blade.php
index cb1ffc461..ab88051e5 100644
--- a/resources/views/shelves/edit.blade.php
+++ b/resources/views/shelves/edit.blade.php
@@ -2,7 +2,7 @@
 
 @section('toolbar')
     <div class="col-sm-12 faded">
-        @include('books._breadcrumbs', ['book' => $book])
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
     </div>
 @stop
 
@@ -11,11 +11,11 @@
     <div class="container small">
         <p>&nbsp;</p>
         <div class="card">
-            <h3>@icon('edit') {{ trans('entities.books_edit') }}</h3>
+            <h3>@icon('edit') {{ trans('entities.shelves_edit') }}</h3>
             <div class="body">
-                <form action="{{ $book->getUrl() }}" method="POST">
+                <form action="{{ $shelf->getUrl() }}" method="POST">
                     <input type="hidden" name="_method" value="PUT">
-                    @include('books/form', ['model' => $book])
+                    @include('shelves/form', ['model' => $shelf])
                 </form>
             </div>
         </div>
diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php
index 142e5c69e..fb6fee115 100644
--- a/resources/views/shelves/form.blade.php
+++ b/resources/views/shelves/form.blade.php
@@ -23,8 +23,8 @@
                 <div class="scroll-box-item scroll-box-placeholder" style="display: none;">
                     <a href="#" class="text-muted">@icon('book') ...</a>
                 </div>
-                @if (isset($shelf) && count($shelf->books) > 0)
-                    @foreach ($shelf->books as $book)
+                @if (isset($shelfBooks) && count($shelfBooks) > 0)
+                    @foreach ($shelfBooks as $book)
                         <div data-id="{{ $book->id }}" class="scroll-box-item">
                             <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
                         </div>
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
index e5845b495..2aae2c6ff 100644
--- a/resources/views/shelves/show.blade.php
+++ b/resources/views/shelves/show.blade.php
@@ -2,37 +2,22 @@
 
 @section('toolbar')
     <div class="col-sm-6 col-xs-1  faded">
-        @include('books._breadcrumbs', ['book' => $book])
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
     </div>
     <div class="col-sm-6 col-xs-11">
         <div class="action-buttons faded">
-            <span dropdown class="dropdown-container">
-                <div dropdown-toggle class="text-button text-primary">@icon('export'){{ trans('entities.export') }}</div>
-                <ul class="wide">
-                    <li><a href="{{ $book->getUrl('/export/html') }}" target="_blank">{{ trans('entities.export_html') }} <span class="text-muted float right">.html</span></a></li>
-                    <li><a href="{{ $book->getUrl('/export/pdf') }}" target="_blank">{{ trans('entities.export_pdf') }} <span class="text-muted float right">.pdf</span></a></li>
-                    <li><a href="{{ $book->getUrl('/export/plaintext') }}" target="_blank">{{ trans('entities.export_text') }} <span class="text-muted float right">.txt</span></a></li>
-                </ul>
-            </span>
-            @if(userCan('page-create', $book))
-                <a href="{{ $book->getUrl('/create-page') }}" class="text-pos text-button">@icon('add'){{ trans('entities.pages_new') }}</a>
+            @if(userCan('bookshelf-update', $shelf))
+                <a href="{{ $shelf->getUrl('/edit') }}" class="text-button text-primary">@icon('edit'){{ trans('common.edit') }}</a>
             @endif
-            @if(userCan('chapter-create', $book))
-                <a href="{{ $book->getUrl('/create-chapter') }}" class="text-pos text-button">@icon('add'){{ trans('entities.chapters_new') }}</a>
-            @endif
-            @if(userCan('book-update', $book) || userCan('restrictions-manage', $book) || userCan('book-delete', $book))
+            @if(userCan('restrictions-manage', $shelf) || userCan('bookshelf-delete', $shelf))
                 <div dropdown class="dropdown-container">
                     <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a>
                     <ul>
-                        @if(userCan('book-update', $book))
-                            <li><a href="{{ $book->getUrl('/edit') }}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li>
-                            <li><a href="{{ $book->getUrl('/sort') }}" class="text-primary">@icon('sort'){{ trans('common.sort') }}</a></li>
+                        @if(userCan('restrictions-manage', $shelf))
+                            <li><a href="{{ $shelf->getUrl('/permissions') }}" class="text-primary">@icon('lock'){{ trans('entities.permissions') }}</a></li>
                         @endif
-                        @if(userCan('restrictions-manage', $book))
-                            <li><a href="{{ $book->getUrl('/permissions') }}" class="text-primary">@icon('lock'){{ trans('entities.permissions') }}</a></li>
-                        @endif
-                        @if(userCan('book-delete', $book))
-                            <li><a href="{{ $book->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('common.delete') }}</a></li>
+                        @if(userCan('bookshelf-delete', $shelf))
+                            <li><a href="{{ $shelf->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('common.delete') }}</a></li>
                         @endif
                     </ul>
                 </div>
@@ -43,32 +28,22 @@
 
 @section('sidebar')
 
-    @if($book->tags->count() > 0)
+    @if($shelf->tags->count() > 0)
         <section>
-            @include('components.tag-list', ['entity' => $book])
+            @include('components.tag-list', ['entity' => $shelf])
         </section>
     @endif
 
-    <div class="card">
-        <div class="body">
-            <form v-on:submit.prevent="searchBook" class="search-box">
-                <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}">
-                <button type="submit">@icon('search')</button>
-                <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button">@icon('close')</button>
-            </form>
-        </div>
-    </div>
-
     <div class="card entity-details">
         <h3>@icon('info') {{ trans('common.details') }}</h3>
         <div class="body text-small text-muted blended-links">
-            @include('partials.entity-meta', ['entity' => $book])
-            @if($book->restricted)
+            @include('partials.entity-meta', ['entity' => $shelf])
+            @if($shelf->restricted)
                 <div class="active-restriction">
-                    @if(userCan('restrictions-manage', $book))
-                        <a href="{{ $book->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.books_permissions_active') }}</a>
+                    @if(userCan('restrictions-manage', $shelf))
+                        <a href="{{ $shelf->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.shelves_permissions_active') }}</a>
                     @else
-                        @icon('lock'){{ trans('entities.books_permissions_active') }}
+                        @icon('lock'){{ trans('entities.shelves_permissions_active') }}
                     @endif
                 </div>
             @endif
@@ -83,53 +58,31 @@
     @endif
 @stop
 
-@section('container-attrs')
-    id="entity-dashboard"
-    entity-id="{{ $book->id }}"
-    entity-type="book"
-@stop
-
 @section('body')
 
     <div class="container small nopad">
-        <h1 class="break-text" v-pre>{{$book->name}}</h1>
-        <div class="book-content" v-show="!searching">
-            <p class="text-muted" v-pre>{!! nl2br(e($book->description)) !!}</p>
-            @if(count($bookChildren) > 0)
-            <div class="page-list" v-pre>
+        <h1 class="break-text">{{$shelf->name}}</h1>
+        <div class="book-content">
+            <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
+            @if(count($books) > 0)
+            <div class="page-list">
                 <hr>
-                @foreach($bookChildren as $childElement)
-                    @if($childElement->isA('chapter'))
-                        @include('chapters/list-item', ['chapter' => $childElement])
-                    @else
-                        @include('pages/list-item', ['page' => $childElement])
-                    @endif
+                @foreach($books as $book)
+                    @include('books/list-item', ['book' => $book])
                     <hr>
                 @endforeach
             </div>
             @else
-                <div class="well">
-                    <p class="text-muted italic">{{ trans('entities.books_empty_contents') }}</p>
-                        @if(userCan('page-create', $book))
-                            <a href="{{ $book->getUrl('/create-page') }}" class="button outline page">@icon('page'){{ trans('entities.books_empty_create_page') }}</a>
-                        @endif
-                        @if(userCan('page-create', $book) && userCan('chapter-create', $book))
-                            &nbsp;&nbsp;<em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em>&nbsp;&nbsp;&nbsp;
-                        @endif
-                        @if(userCan('chapter-create', $book))
-                            <a href="{{ $book->getUrl('/create-chapter') }}" class="button outline chapter">@icon('chapter'){{ trans('entities.books_empty_add_chapter') }}</a>
-                        @endif
-                </div>
+            <p>
+                <hr>
+                <span class="text-muted italic">{{ trans('entities.shelves_empty_contents') }}</span>
+                @if(userCan('bookshelf-create', $shelf))
+                    <br>
+                    <a href="{{ $shelf->getUrl('/edit') }}" class="button outline bookshelf">{{ trans('entities.shelves_edit_and_assign') }}</a>
+                @endif
+            </p>
             @endif
 
-        </div>
-        <div class="search-results" v-cloak v-show="searching">
-            <h3 class="text-muted">{{ trans('entities.search_results') }} <a v-if="searching" v-on:click="clearSearch()" class="text-small">@icon('close'){{ trans('entities.search_clear') }}</a></h3>
-            <div v-if="!searchResults">
-                @include('partials/loading-icon')
-            </div>
-            <div v-html="searchResults"></div>
-        </div>
     </div>
 
 @stop
diff --git a/routes/web.php b/routes/web.php
index 7eae4a3fa..be0b2da6e 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -19,6 +19,11 @@ Route::group(['middleware' => 'auth'], function () {
     Route::group(['prefix' => 'shelves'], function() {
         Route::get('/', 'BookshelfController@index');
         Route::post('/', 'BookshelfController@store');
+        Route::get('/{slug}/edit', 'BookshelfController@edit');
+        Route::get('/{slug}/delete', 'BookshelfController@showDelete');
+        Route::get('/{slug}', 'BookshelfController@show');
+        Route::put('/{slug}', 'BookshelfController@update');
+        Route::delete('/{slug}', 'BookshelfController@destroy');
     });
 
     Route::get('/create-book', 'BookController@create');

From 81eb642f752efaca71b13704996c53ec308be8fe Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 20 Sep 2018 15:27:30 +0100
Subject: [PATCH 06/11] Added bookshelves homepage options

- Updated homepage selection UI to be more scalable
- Cleaned homepage selection logic in code
- Added seed test data for bookshelves
- Added bookshelves to permission system
---
 app/Book.php                                  |  9 +++
 app/Http/Controllers/HomeController.php       | 60 +++++++++----------
 app/Repos/EntityRepo.php                      |  4 +-
 app/Services/PermissionService.php            | 37 +++++++++++-
 database/factories/ModelFactory.php           |  8 +++
 database/seeds/DummyContentSeeder.php         | 24 +++++---
 .../assets/js/components/homepage-control.js  | 22 +++++++
 resources/assets/js/components/index.js       |  1 +
 resources/assets/js/components/page-picker.js | 14 +++--
 resources/lang/en/common.php                  |  1 +
 resources/lang/en/entities.php                |  1 +
 resources/lang/en/settings.php                |  5 +-
 resources/views/common/home-shelves.blade.php | 18 ++++++
 resources/views/common/home.blade.php         |  2 +-
 resources/views/settings/index.blade.php      | 18 ++++--
 tests/HomepageTest.php                        | 29 +++++++--
 16 files changed, 192 insertions(+), 61 deletions(-)
 create mode 100644 resources/assets/js/components/homepage-control.js
 create mode 100644 resources/views/common/home-shelves.blade.php

diff --git a/app/Book.php b/app/Book.php
index effd6ca45..4e944ce10 100644
--- a/app/Book.php
+++ b/app/Book.php
@@ -67,6 +67,15 @@ class Book extends Entity
         return $this->hasMany(Chapter::class);
     }
 
+    /**
+     * Get the shelves this book is contained within.
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     */
+    public function shelves()
+    {
+        return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
+    }
+
     /**
      * Get an excerpt of this book's description to the specified length or less.
      * @param int $length
diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php
index 2077f6888..e47250318 100644
--- a/app/Http/Controllers/HomeController.php
+++ b/app/Http/Controllers/HomeController.php
@@ -33,42 +33,42 @@ class HomeController extends Controller
         $recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 12*$recentFactor);
         $recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 12);
 
-
-        $customHomepage = false;
-        $books = false;
-        $booksViewType = false;
-
-        // Check book homepage
-        $bookHomepageSetting = setting('app-book-homepage');
-        if ($bookHomepageSetting) {
-            $books = $this->entityRepo->getAllPaginated('book', 18);
-            $booksViewType = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books', 'list'));
-        } else {
-            // Check custom homepage
-            $homepageSetting = setting('app-homepage');
-            if ($homepageSetting) {
-                $id = intval(explode(':', $homepageSetting)[0]);
-                $customHomepage = $this->entityRepo->getById('page', $id, false, true);
-                $this->entityRepo->renderPage($customHomepage, true);
-            }
+        $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
+        $homepageOption = setting('app-homepage-type', 'default');
+        if (!in_array($homepageOption, $homepageOptions)) {
+            $homepageOption = 'default';
         }
 
-        $view = 'home';
-        if ($bookHomepageSetting) {
-            $view = 'home-book';
-        } else if ($customHomepage) {
-            $view = 'home-custom';
-        }
-
-        return view('common/' . $view, [
+        $commonData = [
             'activity' => $activity,
             'recents' => $recents,
             'recentlyUpdatedPages' => $recentlyUpdatedPages,
             'draftPages' => $draftPages,
-            'customHomepage' => $customHomepage,
-            'books' => $books,
-            'booksViewType' => $booksViewType
-        ]);
+        ];
+
+        if ($homepageOption === 'bookshelves') {
+            $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18);
+            $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+            $data = array_merge($commonData, ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType]);
+            return view('common.home-shelves', $data);
+        }
+
+        if ($homepageOption === 'books') {
+            $books = $this->entityRepo->getAllPaginated('book', 18);
+            $booksViewType = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books', 'list'));
+            $data = array_merge($commonData, ['books' => $books, 'booksViewType' => $booksViewType]);
+            return view('common.home-book', $data);
+        }
+
+        if ($homepageOption === 'page') {
+            $homepageSetting = setting('app-homepage', '0:');
+            $id = intval(explode(':', $homepageSetting)[0]);
+            $customHomepage = $this->entityRepo->getById('page', $id, false, true);
+            $this->entityRepo->renderPage($customHomepage, true);
+            return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage]));
+        }
+
+        return view('common.home', $commonData);
     }
 
     /**
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index ab4b7cc04..db9226411 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -1250,14 +1250,14 @@ class EntityRepo
      */
     public function destroyPage(Page $page)
     {
-        $this->destroyEntityCommonRelations($page);
-
         // Check if set as custom homepage
         $customHome = setting('app-homepage', '0:');
         if (intval($page->id) === intval(explode(':', $customHome)[0])) {
             throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
         }
 
+        $this->destroyEntityCommonRelations($page);
+
         // Delete Attached Files
         $attachmentService = app(AttachmentService::class);
         foreach ($page->attachments as $attachment) {
diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php
index 428cb895f..13ec1d45b 100644
--- a/app/Services/PermissionService.php
+++ b/app/Services/PermissionService.php
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Services;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\EntityPermission;
@@ -25,6 +26,7 @@ class PermissionService
     public $book;
     public $chapter;
     public $page;
+    public $bookshelf;
 
     protected $db;
 
@@ -38,18 +40,23 @@ class PermissionService
      * PermissionService constructor.
      * @param JointPermission $jointPermission
      * @param EntityPermission $entityPermission
+     * @param Role $role
      * @param Connection $db
+     * @param Bookshelf $bookshelf
      * @param Book $book
      * @param Chapter $chapter
      * @param Page $page
-     * @param Role $role
      */
-    public function __construct(JointPermission $jointPermission, EntityPermission $entityPermission, Connection $db, Book $book, Chapter $chapter, Page $page, Role $role)
+    public function __construct(
+        JointPermission $jointPermission, EntityPermission $entityPermission, Role $role, Connection $db,
+        Bookshelf $bookshelf, Book $book, Chapter $chapter, Page $page
+    )
     {
         $this->db = $db;
         $this->jointPermission = $jointPermission;
         $this->entityPermission = $entityPermission;
         $this->role = $role;
+        $this->bookshelf = $bookshelf;
         $this->book = $book;
         $this->chapter = $chapter;
         $this->page = $page;
@@ -159,6 +166,12 @@ class PermissionService
         $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
             $this->buildJointPermissionsForBooks($books, $roles);
         });
+
+        // Chunk through all bookshelves
+        $this->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+            ->chunk(50, function ($shelves) use ($roles) {
+            $this->buildJointPermissionsForShelves($shelves, $roles);
+        });
     }
 
     /**
@@ -174,6 +187,20 @@ class PermissionService
         }]);
     }
 
+    /**
+     * @param Collection $shelves
+     * @param array $roles
+     * @param bool $deleteOld
+     * @throws \Throwable
+     */
+    protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
+    {
+        if ($deleteOld) {
+            $this->deleteManyJointPermissionsForEntities($shelves->all());
+        }
+        $this->createManyJointPermissions($shelves, $roles);
+    }
+
     /**
      * Build joint permissions for an array of books
      * @param Collection $books
@@ -257,6 +284,12 @@ class PermissionService
         $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
             $this->buildJointPermissionsForBooks($books, $roles);
         });
+
+        // Chunk through all bookshelves
+        $this->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+            ->chunk(50, function ($shelves) use ($roles) {
+                $this->buildJointPermissionsForShelves($shelves, $roles);
+            });
     }
 
     /**
diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php
index c68f5c1e1..3d6ed1d63 100644
--- a/database/factories/ModelFactory.php
+++ b/database/factories/ModelFactory.php
@@ -21,6 +21,14 @@ $factory->define(BookStack\User::class, function ($faker) {
     ];
 });
 
+$factory->define(BookStack\Bookshelf::class, function ($faker) {
+    return [
+        'name' => $faker->sentence,
+        'slug' => str_random(10),
+        'description' => $faker->paragraph
+    ];
+});
+
 $factory->define(BookStack\Book::class, function ($faker) {
     return [
         'name' => $faker->sentence,
diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php
index 41ac6650d..dcf589352 100644
--- a/database/seeds/DummyContentSeeder.php
+++ b/database/seeds/DummyContentSeeder.php
@@ -21,23 +21,29 @@ class DummyContentSeeder extends Seeder
         $role = \BookStack\Role::getRole('viewer');
         $viewerUser->attachRole($role);
 
-        factory(\BookStack\Book::class, 5)->create(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id])
-            ->each(function($book) use ($editorUser) {
-                $chapters = factory(\BookStack\Chapter::class, 3)->create(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id])
-                    ->each(function($chapter) use ($editorUser, $book){
-                        $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'book_id' => $book->id]);
+        $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id];
+
+        factory(\BookStack\Book::class, 5)->create($byData)
+            ->each(function($book) use ($editorUser, $byData) {
+                $chapters = factory(\BookStack\Chapter::class, 3)->create($byData)
+                    ->each(function($chapter) use ($editorUser, $book, $byData){
+                        $pages = factory(\BookStack\Page::class, 3)->make(array_merge($byData, ['book_id' => $book->id]));
                         $chapter->pages()->saveMany($pages);
                     });
-                $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+                $pages = factory(\BookStack\Page::class, 3)->make($byData);
                 $book->chapters()->saveMany($chapters);
                 $book->pages()->saveMany($pages);
             });
 
-        $largeBook = factory(\BookStack\Book::class)->create(['name' => 'Large book' . str_random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $pages = factory(\BookStack\Page::class, 200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $chapters = factory(\BookStack\Chapter::class, 50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+        $largeBook = factory(\BookStack\Book::class)->create(array_merge($byData, ['name' => 'Large book' . str_random(10)]));
+        $pages = factory(\BookStack\Page::class, 200)->make($byData);
+        $chapters = factory(\BookStack\Chapter::class, 50)->make($byData);
         $largeBook->pages()->saveMany($pages);
         $largeBook->chapters()->saveMany($chapters);
+
+        $shelves = factory(\BookStack\Bookshelf::class, 10)->create($byData);
+        $largeBook->shelves()->attach($shelves->pluck('id'));
+
         app(\BookStack\Services\PermissionService::class)->buildJointPermissions();
         app(\BookStack\Services\SearchService::class)->indexAllEntities();
     }
diff --git a/resources/assets/js/components/homepage-control.js b/resources/assets/js/components/homepage-control.js
new file mode 100644
index 000000000..e1f66a592
--- /dev/null
+++ b/resources/assets/js/components/homepage-control.js
@@ -0,0 +1,22 @@
+
+class HomepageControl {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.typeControl = elem.querySelector('[name="setting-app-homepage-type"]');
+        this.pagePickerContainer = elem.querySelector('[page-picker-container]');
+
+        this.typeControl.addEventListener('change', this.controlPagePickerVisibility.bind(this));
+        this.controlPagePickerVisibility();
+    }
+
+    controlPagePickerVisibility() {
+        const showPagePicker = this.typeControl.value === 'page';
+        this.pagePickerContainer.style.display = (showPagePicker ? 'block' : 'none');
+    }
+
+
+
+}
+
+module.exports = HomepageControl;
\ No newline at end of file
diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js
index e1aef032c..768e0983f 100644
--- a/resources/assets/js/components/index.js
+++ b/resources/assets/js/components/index.js
@@ -19,6 +19,7 @@ let componentMapping = {
     'toggle-switch': require('./toggle-switch'),
     'page-display': require('./page-display'),
     'shelf-sort': require('./shelf-sort'),
+    'homepage-control': require('./homepage-control'),
 };
 
 window.components = {};
diff --git a/resources/assets/js/components/page-picker.js b/resources/assets/js/components/page-picker.js
index e697d5f68..5fd2920f4 100644
--- a/resources/assets/js/components/page-picker.js
+++ b/resources/assets/js/components/page-picker.js
@@ -15,18 +15,20 @@ class PagePicker {
     }
 
     setupListeners() {
-        // Select click
-        this.selectButton.addEventListener('click', event => {
-            window.EntitySelectorPopup.show(entity => {
-                this.setValue(entity.id, entity.name);
-            });
-        });
+        this.selectButton.addEventListener('click', this.showPopup.bind(this));
+        this.display.parentElement.addEventListener('click', this.showPopup.bind(this));
 
         this.resetButton.addEventListener('click', event => {
             this.setValue('', '');
         });
     }
 
+    showPopup() {
+        window.EntitySelectorPopup.show(entity => {
+            this.setValue(entity.id, entity.name);
+        });
+    }
+
     setValue(value, name) {
         this.value = value;
         this.input.value = value;
diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php
index c2744d906..8e86129e2 100644
--- a/resources/lang/en/common.php
+++ b/resources/lang/en/common.php
@@ -52,6 +52,7 @@ return [
     'details' => 'Details',
     'grid_view' => 'Grid View',
     'list_view' => 'List View',
+    'default' => 'Default',
 
     /**
      * Header
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 8d1363240..2228da2cd 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -223,6 +223,7 @@ return [
         'message' => ':start :time. Take care not to overwrite each other\'s updates!',
     ],
     'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content',
+    'pages_specific' => 'Specific Page',
 
     /**
      * Editor sidebar
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index d6fbb6107..e2c2ede2b 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -32,9 +32,8 @@ return [
     'app_primary_color' => 'Application primary color',
     'app_primary_color_desc' => 'This should be a hex value. <br>Leave empty to reset to the default color.',
     'app_homepage' => 'Application Homepage',
-    'app_homepage_desc' => 'Select a page to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_default' => 'Default homepage view chosen',
-    'app_homepage_books' => 'Or select the books page as your homepage. This will override any page selected as your homepage.',
+    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
+    'app_homepage_select' => 'Select a page',
     'app_disable_comments' => 'Disable comments',
     'app_disable_comments_desc' => 'Disable comments across all pages in the application. Existing comments are not shown.',
 
diff --git a/resources/views/common/home-shelves.blade.php b/resources/views/common/home-shelves.blade.php
new file mode 100644
index 000000000..3ae055b33
--- /dev/null
+++ b/resources/views/common/home-shelves.blade.php
@@ -0,0 +1,18 @@
+@extends('sidebar-layout')
+
+@section('toolbar')
+    <div class="col-sm-6 faded">
+        <div class="action-buttons text-left">
+            <a expand-toggle=".entity-list.compact .entity-item-snippet" class="text-primary text-button">@icon('expand-text'){{ trans('common.toggle_details') }}</a>
+            @include('shelves/view-toggle', ['shelvesViewType' => $shelvesViewType])
+        </div>
+    </div>
+@stop
+
+@section('sidebar')
+    @include('common/home-sidebar')
+@stop
+
+@section('body')
+    @include('shelves/list', ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType])
+@stop
\ No newline at end of file
diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php
index bbddb072d..cc20fc68e 100644
--- a/resources/views/common/home.blade.php
+++ b/resources/views/common/home.blade.php
@@ -10,7 +10,7 @@
 
 @section('body')
 
-    <div class="container">
+    <div class="container" id="home-default">
         <div class="row">
 
             <div class="col-sm-4">
diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php
index 64017e6e0..3c563a61c 100644
--- a/resources/views/settings/index.blade.php
+++ b/resources/views/settings/index.blade.php
@@ -76,12 +76,22 @@
                             <input type="text" value="{{ setting('app-color') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
                             <input type="hidden" value="{{ setting('app-color-light') }}" name="setting-app-color-light" id="setting-app-color-light">
                         </div>
-                        <div class="form-group" id="homepage-control">
+                        <div homepage-control class="form-group" id="homepage-control">
                             <label for="setting-app-homepage">{{ trans('settings.app_homepage') }}</label>
                             <p class="small">{{ trans('settings.app_homepage_desc') }}</p>
-                            @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_default'), 'value' => setting('app-homepage')])
-                            <p class="small">{{ trans('settings.app_homepage_books') }}</p>
-                            @include('components.toggle-switch', ['name' => 'setting-app-book-homepage', 'value' => setting('app-book-homepage')])
+
+                            <select name="setting-app-homepage-type" id="setting-app-homepage-type">
+                                <option @if(setting('app-homepage-type') === 'default') selected @endif value="default">{{ trans('common.default') }}</option>
+                                <option @if(setting('app-homepage-type') === 'books') selected @endif value="books">{{ trans('entities.books') }}</option>
+                                <option @if(setting('app-homepage-type') === 'bookshelves') selected @endif value="bookshelves">{{ trans('entities.shelves') }}</option>
+                                <option @if(setting('app-homepage-type') === 'page') selected @endif value="page">{{ trans('entities.pages_specific') }}</option>
+                            </select>
+
+                            <br><br>
+
+                            <div page-picker-container style="display: none;">
+                                @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
+                            </div>
                         </div>
                     </div>
 
diff --git a/tests/HomepageTest.php b/tests/HomepageTest.php
index 29e0985c3..86cae7893 100644
--- a/tests/HomepageTest.php
+++ b/tests/HomepageTest.php
@@ -10,15 +10,17 @@ class HomepageTest extends TestCase
         $homeVisit->assertSee('My Recently Viewed');
         $homeVisit->assertSee('Recently Updated Pages');
         $homeVisit->assertSee('Recent Activity');
+        $homeVisit->assertSee('home-default');
     }
 
     public function test_custom_homepage()
     {
         $this->asEditor();
         $name = 'My custom homepage';
-        $content = 'This is the body content of my custom homepage.';
+        $content = str_repeat('This is the body content of my custom homepage.', 20);
         $customPage = $this->newPage(['name' => $name, 'html' => $content]);
         $this->setSettings(['app-homepage' => $customPage->id]);
+        $this->setSettings(['app-homepage-type' => 'page']);
 
         $homeVisit = $this->get('/');
         $homeVisit->assertSee($name);
@@ -32,7 +34,7 @@ class HomepageTest extends TestCase
     {
         $this->asEditor();
         $name = 'My custom homepage';
-        $content = 'This is the body content of my custom homepage.';
+        $content = str_repeat('This is the body content of my custom homepage.', 20);
         $customPage = $this->newPage(['name' => $name, 'html' => $content]);
         $this->setSettings(['app-homepage' => $customPage->id]);
 
@@ -55,7 +57,7 @@ class HomepageTest extends TestCase
         $editor = $this->getEditor();
         setting()->putUser($editor, 'books_view_type', 'grid');
 
-        $this->setSettings(['app-book-homepage' => true]);
+        $this->setSettings(['app-homepage-type' => 'books']);
 
         $this->asEditor();
         $homeVisit = $this->get('/');
@@ -65,7 +67,26 @@ class HomepageTest extends TestCase
         $homeVisit->assertSee('grid-card-footer');
         $homeVisit->assertSee('featured-image-container');
 
-        $this->setSettings(['app-book-homepage' => false]);
+        $this->setSettings(['app-homepage-type' => false]);
+        $this->test_default_homepage_visible();
+    }
+
+    public function test_set_bookshelves_homepage()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'bookshelves_view_type', 'grid');
+
+        $this->setSettings(['app-homepage-type' => 'bookshelves']);
+
+        $this->asEditor();
+        $homeVisit = $this->get('/');
+        $homeVisit->assertSee('Shelves');
+        $homeVisit->assertSee('bookshelf-grid-item grid-card');
+        $homeVisit->assertSee('grid-card-content');
+        $homeVisit->assertSee('grid-card-footer');
+        $homeVisit->assertSee('featured-image-container');
+
+        $this->setSettings(['app-homepage-type' => false]);
         $this->test_default_homepage_visible();
     }
 }

From 0b6f83837b779a81f3258f6485dbf8703997a495 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 20 Sep 2018 16:03:01 +0100
Subject: [PATCH 07/11] Removed joint_permission generation in older migration

---
 .../2016_04_20_192649_create_joint_permissions_table.php      | 4 ----
 1 file changed, 4 deletions(-)

diff --git a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php
index 4c1b43c4e..ce11f7b88 100644
--- a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php
+++ b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php
@@ -74,10 +74,6 @@ class CreateJointPermissionsTable extends Migration
 
         // Update admin role with system name
         DB::table('roles')->where('name', '=', 'admin')->update(['system_name' => 'admin']);
-
-        // Generate the new entity jointPermissions
-        $restrictionService = app(\BookStack\Services\PermissionService::class);
-        $restrictionService->buildJointPermissions();
     }
 
     /**

From 6eead437d86eb4a1be07d03fe23bd756555cb509 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 20 Sep 2018 19:16:11 +0100
Subject: [PATCH 08/11] Added bookshelf permission control UI and copy-down
 ability

---
 app/Http/Controllers/BookshelfController.php  | 83 ++++++++++++-------
 app/Repos/EntityRepo.php                      | 25 ++++++
 resources/lang/en/entities.php                |  9 +-
 .../views/shelves/restrictions.blade.php      | 21 ++++-
 routes/web.php                                |  3 +
 5 files changed, 104 insertions(+), 37 deletions(-)

diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
index 02b6299ce..d1752d180 100644
--- a/app/Http/Controllers/BookshelfController.php
+++ b/app/Http/Controllers/BookshelfController.php
@@ -189,37 +189,56 @@ class BookshelfController extends Controller
         $this->entityRepo->destroyBookshelf($bookshelf);
         return redirect('/shelves');
     }
-//
-//    /**
-//     * Show the Restrictions view.
-//     * @param $bookSlug
-//     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
-//     */
-//    public function showRestrict($bookSlug)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $this->checkOwnablePermission('restrictions-manage', $book);
-//        $roles = $this->userRepo->getRestrictableRoles();
-//        return view('books/restrictions', [
-//            'book' => $book,
-//            'roles' => $roles
-//        ]);
-//    }
-//
-//    /**
-//     * Set the restrictions for this book.
-//     * @param $bookSlug
-//     * @param $bookSlug
-//     * @param Request $request
-//     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
-//     */
-//    public function restrict($bookSlug, Request $request)
-//    {
-//        $book = $this->entityRepo->getBySlug('book', $bookSlug);
-//        $this->checkOwnablePermission('restrictions-manage', $book);
-//        $this->entityRepo->updateEntityPermissionsFromRequest($request, $book);
-//        session()->flash('success', trans('entities.books_permissions_updated'));
-//        return redirect($book->getUrl());
-//    }
+
+    /**
+     * Show the Restrictions view.
+     * @param $slug
+     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function showRestrict(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug);
+        $this->checkOwnablePermission('restrictions-manage', $bookshelf);
+
+        $roles = $this->userRepo->getRestrictableRoles();
+        return view('shelves.restrictions', [
+            'shelf' => $bookshelf,
+            'roles' => $roles
+        ]);
+    }
+
+    /**
+     * Set the restrictions for this bookshelf.
+     * @param $slug
+     * @param Request $request
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function restrict(string $slug, Request $request)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug);
+        $this->checkOwnablePermission('restrictions-manage', $bookshelf);
+
+        $this->entityRepo->updateEntityPermissionsFromRequest($request, $bookshelf);
+        session()->flash('success', trans('entities.shelves_permissions_updated'));
+        return redirect($bookshelf->getUrl());
+    }
+
+    /**
+     * Copy the permissions of a bookshelf to the child books.
+     * @param string $slug
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function copyPermissions(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug);
+        $this->checkOwnablePermission('restrictions-manage', $bookshelf);
+
+        $updateCount = $this->entityRepo->copyBookshelfPermissions($bookshelf);
+        session()->flash('success', trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
+        return redirect($bookshelf->getUrl());
+    }
 
 }
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index db9226411..ccccd95f4 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -1282,4 +1282,29 @@ class EntityRepo
         $this->permissionService->deleteJointPermissionsForEntity($entity);
         $this->searchService->deleteEntityTerms($entity);
     }
+
+    /**
+     * Copy the permissions of a bookshelf to all child books.
+     * Returns the number of books that had permissions updated.
+     * @param Bookshelf $bookshelf
+     * @return int
+     */
+    public function copyBookshelfPermissions(Bookshelf $bookshelf)
+    {
+        $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
+        $shelfBooks = $bookshelf->books()->get();
+        $updatedBookCount = 0;
+
+        foreach ($shelfBooks as $book) {
+            if (!userCan('restrictions-manage', $book)) continue;
+            $book->permissions()->delete();
+            $book->restricted = $bookshelf->restricted;
+            $book->permissions()->createMany($shelfPermissions);
+            $book->save();
+            $this->permissionService->buildJointPermissionsForEntity($book);
+            $updatedBookCount++;
+        }
+
+        return $updatedBookCount;
+    }
 }
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 2228da2cd..44ab2c9cc 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -68,7 +68,7 @@ return [
      * Shelves
      */
     'shelves' => 'Shelves',
-    'shelves_long' => 'BookShelves',
+    'shelves_long' => 'Bookshelves',
     'shelves_empty' => 'No shelves have been created',
     'shelves_create' => 'Create New Shelf',
     'shelves_popular' => 'Popular Shelves',
@@ -87,6 +87,13 @@ return [
     'shelves_delete_named' => 'Delete Bookshelf :name',
     'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
     'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
+    'shelves_permissions' => 'Bookshelf Permissions',
+    'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
+    'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
+    'shelves_copy_permissions' => 'Copy Permissions',
+    'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
+    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
 
     /**
      * Books
diff --git a/resources/views/shelves/restrictions.blade.php b/resources/views/shelves/restrictions.blade.php
index 2a6eb0bea..472078ad2 100644
--- a/resources/views/shelves/restrictions.blade.php
+++ b/resources/views/shelves/restrictions.blade.php
@@ -2,18 +2,31 @@
 
 @section('toolbar')
     <div class="col-sm-12 faded">
-        @include('books._breadcrumbs', ['book' => $book])
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
     </div>
 @stop
 
 @section('body')
 
-    <div class="container">
+    <div class="container small">
         <p>&nbsp;</p>
         <div class="card">
-            <h3>@icon('lock') {{ trans('entities.books_permissions') }}</h3>
+            <h3>@icon('lock') {{ trans('entities.shelves_permissions') }}</h3>
             <div class="body">
-                @include('form/restriction-form', ['model' => $book])
+                @include('form/restriction-form', ['model' => $shelf])
+            </div>
+        </div>
+
+        <p>&nbsp;</p>
+
+        <div class="card">
+            <h3>@icon('copy') {{ trans('entities.shelves_copy_permissions_to_books') }}</h3>
+            <div class="body">
+                <p>{{ trans('entities.shelves_copy_permissions_explain') }}</p>
+                <form action="{{ $shelf->getUrl('/copy-permissions') }}" method="post" class="text-right">
+                    {{ csrf_field() }}
+                    <button class="button">{{ trans('entities.shelves_copy_permissions') }}</button>
+                </form>
             </div>
         </div>
     </div>
diff --git a/routes/web.php b/routes/web.php
index be0b2da6e..4bdd5fc09 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -24,6 +24,9 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/{slug}', 'BookshelfController@show');
         Route::put('/{slug}', 'BookshelfController@update');
         Route::delete('/{slug}', 'BookshelfController@destroy');
+        Route::get('/{slug}/permissions', 'BookshelfController@showRestrict');
+        Route::put('/{slug}/permissions', 'BookshelfController@restrict');
+        Route::post('/{slug}/copy-permissions', 'BookshelfController@copyPermissions');
     });
 
     Route::get('/create-book', 'BookController@create');

From 8ff969dd17475fe25c862825f5cdbed66ffc9bbd Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 20 Sep 2018 19:48:08 +0100
Subject: [PATCH 09/11] Updated so permission effect admins more

Asset permissions can now be configured for admins.
joint_permissions will now effect admins more often.
Made so shelves header link will hide if you have no bookshelves view
permission.
---
 app/Repos/PermissionsRepo.php                 | 15 ++++--
 app/Services/PermissionService.php            | 52 ++++---------------
 resources/lang/en/settings.php                |  1 +
 resources/views/base.blade.php                |  4 +-
 resources/views/settings/roles/form.blade.php |  4 ++
 5 files changed, 27 insertions(+), 49 deletions(-)

diff --git a/app/Repos/PermissionsRepo.php b/app/Repos/PermissionsRepo.php
index 6f7ea1dc8..68c9270be 100644
--- a/app/Repos/PermissionsRepo.php
+++ b/app/Repos/PermissionsRepo.php
@@ -80,7 +80,7 @@ class PermissionsRepo
 
     /**
      * Updates an existing role.
-     * Ensure Admin role always has all permissions.
+     * Ensure Admin role always have core permissions.
      * @param $roleId
      * @param $roleData
      * @throws PermissionsException
@@ -90,13 +90,18 @@ class PermissionsRepo
         $role = $this->role->findOrFail($roleId);
 
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
-        $this->assignRolePermissions($role, $permissions);
-
         if ($role->system_name === 'admin') {
-            $permissions = $this->permission->all()->pluck('id')->toArray();
-            $role->permissions()->sync($permissions);
+            $permissions = array_merge($permissions, [
+                'users-manage',
+                'user-roles-manage',
+                'restrictions-manage-all',
+                'restrictions-manage-own',
+                'settings-manage',
+            ]);
         }
 
+        $this->assignRolePermissions($role, $permissions);
+
         $role->fill($roleData);
         $role->save();
         $this->permissionService->buildJointPermissionForRole($role);
diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php
index 13ec1d45b..dade68290 100644
--- a/app/Services/PermissionService.php
+++ b/app/Services/PermissionService.php
@@ -60,7 +60,6 @@ class PermissionService
         $this->book = $book;
         $this->chapter = $chapter;
         $this->page = $page;
-        // TODO - Update so admin still goes through filters
     }
 
     /**
@@ -520,11 +519,6 @@ class PermissionService
      */
     public function checkOwnableUserAccess(Ownable $ownable, $permission)
     {
-        if ($this->isAdmin()) {
-            $this->clean();
-            return true;
-        }
-
         $explodedPermission = explode('-', $permission);
 
         $baseQuery = $ownable->where('id', '=', $ownable->id);
@@ -617,17 +611,16 @@ class PermissionService
         $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
             ->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
 
-        if (!$this->isAdmin()) {
-            $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
-                ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
-                ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
-                ->where(function ($query) {
-                    $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
-                        $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
-                    });
+        // Add joint permission filter
+        $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
+            ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
+            ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
+            ->where(function ($query) {
+                $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
+                    $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
                 });
-            $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
-        }
+            });
+        $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
 
         $query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
         $this->clean();
@@ -655,11 +648,6 @@ class PermissionService
             });
         }
 
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
-
         $this->currentAction = $action;
         return $this->entityRestrictionQuery($query);
     }
@@ -675,10 +663,6 @@ class PermissionService
      */
     public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
     {
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
 
         $this->currentAction = $action;
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
@@ -711,11 +695,6 @@ class PermissionService
      */
     public function filterRelatedPages($query, $tableName, $entityIdColumn)
     {
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
-
         $this->currentAction = 'view';
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
 
@@ -740,19 +719,6 @@ class PermissionService
         return $q;
     }
 
-    /**
-     * Check if the current user is an admin.
-     * @return bool
-     */
-    private function isAdmin()
-    {
-        if ($this->isAdminUser === null) {
-            $this->isAdminUser = ($this->currentUser()->id !== null) ? $this->currentUser()->hasSystemRole('admin') : false;
-        }
-
-        return $this->isAdminUser;
-    }
-
     /**
      * Get the current user
      * @return User
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index e2c2ede2b..80ab77d19 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -90,6 +90,7 @@ return [
     'role_manage_settings' => 'Manage app settings',
     'role_asset' => 'Asset Permissions',
     'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
+    'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
     'role_all' => 'All',
     'role_own' => 'Own',
     'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php
index 93ee6cdc6..016f8e833 100644
--- a/resources/views/base.blade.php
+++ b/resources/views/base.blade.php
@@ -52,7 +52,9 @@
                             </form>
                         </div>
                         <div class="links text-center">
-                            <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
+                            @if(userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+                                <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
+                            @endif
                             <a href="{{ baseUrl('/books') }}">@icon('book'){{ trans('entities.books') }}</a>
                             @if(signedInUser() && userCan('settings-manage'))
                                 <a href="{{ baseUrl('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
index 44cdbb3c0..619229a65 100644
--- a/resources/views/settings/roles/form.blade.php
+++ b/resources/views/settings/roles/form.blade.php
@@ -36,6 +36,10 @@
                     <h5>{{ trans('settings.role_asset') }}</h5>
                     <p>{{ trans('settings.role_asset_desc') }}</p>
 
+                    @if (isset($role) && $role->system_name === 'admin')
+                        <p>{{ trans('settings.role_asset_admins') }}</p>
+                    @endif
+
                     <table class="table">
                         <tr>
                             <th width="20%"></th>

From b59e5942c8c2e233752f79ff9668d3646c22c14e Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 21 Sep 2018 15:15:16 +0100
Subject: [PATCH 10/11] Added testing coverage for Bookshelves

Created modified TestResponse so we can use DOM operations in new
Testcases as we move away from the BrowserKit tests.
---
 app/Repos/EntityRepo.php               |   1 +
 resources/lang/en/errors.php           |   1 +
 tests/Entity/BookShelfTest.php         | 170 +++++++++++++++++++++++++
 tests/Permissions/RestrictionsTest.php | 113 ++++++++++++++++
 tests/Permissions/RolesTest.php        | 111 ++++++++++++----
 tests/SharedTestHelpers.php            |  47 +++++++
 tests/TestCase.php                     |  39 +++++-
 tests/TestResponse.php                 | 141 ++++++++++++++++++++
 8 files changed, 595 insertions(+), 28 deletions(-)
 create mode 100644 tests/Entity/BookShelfTest.php
 create mode 100644 tests/TestResponse.php

diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index ccccd95f4..11f89fc34 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -1288,6 +1288,7 @@ class EntityRepo
      * Returns the number of books that had permissions updated.
      * @param Bookshelf $bookshelf
      * @return int
+     * @throws \Throwable
      */
     public function copyBookshelfPermissions(Bookshelf $bookshelf)
     {
diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php
index a86a1cdfc..fb09841cf 100644
--- a/resources/lang/en/errors.php
+++ b/resources/lang/en/errors.php
@@ -49,6 +49,7 @@ return [
 
     // Entities
     'entity_not_found' => 'Entity not found',
+    'bookshelf_not_found' => 'Bookshelf not found',
     'book_not_found' => 'Book not found',
     'page_not_found' => 'Page not found',
     'chapter_not_found' => 'Chapter not found',
diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php
new file mode 100644
index 000000000..9071e3c06
--- /dev/null
+++ b/tests/Entity/BookShelfTest.php
@@ -0,0 +1,170 @@
+<?php namespace Tests;
+
+use BookStack\Book;
+use BookStack\Bookshelf;
+
+class BookShelfTest extends TestCase
+{
+
+    public function test_shelves_shows_in_header_if_have_view_permissions()
+    {
+        $viewer = $this->getViewer();
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+
+        $viewer->roles()->delete();
+        $this->giveUserPermissions($viewer);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementNotContains('header', 'Shelves');
+
+        $this->giveUserPermissions($viewer, ['bookshelf-view-all']);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+
+        $viewer->roles()->delete();
+        $this->giveUserPermissions($viewer, ['bookshelf-view-own']);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+    }
+
+    public function test_shelves_page_contains_create_link()
+    {
+        $resp = $this->asEditor()->get('/shelves');
+        $resp->assertElementContains('a', 'Create New Shelf');
+    }
+
+    public function test_shelves_create()
+    {
+        $booksToInclude = Book::take(2)->get();
+        $shelfInfo = [
+            'name' => 'My test book' . str_random(4),
+            'description' => 'Test book description ' . str_random(10)
+        ];
+        $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [
+            'books' => $booksToInclude->implode('id', ','),
+            'tags' => [
+                [
+                    'name' => 'Test Category',
+                    'value' => 'Test Tag Value',
+                ]
+            ],
+        ]));
+        $resp->assertRedirect();
+        $editorId = $this->getEditor()->id;
+        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
+
+        $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
+        $shelfPage = $this->get($shelf->getUrl());
+        $shelfPage->assertSee($shelfInfo['name']);
+        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertElementContains('.tag-item', 'Test Category');
+        $shelfPage->assertElementContains('.tag-item', 'Test Tag Value');
+
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
+    }
+
+    public function test_shelf_view()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertStatus(200);
+        $resp->assertSeeText($shelf->name);
+        $resp->assertSeeText($shelf->description);
+
+        foreach ($shelf->books as $book) {
+            $resp->assertSee($book->name);
+        }
+    }
+
+    public function test_shelf_view_shows_action_buttons()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl());
+        $resp->assertSee($shelf->getUrl('/edit'));
+        $resp->assertSee($shelf->getUrl('/permissions'));
+        $resp->assertSee($shelf->getUrl('/delete'));
+        $resp->assertElementContains('a', 'Edit');
+        $resp->assertElementContains('a', 'Permissions');
+        $resp->assertElementContains('a', 'Delete');
+
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertDontSee($shelf->getUrl('/permissions'));
+    }
+
+    public function test_shelf_edit()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/edit'));
+        $resp->assertSeeText('Edit Bookshelf');
+
+        $booksToInclude = Book::take(2)->get();
+        $shelfInfo = [
+            'name' => 'My test book' . str_random(4),
+            'description' => 'Test book description ' . str_random(10)
+        ];
+
+        $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [
+            'books' => $booksToInclude->implode('id', ','),
+            'tags' => [
+                [
+                    'name' => 'Test Category',
+                    'value' => 'Test Tag Value',
+                ]
+            ],
+        ]));
+        $shelf = Bookshelf::find($shelf->id);
+        $resp->assertRedirect($shelf->getUrl());
+        $this->assertSessionHas('success');
+
+        $editorId = $this->getEditor()->id;
+        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
+
+        $shelfPage = $this->get($shelf->getUrl());
+        $shelfPage->assertSee($shelfInfo['name']);
+        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertElementContains('.tag-item', 'Test Category');
+        $shelfPage->assertElementContains('.tag-item', 'Test Tag Value');
+
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
+    }
+
+    public function test_shelf_delete()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/delete'));
+        $resp->assertSeeText('Delete Bookshelf');
+        $resp->assertSee("action=\"{$shelf->getUrl()}\"");
+
+        $resp = $this->delete($shelf->getUrl());
+        $resp->assertRedirect('/shelves');
+        $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]);
+        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+        $this->assertSessionHas('success');
+    }
+
+    public function test_shelf_copy_permissions()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl('/permissions'));
+        $resp->assertSeeText('Copy Permissions');
+        $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\"");
+
+        $child = $shelf->books()->first();
+        $editorRole = $this->getEditor()->roles()->first();
+        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
+        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+
+        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
+        $resp = $this->post($shelf->getUrl('/copy-permissions'));
+        $child = $shelf->books()->first();
+
+        $resp->assertRedirect($shelf->getUrl());
+        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
+        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+    }
+
+}
diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php
index 2bbb1a5fa..540125fd1 100644
--- a/tests/Permissions/RestrictionsTest.php
+++ b/tests/Permissions/RestrictionsTest.php
@@ -1,6 +1,7 @@
 <?php namespace Tests;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Entity;
 use BookStack\User;
 use BookStack\Repos\EntityRepo;
@@ -34,6 +35,63 @@ class RestrictionsTest extends BrowserKitTest
         parent::setEntityRestrictions($entity, $actions, $roles);
     }
 
+    public function test_bookshelf_view_restriction()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl())
+            ->seePageIs($shelf->getUrl());
+
+        $this->setEntityRestrictions($shelf, []);
+
+        $this->forceVisit($shelf->getUrl())
+            ->see('Bookshelf not found');
+
+        $this->setEntityRestrictions($shelf, ['view']);
+
+        $this->visit($shelf->getUrl())
+            ->see($shelf->name);
+    }
+
+    public function test_bookshelf_update_restriction()
+    {
+        $shelf = BookShelf::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl('/edit'))
+            ->see('Edit Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->forceVisit($shelf->getUrl('/edit'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->visit($shelf->getUrl('/edit'))
+            ->seePageIs($shelf->getUrl('/edit'));
+    }
+
+    public function test_bookshelf_delete_restriction()
+    {
+        $shelf = Book::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl('/delete'))
+            ->see('Delete Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->forceVisit($shelf->getUrl('/delete'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->visit($shelf->getUrl('/delete'))
+            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+    }
+
     public function test_book_view_restriction()
     {
         $book = Book::first();
@@ -325,6 +383,23 @@ class RestrictionsTest extends BrowserKitTest
             ->seePageIs($pageUrl . '/delete')->see('Delete Page');
     }
 
+    public function test_bookshelf_restriction_form()
+    {
+        $shelf = Bookshelf::first();
+        $this->asAdmin()->visit($shelf->getUrl('/permissions'))
+            ->see('Bookshelf Permissions')
+            ->check('restricted')
+            ->check('restrictions[2][view]')
+            ->press('Save Permissions')
+            ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true])
+            ->seeInDatabase('entity_permissions', [
+                'restrictable_id' => $shelf->id,
+                'restrictable_type' => 'BookStack\Bookshelf',
+                'role_id' => '2',
+                'action' => 'view'
+            ]);
+    }
+
     public function test_book_restriction_form()
     {
         $book = Book::first();
@@ -413,6 +488,44 @@ class RestrictionsTest extends BrowserKitTest
             ->dontSee($page->name);
     }
 
+    public function test_bookshelf_update_restriction_override()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->viewer)
+            ->visit($shelf->getUrl('/edit'))
+            ->dontSee('Edit Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->forceVisit($shelf->getUrl('/edit'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->visit($shelf->getUrl('/edit'))
+            ->seePageIs($shelf->getUrl('/edit'));
+    }
+
+    public function test_bookshelf_delete_restriction_override()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->viewer)
+            ->visit($shelf->getUrl('/delete'))
+            ->dontSee('Delete Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->forceVisit($shelf->getUrl('/delete'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->visit($shelf->getUrl('/delete'))
+            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+    }
+
     public function test_book_create_restriction_override()
     {
         $book = Book::first();
diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php
index f076e6734..e0f827d02 100644
--- a/tests/Permissions/RolesTest.php
+++ b/tests/Permissions/RolesTest.php
@@ -1,5 +1,6 @@
 <?php namespace Tests;
 
+use BookStack\Bookshelf;
 use BookStack\Page;
 use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
@@ -16,32 +17,6 @@ class RolesTest extends BrowserKitTest
         $this->user = $this->getViewer();
     }
 
-    /**
-     * Give the given user some permissions.
-     * @param \BookStack\User $user
-     * @param array $permissions
-     */
-    protected function giveUserPermissions(\BookStack\User $user, $permissions = [])
-    {
-        $newRole = $this->createNewRole($permissions);
-        $user->attachRole($newRole);
-        $user->load('roles');
-        $user->permissions(false);
-    }
-
-    /**
-     * Create a new basic role for testing purposes.
-     * @param array $permissions
-     * @return Role
-     */
-    protected function createNewRole($permissions = [])
-    {
-        $permissionRepo = app(PermissionsRepo::class);
-        $roleData = factory(\BookStack\Role::class)->make()->toArray();
-        $roleData['permissions'] = array_flip($permissions);
-        return $permissionRepo->saveNewRole($roleData);
-    }
-
     public function test_admin_can_see_settings()
     {
         $this->asAdmin()->visit('/settings')->see('Settings');
@@ -203,6 +178,90 @@ class RolesTest extends BrowserKitTest
         }
     }
 
+    public function test_bookshelves_create_all_permissions()
+    {
+        $this->checkAccessPermission('bookshelf-create-all', [
+            '/create-shelf'
+        ], [
+            '/shelves' => 'Create New Shelf'
+        ]);
+
+        $this->visit('/create-shelf')
+            ->type('test shelf', 'name')
+            ->type('shelf desc', 'description')
+            ->press('Save Shelf')
+            ->seePageIs('/shelves/test-shelf');
+    }
+
+    public function test_bookshelves_edit_own_permission()
+    {
+        $otherShelf = Bookshelf::first();
+        $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
+        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $this->regenEntityPermissions($ownShelf);
+
+        $this->checkAccessPermission('bookshelf-update-own', [
+            $ownShelf->getUrl('/edit')
+        ], [
+            $ownShelf->getUrl() => 'Edit'
+        ]);
+
+        $this->visit($otherShelf->getUrl())
+            ->dontSeeInElement('.action-buttons', 'Edit')
+            ->visit($otherShelf->getUrl('/edit'))
+            ->seePageIs('/');
+    }
+
+    public function test_bookshelves_edit_all_permission()
+    {
+        $otherShelf = \BookStack\Bookshelf::first();
+        $this->checkAccessPermission('bookshelf-update-all', [
+            $otherShelf->getUrl('/edit')
+        ], [
+            $otherShelf->getUrl() => 'Edit'
+        ]);
+    }
+
+    public function test_bookshelves_delete_own_permission()
+    {
+        $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
+        $otherShelf = \BookStack\Bookshelf::first();
+        $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
+        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $this->regenEntityPermissions($ownShelf);
+
+        $this->checkAccessPermission('bookshelf-delete-own', [
+            $ownShelf->getUrl('/delete')
+        ], [
+            $ownShelf->getUrl() => 'Delete'
+        ]);
+
+        $this->visit($otherShelf->getUrl())
+            ->dontSeeInElement('.action-buttons', 'Delete')
+            ->visit($otherShelf->getUrl('/delete'))
+            ->seePageIs('/');
+        $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete'))
+            ->press('Confirm')
+            ->seePageIs('/shelves')
+            ->dontSee($ownShelf->name);
+    }
+
+    public function test_bookshelves_delete_all_permission()
+    {
+        $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
+        $otherShelf = \BookStack\Bookshelf::first();
+        $this->checkAccessPermission('bookshelf-delete-all', [
+            $otherShelf->getUrl('/delete')
+        ], [
+            $otherShelf->getUrl() => 'Delete'
+        ]);
+
+        $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete'))
+            ->press('Confirm')
+            ->seePageIs('/shelves')
+            ->dontSee($otherShelf->name);
+    }
+
     public function test_books_create_all_permissions()
     {
         $this->checkAccessPermission('book-create-all', [
diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php
index 325979e74..581dac5f1 100644
--- a/tests/SharedTestHelpers.php
+++ b/tests/SharedTestHelpers.php
@@ -1,9 +1,11 @@
 <?php namespace Tests;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\Repos\EntityRepo;
+use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
 use BookStack\Services\PermissionService;
 use BookStack\Services\SettingService;
@@ -69,6 +71,25 @@ trait SharedTestHelpers
         return $user;
     }
 
+    /**
+     * Regenerate the permission for an entity.
+     * @param Entity $entity
+     */
+    protected function regenEntityPermissions(Entity $entity)
+    {
+        $this->app[PermissionService::class]->buildJointPermissionsForEntity($entity);
+        $entity->load('jointPermissions');
+    }
+
+    /**
+     * Create and return a new bookshelf.
+     * @param array $input
+     * @return Bookshelf
+     */
+    public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) {
+        return $this->app[EntityRepo::class]->createFromInput('bookshelf', $input, false);
+    }
+
     /**
      * Create and return a new book.
      * @param array $input
@@ -140,4 +161,30 @@ trait SharedTestHelpers
         $entity->load('jointPermissions');
     }
 
+    /**
+     * Give the given user some permissions.
+     * @param \BookStack\User $user
+     * @param array $permissions
+     */
+    protected function giveUserPermissions(\BookStack\User $user, $permissions = [])
+    {
+        $newRole = $this->createNewRole($permissions);
+        $user->attachRole($newRole);
+        $user->load('roles');
+        $user->permissions(false);
+    }
+
+    /**
+     * Create a new basic role for testing purposes.
+     * @param array $permissions
+     * @return Role
+     */
+    protected function createNewRole($permissions = [])
+    {
+        $permissionRepo = app(PermissionsRepo::class);
+        $roleData = factory(Role::class)->make()->toArray();
+        $roleData['permissions'] = array_flip($permissions);
+        return $permissionRepo->saveNewRole($roleData);
+    }
+
 }
\ No newline at end of file
diff --git a/tests/TestCase.php b/tests/TestCase.php
index e0f160eed..939a1a91e 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,13 +2,13 @@
 
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
-use Illuminate\Foundation\Testing\TestResponse;
 
 abstract class TestCase extends BaseTestCase
 {
     use CreatesApplication;
     use DatabaseTransactions;
     use SharedTestHelpers;
+
     /**
      * The base URL to use while testing the application.
      * @var string
@@ -18,11 +18,46 @@ abstract class TestCase extends BaseTestCase
     /**
      * Assert a permission error has occurred.
      * @param TestResponse $response
+     * @return TestCase
      */
     protected function assertPermissionError(TestResponse $response)
     {
         $response->assertRedirect('/');
-        $this->assertTrue(session()->has('error'));
+        $this->assertSessionHas('error');
         session()->remove('error');
+        return $this;
+    }
+
+    /**
+     * Assert the session contains a specific entry.
+     * @param string $key
+     * @return $this
+     */
+    protected function assertSessionHas(string $key)
+    {
+        $this->assertTrue(session()->has($key), "Session does not contain a [{$key}] entry");
+        return $this;
+    }
+
+    /**
+     * Override of the get method so we can get visibility of custom TestResponse methods.
+     * @param  string  $uri
+     * @param  array  $headers
+     * @return TestResponse
+     */
+    public function get($uri, array $headers = [])
+    {
+        return parent::get($uri, $headers);
+    }
+
+    /**
+     * Create the test response instance from the given response.
+     *
+     * @param  \Illuminate\Http\Response $response
+     * @return TestResponse
+     */
+    protected function createTestResponse($response)
+    {
+        return TestResponse::fromBaseResponse($response);
     }
 }
\ No newline at end of file
diff --git a/tests/TestResponse.php b/tests/TestResponse.php
new file mode 100644
index 000000000..a68a5783f
--- /dev/null
+++ b/tests/TestResponse.php
@@ -0,0 +1,141 @@
+<?php namespace Tests;
+
+use \Illuminate\Foundation\Testing\TestResponse as BaseTestResponse;
+use Symfony\Component\DomCrawler\Crawler;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+/**
+ * Class TestResponse
+ * Custom extension of the default Laravel TestResponse class.
+ * @package Tests
+ */
+class TestResponse extends BaseTestResponse {
+
+    protected $crawlerInstance;
+
+    /**
+     * Get the DOM Crawler for the response content.
+     * @return Crawler
+     */
+    protected function crawler()
+    {
+        if (!is_object($this->crawlerInstance)) {
+            $this->crawlerInstance = new Crawler($this->getContent());
+        }
+        return $this->crawlerInstance;
+    }
+
+    /**
+     * Assert the response contains the specified element.
+     * @param string $selector
+     * @return $this
+     */
+    public function assertElementExists(string $selector)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() > 0,
+            'Unable to find element matching the selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+        return $this;
+    }
+
+    /**
+     * Assert the response does not contain the specified element.
+     * @param string $selector
+     * @return $this
+     */
+    public function assertElementNotExists(string $selector)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() === 0,
+            'Found elements matching the selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+        return $this;
+    }
+
+    /**
+     * Assert the response includes a specific element containing the given text.
+     * @param string $selector
+     * @param string $text
+     * @return $this
+     */
+    public function assertElementContains(string $selector, string $text)
+    {
+        $elements = $this->crawler()->filter($selector);
+        $matched = false;
+        $pattern = $this->getEscapedPattern($text);
+        foreach ($elements as $element) {
+            $element = new Crawler($element);
+            if (preg_match("/$pattern/i", $element->html())) {
+                $matched = true;
+                break;
+            }
+        }
+
+        PHPUnit::assertTrue(
+            $matched,
+            'Unable to find element of selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'containing text'.PHP_EOL.PHP_EOL.
+            "[{$text}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Assert the response does not include a specific element containing the given text.
+     * @param string $selector
+     * @param string $text
+     * @return $this
+     */
+    public function assertElementNotContains(string $selector, string $text)
+    {
+        $elements = $this->crawler()->filter($selector);
+        $matched = false;
+        $pattern = $this->getEscapedPattern($text);
+        foreach ($elements as $element) {
+            $element = new Crawler($element);
+            if (preg_match("/$pattern/i", $element->html())) {
+                $matched = true;
+                break;
+            }
+        }
+
+        PHPUnit::assertTrue(
+            !$matched,
+            'Found element of selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'containing text'.PHP_EOL.PHP_EOL.
+            "[{$text}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Get the escaped text pattern for the constraint.
+     * @param  string  $text
+     * @return string
+     */
+    protected function getEscapedPattern($text)
+    {
+        $rawPattern = preg_quote($text, '/');
+        $escapedPattern = preg_quote(e($text), '/');
+        return $rawPattern == $escapedPattern
+            ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
+    }
+
+}

From c8d893fac73dc1a2c1cd33a450305d1a9fa7c850 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 21 Sep 2018 15:24:29 +0100
Subject: [PATCH 11/11] Updated 404 test to not fail based on random long name

---
 tests/ErrorTest.php | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php
index c9b5a0109..a5e4a4a5e 100644
--- a/tests/ErrorTest.php
+++ b/tests/ErrorTest.php
@@ -9,10 +9,13 @@ class ErrorTest extends TestCase
         // if our custom, middleware-loaded handler fails but this is here
         // as a reminder and as a general check in the event of other issues.
         $editor = $this->getEditor();
+        $editor->name = 'tester';
+        $editor->save();
+
         $this->actingAs($editor);
         $notFound = $this->get('/fgfdngldfnotfound');
         $notFound->assertStatus(404);
         $notFound->assertDontSeeText('Log in');
-        $notFound->assertSeeText($editor->getShortName(9));
+        $notFound->assertSeeText('tester');
     }
 }
\ No newline at end of file