diff --git a/.gitignore b/.gitignore
index 5f41a864e..e7e053505 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,16 +8,15 @@ Homestead.yaml
 /public/css
 /public/js
 /public/bower
+/public/build/
 /storage/images
 _ide_helper.php
 /storage/debugbar
 .phpstorm.meta.php
 yarn.lock
 /bin
+nbproject
 .buildpath
-
 .project
-
 .settings/org.eclipse.wst.common.project.facet.core.xml
-
 .settings/org.eclipse.php.core.prefs
diff --git a/.travis.yml b/.travis.yml
index 909e3e1f4..839d3be3f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -25,4 +25,4 @@ after_failure:
   - cat storage/logs/laravel.log
 
 script:
-  - phpunit
\ No newline at end of file
+  - phpunit
diff --git a/app/Comment.php b/app/Comment.php
new file mode 100644
index 000000000..de01b6212
--- /dev/null
+++ b/app/Comment.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace BookStack;
+
+class Comment extends Ownable
+{
+    public $sub_comments = [];
+    protected $fillable = ['text', 'html', 'parent_id'];
+    protected $appends = ['created', 'updated', 'sub_comments'];
+    /**
+     * Get the entity that this comment belongs to
+     * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+     */
+    public function entity()
+    {
+        return $this->morphTo('entity');
+    }
+
+    /**
+     * Get the page that this comment is in.
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function page()
+    {
+        return $this->belongsTo(Page::class);
+    }
+
+    /**
+     * Get the owner of this comment.
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function user()
+    {
+        return $this->belongsTo(User::class);
+    }
+
+    /*
+     * Not being used, but left here because might be used in the future for performance reasons.
+     */
+    public function getPageComments($pageId) {
+        $query = static::newQuery();
+        $query->join('users AS u', 'comments.created_by', '=', 'u.id');
+        $query->leftJoin('users AS u1', 'comments.updated_by', '=', 'u1.id');
+        $query->leftJoin('images AS i', 'i.id', '=', 'u.image_id');
+        $query->selectRaw('comments.id, text, html, comments.created_by, comments.updated_by, '
+                . 'comments.created_at, comments.updated_at, comments.parent_id, '
+                . 'u.name AS created_by_name, u1.name AS updated_by_name, '
+                . 'i.url AS avatar ');
+        $query->whereRaw('page_id = ?', [$pageId]);
+        $query->orderBy('created_at');
+        return $query->get();
+    }
+
+    public function getAllPageComments($pageId) {
+        return self::where('page_id', '=', $pageId)->with(['createdBy' => function($query) {
+            $query->select('id', 'name', 'image_id');
+        }, 'updatedBy' => function($query) {
+            $query->select('id', 'name');
+        }, 'createdBy.avatar' => function ($query) {
+            $query->select('id', 'path', 'url');
+        }])->get();
+    }
+
+    public function getCommentById($commentId) {
+        return self::where('id', '=', $commentId)->with(['createdBy' => function($query) {
+            $query->select('id', 'name', 'image_id');
+        }, 'updatedBy' => function($query) {
+            $query->select('id', 'name');
+        }, 'createdBy.avatar' => function ($query) {
+            $query->select('id', 'path', 'url');
+        }])->first();
+    }
+
+    public function getCreatedAttribute() {
+        $created = [
+            'day_time_str' => $this->created_at->toDayDateTimeString(),
+            'diff' => $this->created_at->diffForHumans()
+        ];
+        return $created;
+    }
+
+    public function getUpdatedAttribute() {
+        if (empty($this->updated_at)) {
+            return null;
+        }
+        $updated = [
+            'day_time_str' => $this->updated_at->toDayDateTimeString(),
+            'diff' => $this->updated_at->diffForHumans()
+        ];
+        return $updated;
+    }
+
+    public function getSubCommentsAttribute() {
+        return $this->sub_comments;
+    }
+}
diff --git a/app/Console/Commands/UpgradeDatabaseEncoding.php b/app/Console/Commands/UpgradeDatabaseEncoding.php
new file mode 100644
index 000000000..a17fc9523
--- /dev/null
+++ b/app/Console/Commands/UpgradeDatabaseEncoding.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace BookStack\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class UpgradeDatabaseEncoding extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'bookstack:db-utf8mb4 {--database= : The database connection to use.}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Generate SQL commands to upgrade the database to UTF8mb4';
+
+    /**
+     * Create a new command instance.
+     *
+     */
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the console command.
+     *
+     * @return mixed
+     */
+    public function handle()
+    {
+        $connection = DB::getDefaultConnection();
+        if ($this->option('database') !== null) {
+            DB::setDefaultConnection($this->option('database'));
+        }
+
+        $database = DB::getDatabaseName();
+        $tables = DB::select('SHOW TABLES');
+        $this->line('ALTER DATABASE `'.$database.'` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+        $this->line('USE `'.$database.'`;');
+        $key = 'Tables_in_' . $database;
+        foreach ($tables as $table) {
+            $tableName = $table->$key;
+            $this->line('ALTER TABLE `'.$tableName.'` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+        }
+
+        DB::setDefaultConnection($connection);
+    }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 4fa0b3c80..af9f5fd46 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -15,7 +15,8 @@ class Kernel extends ConsoleKernel
         Commands\ClearActivity::class,
         Commands\ClearRevisions::class,
         Commands\RegeneratePermissions::class,
-        Commands\RegenerateSearch::class
+        Commands\RegenerateSearch::class,
+        Commands\UpgradeDatabaseEncoding::class
     ];
 
     /**
diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php
index 8b0ef309a..9a23fe2a1 100644
--- a/app/Http/Controllers/Auth/RegisterController.php
+++ b/app/Http/Controllers/Auth/RegisterController.php
@@ -8,6 +8,7 @@ use BookStack\Exceptions\UserRegistrationException;
 use BookStack\Repos\UserRepo;
 use BookStack\Services\EmailConfirmationService;
 use BookStack\Services\SocialAuthService;
+use BookStack\SocialAccount;
 use BookStack\User;
 use Exception;
 use Illuminate\Http\Request;
@@ -103,7 +104,7 @@ class RegisterController extends Controller
      * @param Request|\Illuminate\Http\Request $request
      * @return Response
      * @throws UserRegistrationException
-     * @throws \Illuminate\Foundation\Validation\ValidationException
+     * @throws \Illuminate\Validation\ValidationException
      */
     public function postRegister(Request $request)
     {
@@ -255,16 +256,13 @@ class RegisterController extends Controller
      */
     public function socialCallback($socialDriver)
     {
-        if (session()->has('social-callback')) {
-            $action = session()->pull('social-callback');
-            if ($action == 'login') {
-                return $this->socialAuthService->handleLoginCallback($socialDriver);
-            } elseif ($action == 'register') {
-                return $this->socialRegisterCallback($socialDriver);
-            }
-        } else {
+        if (!session()->has('social-callback')) {
             throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
         }
+
+        $action = session()->pull('social-callback');
+        if ($action == 'login') return $this->socialAuthService->handleLoginCallback($socialDriver);
+        if ($action == 'register') return $this->socialRegisterCallback($socialDriver);
         return redirect()->back();
     }
 
diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php
new file mode 100644
index 000000000..e8d5eab30
--- /dev/null
+++ b/app/Http/Controllers/CommentController.php
@@ -0,0 +1,99 @@
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Repos\CommentRepo;
+use BookStack\Repos\EntityRepo;
+use BookStack\Comment;
+use Illuminate\Http\Request;
+
+class CommentController extends Controller
+{
+    protected $entityRepo;
+
+    public function __construct(EntityRepo $entityRepo, CommentRepo $commentRepo, Comment $comment)
+    {
+        $this->entityRepo = $entityRepo;
+        $this->commentRepo = $commentRepo;
+        $this->comment = $comment;
+        parent::__construct();
+    }
+
+    public function save(Request $request, $pageId, $commentId = null)
+    {
+        $this->validate($request, [
+            'text' => 'required|string',
+            'html' => 'required|string',
+        ]);
+
+        try {
+            $page = $this->entityRepo->getById('page', $pageId, true);
+        } catch (ModelNotFoundException $e) {
+            return response('Not found', 404);
+        }
+
+        if($page->draft) {
+            // cannot add comments to drafts.
+            return response()->json([
+                'status' => 'error',
+                'message' => trans('errors.cannot_add_comment_to_draft'),
+            ], 400);
+        }
+
+        $this->checkOwnablePermission('page-view', $page);
+        if (empty($commentId)) {
+            // create a new comment.
+            $this->checkPermission('comment-create-all');
+            $comment = $this->commentRepo->create($page, $request->only(['text', 'html', 'parent_id']));
+            $respMsg = trans('entities.comment_created');
+        } else {
+            // update existing comment
+            // get comment by ID and check if this user has permission to update.
+            $comment = $this->comment->findOrFail($commentId);
+            $this->checkOwnablePermission('comment-update', $comment);
+            $this->commentRepo->update($comment, $request->all());
+            $respMsg = trans('entities.comment_updated');
+        }
+
+        $comment = $this->commentRepo->getCommentById($comment->id);
+
+        return response()->json([
+            'status'    => 'success',
+            'message'   => $respMsg,
+            'comment'   => $comment
+        ]);
+
+    }
+
+    public function destroy($id) {
+        $comment = $this->comment->findOrFail($id);
+        $this->checkOwnablePermission('comment-delete', $comment);
+        $this->commentRepo->delete($comment);
+        $updatedComment = $this->commentRepo->getCommentById($comment->id);
+
+        return response()->json([
+            'status' => 'success',
+            'message' => trans('entities.comment_deleted'),
+            'comment' => $updatedComment
+        ]);
+    }
+
+
+    public function getPageComments($pageId) {
+        try {
+            $page = $this->entityRepo->getById('page', $pageId, true);
+        } catch (ModelNotFoundException $e) {
+            return response('Not found', 404);
+        }
+
+        $this->checkOwnablePermission('page-view', $page);
+
+        $comments = $this->commentRepo->getPageComments($pageId);
+        return response()->json(['status' => 'success', 'comments'=> $comments['comments'],
+            'total' => $comments['total'], 'permissions' => [
+                'comment_create' => $this->currentUser->can('comment-create-all'),
+                'comment_update_own' => $this->currentUser->can('comment-update-own'),
+                'comment_update_all' => $this->currentUser->can('comment-update-all'),
+                'comment_delete_all' => $this->currentUser->can('comment-delete-all'),
+                'comment_delete_own' => $this->currentUser->can('comment-delete-own'),
+            ], 'user_id' => $this->currentUser->id]);
+    }
+}
diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php
index c97597bc4..9a8525c23 100644
--- a/app/Http/Controllers/PageController.php
+++ b/app/Http/Controllers/PageController.php
@@ -161,7 +161,7 @@ class PageController extends Controller
         $pageContent = $this->entityRepo->renderPage($page);
         $sidebarTree = $this->entityRepo->getBookChildren($page->book);
         $pageNav = $this->entityRepo->getPageNav($pageContent);
-        
+
         Views::add($page);
         $this->setPageTitle($page->getShortName());
         return view('pages/show', [
@@ -376,7 +376,7 @@ class PageController extends Controller
 
         $page->fill($revision->toArray());
         $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
-        
+
         return view('pages/revision', [
             'page' => $page,
             'book' => $page->book,
diff --git a/app/Page.php b/app/Page.php
index c9823e7e4..d722e4e54 100644
--- a/app/Page.php
+++ b/app/Page.php
@@ -66,6 +66,10 @@ class Page extends Entity
         return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
     }
 
+    public function comments() {
+        return $this->hasMany(Comment::class, 'page_id')->orderBy('created_on', 'asc');
+    }
+
     /**
      * Get the url for this page.
      * @param string|bool $path
diff --git a/app/Repos/CommentRepo.php b/app/Repos/CommentRepo.php
new file mode 100644
index 000000000..ce71b9234
--- /dev/null
+++ b/app/Repos/CommentRepo.php
@@ -0,0 +1,105 @@
+<?php namespace BookStack\Repos;
+
+use BookStack\Comment;
+use BookStack\Page;
+
+/**
+ * Class TagRepo
+ * @package BookStack\Repos
+ */
+class CommentRepo {
+    /**
+     *
+     * @var Comment $comment
+     */
+    protected $comment;
+
+    public function __construct(Comment $comment)
+    {
+        $this->comment = $comment;
+    }
+
+    public function create (Page $page, $data = []) {
+        $userId = user()->id;
+        $comment = $this->comment->newInstance();
+        $comment->fill($data);
+        // new comment
+        $comment->page_id = $page->id;
+        $comment->created_by = $userId;
+        $comment->updated_at = null;
+        $comment->save();
+        return $comment;
+    }
+
+    public function update($comment, $input, $activeOnly = true) {
+        $userId = user()->id;
+        $comment->updated_by = $userId;
+        $comment->fill($input);
+
+        // only update active comments by default.
+        $whereClause = ['active' => 1];
+        if (!$activeOnly) {
+            $whereClause = [];
+        }
+        $comment->update($whereClause);
+        return $comment;
+    }
+
+    public function delete($comment) {
+        $comment->text = trans('entities.comment_deleted');
+        $comment->html = trans('entities.comment_deleted');
+        $comment->active = false;
+        $userId = user()->id;
+        $comment->updated_by = $userId;
+        $comment->save();
+        return $comment;
+    }
+
+    public function getPageComments($pageId) {
+        $comments = $this->comment->getAllPageComments($pageId);
+        $index = [];
+        $totalComments = count($comments);
+        $finalCommentList = [];
+
+        // normalizing the response.
+        for ($i = 0; $i < count($comments); ++$i) {
+            $comment = $this->normalizeComment($comments[$i]);
+            $parentId = $comment->parent_id;
+            if (empty($parentId)) {
+                $finalCommentList[] = $comment;
+                $index[$comment->id] = $comment;
+                continue;
+            }
+
+            if (empty($index[$parentId])) {
+                // weird condition should not happen.
+                continue;
+            }
+            if (empty($index[$parentId]->sub_comments)) {
+                $index[$parentId]->sub_comments = [];
+            }
+            array_push($index[$parentId]->sub_comments, $comment);
+            $index[$comment->id] = $comment;
+        }
+        return [
+            'comments' => $finalCommentList,
+            'total' => $totalComments
+        ];
+    }
+
+    public function getCommentById($commentId) {
+        return $this->normalizeComment($this->comment->getCommentById($commentId));
+    }
+
+    private function normalizeComment($comment) {
+        if (empty($comment)) {
+            return;
+        }
+        $comment->createdBy->avatar_url = $comment->createdBy->getAvatar(50);
+        $comment->createdBy->profile_url = $comment->createdBy->getProfileUrl();
+        if (!empty($comment->updatedBy)) {
+            $comment->updatedBy->profile_url = $comment->updatedBy->getProfileUrl();
+        }
+        return $comment;
+    }
+}
\ No newline at end of file
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index 7bc5fc4fc..d87c40f9b 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -571,7 +571,7 @@ class EntityRepo
 
         $draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
         $draftPage->html = $this->formatHtml($input['html']);
-        $draftPage->text = strip_tags($draftPage->html);
+        $draftPage->text = $this->pageToPlainText($draftPage);
         $draftPage->draft = false;
         $draftPage->revision_count = 1;
 
@@ -713,6 +713,17 @@ class EntityRepo
         return $content;
     }
 
+    /**
+     * Get the plain text version of a page's content.
+     * @param Page $page
+     * @return string
+     */
+    public function pageToPlainText(Page $page)
+    {
+        $html = $this->renderPage($page);
+        return strip_tags($html);
+    }
+
     /**
      * Get a new draft page instance.
      * @param Book $book
@@ -816,7 +827,7 @@ class EntityRepo
         $userId = user()->id;
         $page->fill($input);
         $page->html = $this->formatHtml($input['html']);
-        $page->text = strip_tags($page->html);
+        $page->text = $this->pageToPlainText($page);
         if (setting('app-editor') !== 'markdown') $page->markdown = '';
         $page->updated_by = $userId;
         $page->revision_count++;
@@ -933,7 +944,7 @@ class EntityRepo
         $revision = $page->revisions()->where('id', '=', $revisionId)->first();
         $page->fill($revision->toArray());
         $page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
-        $page->text = strip_tags($page->html);
+        $page->text = $this->pageToPlainText($page);
         $page->updated_by = user()->id;
         $page->save();
         $this->searchService->indexEntity($page);
@@ -953,7 +964,7 @@ class EntityRepo
         if ($page->draft) {
             $page->fill($data);
             if (isset($data['html'])) {
-                $page->text = strip_tags($data['html']);
+                $page->text = $this->pageToPlainText($page);
             }
             $page->save();
             return $page;
diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php
index c6c981337..93787a3e5 100644
--- a/app/Services/PermissionService.php
+++ b/app/Services/PermissionService.php
@@ -468,7 +468,7 @@ class PermissionService
         $action = end($explodedPermission);
         $this->currentAction = $action;
 
-        $nonJointPermissions = ['restrictions', 'image', 'attachment'];
+        $nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
 
         // Handle non entity specific jointPermissions
         if (in_array($explodedPermission[0], $nonJointPermissions)) {
diff --git a/config/app.php b/config/app.php
index 48348f837..a390eaf83 100644
--- a/config/app.php
+++ b/config/app.php
@@ -58,7 +58,7 @@ return [
     */
 
     'locale' => env('APP_LANG', 'en'),
-    'locales' => ['en', 'de', 'es', 'fr', 'nl', 'pt_BR', 'sk', 'ja'],
+    'locales' => ['en', 'de', 'es', 'fr', 'nl', 'pt_BR', 'sk', 'ja', 'pl'],
 
     /*
     |--------------------------------------------------------------------------
diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php
index ebf78d1fa..b03e34b9b 100644
--- a/database/factories/ModelFactory.php
+++ b/database/factories/ModelFactory.php
@@ -70,4 +70,14 @@ $factory->define(BookStack\Image::class, function ($faker) {
         'type' => 'gallery',
         'uploaded_to' => 0
     ];
+});
+
+$factory->define(BookStack\Comment::class, function($faker) {
+    $text = $faker->paragraph(3);
+    $html = '<p>' . $text. '</p>';
+    return [
+        'html' => $html,
+        'text' => $text,
+        'active' => 1
+    ];
 });
\ No newline at end of file
diff --git a/database/migrations/2017_07_02_152834_update_db_encoding_to_ut8mb4.php b/database/migrations/2017_07_02_152834_update_db_encoding_to_ut8mb4.php
index 550c95826..5681013ad 100644
--- a/database/migrations/2017_07_02_152834_update_db_encoding_to_ut8mb4.php
+++ b/database/migrations/2017_07_02_152834_update_db_encoding_to_ut8mb4.php
@@ -1,7 +1,5 @@
 <?php
 
-use Illuminate\Support\Facades\Schema;
-use Illuminate\Database\Schema\Blueprint;
 use Illuminate\Database\Migrations\Migration;
 
 class UpdateDbEncodingToUt8mb4 extends Migration
@@ -13,16 +11,9 @@ class UpdateDbEncodingToUt8mb4 extends Migration
      */
     public function up()
     {
-        $database = DB::getDatabaseName();
-        $tables = DB::select('SHOW TABLES');
-        $pdo = DB::getPdo();
-        $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
-        $pdo->exec('ALTER DATABASE `'.$database.'` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
-        $key = 'Tables_in_' . $database;
-        foreach ($tables as $table) {
-            $tableName = $table->$key;
-            $pdo->exec('ALTER TABLE `'.$tableName.'` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
-        }
+        // Migration removed due to issues during live migration.
+        // Instead you can run the command `artisan bookstack:db-utf8mb4`
+        // which will generate out the SQL request to upgrade your DB to utf8mb4.
     }
 
     /**
@@ -32,15 +23,6 @@ class UpdateDbEncodingToUt8mb4 extends Migration
      */
     public function down()
     {
-        $database = DB::getDatabaseName();
-        $tables = DB::select('SHOW TABLES');
-        $pdo = DB::getPdo();
-        $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
-        $pdo->exec('ALTER DATABASE `'.$database.'` CHARACTER SET utf8 COLLATE utf8_unicode_ci');
-        $key = 'Tables_in_' . $database;
-        foreach ($tables as $table) {
-            $tableName = $table->$key;
-            $pdo->exec('ALTER TABLE `'.$tableName.'` CONVERT TO CHARACTER SET utf8 COLLATE utf8_unicode_ci');
-        }
+        //
     }
 }
diff --git a/database/migrations/2017_08_01_130541_create_comments_table.php b/database/migrations/2017_08_01_130541_create_comments_table.php
new file mode 100644
index 000000000..bfb7eecbf
--- /dev/null
+++ b/database/migrations/2017_08_01_130541_create_comments_table.php
@@ -0,0 +1,66 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateCommentsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('comments', function (Blueprint $table) {
+            $table->increments('id')->unsigned();
+            $table->integer('page_id')->unsigned();
+            $table->longText('text')->nullable();
+            $table->longText('html')->nullable();
+            $table->integer('parent_id')->unsigned()->nullable();
+            $table->integer('created_by')->unsigned();
+            $table->integer('updated_by')->unsigned()->nullable();
+            $table->boolean('active')->default(true);
+
+            $table->index(['page_id']);
+            $table->timestamps();
+
+            // Assign new comment permissions to admin role
+            $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
+            // Create & attach new entity permissions
+            $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
+            $entity = 'Comment';
+            foreach ($ops as $op) {
+                $permissionId = DB::table('role_permissions')->insertGetId([
+                    'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
+                    'display_name' => $op . ' ' . $entity . 's',
+                    'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
+                    'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+                ]);
+                DB::table('permission_role')->insert([
+                    'role_id' => $adminRoleId,
+                    'permission_id' => $permissionId
+                ]);
+            }
+
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('comments');
+        // Delete comment role permissions
+        $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
+        $entity = 'Comment';
+        foreach ($ops as $op) {
+            $permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
+            DB::table('role_permissions')->where('name', '=', $permName)->delete();
+        }
+    }
+}
diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php
index 3d92efab1..996cd178d 100644
--- a/database/seeds/DummyContentSeeder.php
+++ b/database/seeds/DummyContentSeeder.php
@@ -20,7 +20,10 @@ class DummyContentSeeder extends Seeder
             ->each(function($book) use ($user) {
                 $chapters = factory(\BookStack\Chapter::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id])
                     ->each(function($chapter) use ($user, $book){
-                       $pages = factory(\BookStack\Page::class, 5)->make(['created_by' => $user->id, 'updated_by' => $user->id, 'book_id' => $book->id]);
+                       $pages = factory(\BookStack\Page::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id, 'book_id' => $book->id])->each(function($page) use ($user) {
+                           $comments = factory(\BookStack\Comment::class, 3)->make(['created_by' => $user->id, 'updated_by' => $user->id, 'page_id' => $page->id]);
+                           $page->comments()->saveMany($comments);
+                       });
                         $chapter->pages()->saveMany($pages);
                     });
                 $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $user->id, 'updated_by' => $user->id]);
diff --git a/gulpfile.js b/gulpfile.js
index b72bb366d..f851dd7d6 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -1,3 +1,5 @@
+'use strict';
+
 const argv = require('yargs').argv;
 const gulp = require('gulp'),
     plumber = require('gulp-plumber');
@@ -12,8 +14,10 @@ const babelify = require("babelify");
 const watchify = require("watchify");
 const envify = require("envify");
 const gutil = require("gulp-util");
+const liveReload = require('gulp-livereload');
 
 if (argv.production) process.env.NODE_ENV = 'production';
+let isProduction = argv.production || process.env.NODE_ENV === 'production';
 
 gulp.task('styles', () => {
     let chain = gulp.src(['resources/assets/sass/**/*.scss'])
@@ -24,31 +28,40 @@ gulp.task('styles', () => {
             }}))
         .pipe(sass())
         .pipe(autoprefixer('last 2 versions'));
-    if (argv.production) chain = chain.pipe(minifycss());
-    return chain.pipe(gulp.dest('public/css/'));
+    if (isProduction) chain = chain.pipe(minifycss());
+    return chain.pipe(gulp.dest('public/css/')).pipe(liveReload());
 });
 
 
-function scriptTask(watch=false) {
+function scriptTask(watch = false) {
 
     let props = {
         basedir: 'resources/assets/js',
         debug: true,
-        entries: ['global.js']
+        entries: ['global.js'],
+        fast: !isProduction,
+        cache: {},
+        packageCache: {},
     };
 
     let bundler = watch ? watchify(browserify(props), { poll: true }) : browserify(props);
-    bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']});
+
+    if (isProduction) {
+        bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']});
+    }
+
     function rebundle() {
         let stream = bundler.bundle();
         stream = stream.pipe(source('common.js'));
-        if (argv.production) stream = stream.pipe(buffer()).pipe(uglify());
-        return stream.pipe(gulp.dest('public/js/'));
+        if (isProduction) stream = stream.pipe(buffer()).pipe(uglify());
+        return stream.pipe(gulp.dest('public/js/')).pipe(liveReload());
     }
+
     bundler.on('update', function() {
         rebundle();
-        gutil.log('Rebundle...');
+        gutil.log('Rebundling assets...');
     });
+
     bundler.on('log', gutil.log);
     return rebundle();
 }
@@ -57,6 +70,7 @@ gulp.task('scripts', () => {scriptTask(false)});
 gulp.task('scripts-watch', () => {scriptTask(true)});
 
 gulp.task('default', ['styles', 'scripts-watch'], () => {
+    liveReload.listen();
     gulp.watch("resources/assets/sass/**/*.scss", ['styles']);
 });
 
diff --git a/package.json b/package.json
index 93f62bf1f..f447ec786 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,8 @@
     "build": "gulp build",
     "production": "gulp build --production",
     "dev": "gulp",
-    "watch": "gulp"
+    "watch": "gulp",
+    "permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
   },
   "devDependencies": {
     "babelify": "^7.3.0",
@@ -13,6 +14,7 @@
     "gulp": "3.9.1",
     "gulp-autoprefixer": "3.1.1",
     "gulp-clean-css": "^3.0.4",
+    "gulp-livereload": "^3.8.1",
     "gulp-minify-css": "1.2.4",
     "gulp-plumber": "1.1.0",
     "gulp-sass": "3.1.0",
@@ -29,15 +31,17 @@
     "angular-sanitize": "^1.5.5",
     "angular-ui-sortable": "^0.17.0",
     "axios": "^0.16.1",
+    "babel-polyfill": "^6.23.0",
     "babel-preset-es2015": "^6.24.1",
-    "clipboard": "^1.5.16",
+    "clipboard": "^1.7.1",
     "codemirror": "^5.26.0",
     "dropzone": "^4.0.1",
     "gulp-util": "^3.0.8",
     "markdown-it": "^8.3.1",
     "markdown-it-task-lists": "^2.0.0",
     "moment": "^2.12.0",
-    "vue": "^2.2.6"
+    "vue": "^2.2.6",
+    "vuedraggable": "^2.14.1"
   },
   "browser": {
     "vue": "vue/dist/vue.common.js"
diff --git a/public/fonts/roboto-mono-v4-latin-regular.woff b/public/fonts/roboto-mono-v4-latin-regular.woff
deleted file mode 100644
index 8cb9e6fd8..000000000
Binary files a/public/fonts/roboto-mono-v4-latin-regular.woff and /dev/null differ
diff --git a/public/fonts/roboto-mono-v4-latin-regular.woff2 b/public/fonts/roboto-mono-v4-latin-regular.woff2
deleted file mode 100644
index 1f6598111..000000000
Binary files a/public/fonts/roboto-mono-v4-latin-regular.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-100.woff b/public/fonts/roboto-v15-cyrillic_latin-100.woff
deleted file mode 100644
index 4eb2be6a1..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-100.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-100.woff2 b/public/fonts/roboto-v15-cyrillic_latin-100.woff2
deleted file mode 100644
index 007b90e85..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-100.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-100italic.woff b/public/fonts/roboto-v15-cyrillic_latin-100italic.woff
deleted file mode 100644
index fa7e51bc8..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-100italic.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-100italic.woff2 b/public/fonts/roboto-v15-cyrillic_latin-100italic.woff2
deleted file mode 100644
index f27a169cb..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-100italic.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-300.woff b/public/fonts/roboto-v15-cyrillic_latin-300.woff
deleted file mode 100644
index ace052941..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-300.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-300.woff2 b/public/fonts/roboto-v15-cyrillic_latin-300.woff2
deleted file mode 100644
index 0c093b91c..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-300.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-300italic.woff b/public/fonts/roboto-v15-cyrillic_latin-300italic.woff
deleted file mode 100644
index 7984971e7..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-300italic.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-300italic.woff2 b/public/fonts/roboto-v15-cyrillic_latin-300italic.woff2
deleted file mode 100644
index 46ed6c7cc..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-300italic.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-500.woff b/public/fonts/roboto-v15-cyrillic_latin-500.woff
deleted file mode 100644
index 8ae98f2de..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-500.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-500.woff2 b/public/fonts/roboto-v15-cyrillic_latin-500.woff2
deleted file mode 100644
index fba67842e..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-500.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-500italic.woff b/public/fonts/roboto-v15-cyrillic_latin-500italic.woff
deleted file mode 100644
index 560968d16..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-500italic.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-500italic.woff2 b/public/fonts/roboto-v15-cyrillic_latin-500italic.woff2
deleted file mode 100644
index cc41bf873..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-500italic.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-700.woff b/public/fonts/roboto-v15-cyrillic_latin-700.woff
deleted file mode 100644
index 7d19e332d..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-700.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-700.woff2 b/public/fonts/roboto-v15-cyrillic_latin-700.woff2
deleted file mode 100644
index e2274a4fb..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-700.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-700italic.woff b/public/fonts/roboto-v15-cyrillic_latin-700italic.woff
deleted file mode 100644
index 1604c8763..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-700italic.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-700italic.woff2 b/public/fonts/roboto-v15-cyrillic_latin-700italic.woff2
deleted file mode 100644
index f950ca2aa..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-700italic.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-italic.woff b/public/fonts/roboto-v15-cyrillic_latin-italic.woff
deleted file mode 100644
index d76d13d6a..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-italic.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-italic.woff2 b/public/fonts/roboto-v15-cyrillic_latin-italic.woff2
deleted file mode 100644
index a80f41528..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-italic.woff2 and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-regular.woff b/public/fonts/roboto-v15-cyrillic_latin-regular.woff
deleted file mode 100644
index a2ada2f46..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-regular.woff and /dev/null differ
diff --git a/public/fonts/roboto-v15-cyrillic_latin-regular.woff2 b/public/fonts/roboto-v15-cyrillic_latin-regular.woff2
deleted file mode 100644
index a3b35e686..000000000
Binary files a/public/fonts/roboto-v15-cyrillic_latin-regular.woff2 and /dev/null differ
diff --git a/public/logo.png b/public/logo.png
index 1803feebf..585f8895b 100644
Binary files a/public/logo.png and b/public/logo.png differ
diff --git a/readme.md b/readme.md
index 4f025e3c2..5d099ad5f 100644
--- a/readme.md
+++ b/readme.md
@@ -22,9 +22,12 @@ All development on BookStack is currently done on the master branch. When it's t
 SASS is used to help the CSS development and the JavaScript is run through browserify/babel to allow for writing ES6 code. Both of these are done using gulp. To run the build task you can use the following commands:
 
 ``` bash
-# Build and minify for production
+# Build assets for development
 npm run-script build
 
+# Build and minify assets for production
+npm run-script production
+
 # Build for dev (With sourcemaps) and watch for changes
 npm run-script dev
 ```
@@ -76,7 +79,7 @@ These are the great open-source projects used to help build BookStack:
 * [jQuery Sortable](https://johnny.github.io/jquery-sortable/)
 * [Material Design Iconic Font](http://zavoloklom.github.io/material-design-iconic-font/icons.html)
 * [Dropzone.js](http://www.dropzonejs.com/)
-* [ZeroClipboard](http://zeroclipboard.org/)
+* [clipboard.js](https://clipboardjs.com/)
 * [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html)
 * [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists)
 * [Moment.js](http://momentjs.com/)
diff --git a/resources/assets/js/components/back-top-top.js b/resources/assets/js/components/back-top-top.js
new file mode 100644
index 000000000..5fa9b3436
--- /dev/null
+++ b/resources/assets/js/components/back-top-top.js
@@ -0,0 +1,53 @@
+
+class BackToTop {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.targetElem = document.getElementById('header');
+        this.showing = false;
+        this.breakPoint = 1200;
+        this.elem.addEventListener('click', this.scrollToTop.bind(this));
+        window.addEventListener('scroll', this.onPageScroll.bind(this));
+    }
+
+    onPageScroll() {
+        let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
+        if (!this.showing && scrollTopPos > this.breakPoint) {
+            this.elem.style.display = 'block';
+            this.showing = true;
+            setTimeout(() => {
+                this.elem.style.opacity = 0.4;
+            }, 1);
+        } else if (this.showing && scrollTopPos < this.breakPoint) {
+            this.elem.style.opacity = 0;
+            this.showing = false;
+            setTimeout(() => {
+                this.elem.style.display = 'none';
+            }, 500);
+        }
+    }
+
+    scrollToTop() {
+        let targetTop = this.targetElem.getBoundingClientRect().top;
+        let scrollElem = document.documentElement.scrollTop ? document.documentElement : document.body;
+        let duration = 300;
+        let start = Date.now();
+        let scrollStart = this.targetElem.getBoundingClientRect().top;
+
+        function setPos() {
+            let percentComplete = (1-((Date.now() - start) / duration));
+            let target = Math.abs(percentComplete * scrollStart);
+            if (percentComplete > 0) {
+                scrollElem.scrollTop = target;
+                requestAnimationFrame(setPos.bind(this));
+            } else {
+                scrollElem.scrollTop = targetTop;
+            }
+        }
+
+        requestAnimationFrame(setPos.bind(this));
+    }
+
+}
+
+module.exports = BackToTop;
\ No newline at end of file
diff --git a/resources/assets/js/components/chapter-toggle.js b/resources/assets/js/components/chapter-toggle.js
new file mode 100644
index 000000000..ad373a668
--- /dev/null
+++ b/resources/assets/js/components/chapter-toggle.js
@@ -0,0 +1,67 @@
+
+class ChapterToggle {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.isOpen = elem.classList.contains('open');
+        elem.addEventListener('click', this.click.bind(this));
+    }
+
+    open() {
+        let list = this.elem.parentNode.querySelector('.inset-list');
+
+        this.elem.classList.add('open');
+        list.style.display = 'block';
+        list.style.height = '';
+        let height = list.getBoundingClientRect().height;
+        list.style.height = '0px';
+        list.style.overflow = 'hidden';
+        list.style.transition = 'height ease-in-out 240ms';
+
+        let transitionEndBound = onTransitionEnd.bind(this);
+        function onTransitionEnd() {
+            list.style.overflow = '';
+            list.style.height = '';
+            list.style.transition = '';
+            list.removeEventListener('transitionend', transitionEndBound);
+        }
+
+        setTimeout(() => {
+            list.style.height = `${height}px`;
+            list.addEventListener('transitionend', transitionEndBound)
+        }, 1);
+    }
+
+    close() {
+        let list = this.elem.parentNode.querySelector('.inset-list');
+
+        this.elem.classList.remove('open');
+        list.style.display =  'block';
+        list.style.height = list.getBoundingClientRect().height + 'px';
+        list.style.overflow = 'hidden';
+        list.style.transition = 'height ease-in-out 240ms';
+
+        let transitionEndBound = onTransitionEnd.bind(this);
+        function onTransitionEnd() {
+            list.style.overflow = '';
+            list.style.height = '';
+            list.style.transition = '';
+            list.style.display =  'none';
+            list.removeEventListener('transitionend', transitionEndBound);
+        }
+
+        setTimeout(() => {
+            list.style.height = `0px`;
+            list.addEventListener('transitionend', transitionEndBound)
+        }, 1);
+    }
+
+    click(event) {
+        event.preventDefault();
+        this.isOpen ?  this.close() : this.open();
+        this.isOpen = !this.isOpen;
+    }
+
+}
+
+module.exports = ChapterToggle;
\ No newline at end of file
diff --git a/resources/assets/js/components/dropdown.js b/resources/assets/js/components/dropdown.js
new file mode 100644
index 000000000..0401efce0
--- /dev/null
+++ b/resources/assets/js/components/dropdown.js
@@ -0,0 +1,48 @@
+/**
+ * Dropdown
+ * Provides some simple logic to create simple dropdown menus.
+ */
+class DropDown {
+
+    constructor(elem) {
+        this.container = elem;
+        this.menu = elem.querySelector('ul');
+        this.toggle = elem.querySelector('[dropdown-toggle]');
+        this.setupListeners();
+    }
+
+    show() {
+        this.menu.style.display = 'block';
+        this.menu.classList.add('anim', 'menuIn');
+        this.container.addEventListener('mouseleave', this.hide.bind(this));
+
+        // Focus on first input if existing
+        let input = this.menu.querySelector('input');
+        if (input !== null) input.focus();
+    }
+
+    hide() {
+        this.menu.style.display = 'none';
+        this.menu.classList.remove('anim', 'menuIn');
+    }
+
+    setupListeners() {
+        // Hide menu on option click
+        this.container.addEventListener('click', event => {
+             let possibleChildren = Array.from(this.menu.querySelectorAll('a'));
+             if (possibleChildren.indexOf(event.target) !== -1) this.hide();
+        });
+        // Show dropdown on toggle click
+        this.toggle.addEventListener('click', this.show.bind(this));
+        // Hide menu on enter press
+        this.container.addEventListener('keypress', event => {
+                if (event.keyCode !== 13) return true;
+                event.preventDefault();
+                this.hide();
+                return false;
+        });
+    }
+
+}
+
+module.exports = DropDown;
\ No newline at end of file
diff --git a/resources/assets/js/components/expand-toggle.js b/resources/assets/js/components/expand-toggle.js
new file mode 100644
index 000000000..61d9f54b7
--- /dev/null
+++ b/resources/assets/js/components/expand-toggle.js
@@ -0,0 +1,65 @@
+
+class ExpandToggle {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.isOpen = false;
+        this.selector = elem.getAttribute('expand-toggle');
+        elem.addEventListener('click', this.click.bind(this));
+    }
+
+    open(elemToToggle) {
+        elemToToggle.style.display = 'block';
+        elemToToggle.style.height = '';
+        let height = elemToToggle.getBoundingClientRect().height;
+        elemToToggle.style.height = '0px';
+        elemToToggle.style.overflow = 'hidden';
+        elemToToggle.style.transition = 'height ease-in-out 240ms';
+
+        let transitionEndBound = onTransitionEnd.bind(this);
+        function onTransitionEnd() {
+            elemToToggle.style.overflow = '';
+            elemToToggle.style.height = '';
+            elemToToggle.style.transition = '';
+            elemToToggle.removeEventListener('transitionend', transitionEndBound);
+        }
+
+        setTimeout(() => {
+            elemToToggle.style.height = `${height}px`;
+            elemToToggle.addEventListener('transitionend', transitionEndBound)
+        }, 1);
+    }
+
+    close(elemToToggle) {
+        elemToToggle.style.display =  'block';
+        elemToToggle.style.height = elemToToggle.getBoundingClientRect().height + 'px';
+        elemToToggle.style.overflow = 'hidden';
+        elemToToggle.style.transition = 'all ease-in-out 240ms';
+
+        let transitionEndBound = onTransitionEnd.bind(this);
+        function onTransitionEnd() {
+            elemToToggle.style.overflow = '';
+            elemToToggle.style.height = '';
+            elemToToggle.style.transition = '';
+            elemToToggle.style.display =  'none';
+            elemToToggle.removeEventListener('transitionend', transitionEndBound);
+        }
+
+        setTimeout(() => {
+            elemToToggle.style.height = `0px`;
+            elemToToggle.addEventListener('transitionend', transitionEndBound)
+        }, 1);
+    }
+
+    click(event) {
+        event.preventDefault();
+        let matchingElems = document.querySelectorAll(this.selector);
+        for (let i = 0, len = matchingElems.length; i < len; i++) {
+            this.isOpen ?  this.close(matchingElems[i]) : this.open(matchingElems[i]);
+        }
+        this.isOpen = !this.isOpen;
+    }
+
+}
+
+module.exports = ExpandToggle;
\ No newline at end of file
diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js
new file mode 100644
index 000000000..43466a0d9
--- /dev/null
+++ b/resources/assets/js/components/index.js
@@ -0,0 +1,28 @@
+
+let componentMapping = {
+    'dropdown': require('./dropdown'),
+    'overlay': require('./overlay'),
+    'back-to-top': require('./back-top-top'),
+    'notification': require('./notification'),
+    'chapter-toggle': require('./chapter-toggle'),
+    'expand-toggle': require('./expand-toggle'),
+};
+
+window.components = {};
+
+let componentNames = Object.keys(componentMapping);
+
+for (let i = 0, len = componentNames.length; i < len; i++) {
+    let name = componentNames[i];
+    let elems = document.querySelectorAll(`[${name}]`);
+    if (elems.length === 0) continue;
+
+    let component = componentMapping[name];
+    if (typeof window.components[name] === "undefined") window.components[name] = [];
+    for (let j = 0, jLen = elems.length; j < jLen; j++) {
+         let instance = new component(elems[j]);
+         if (typeof elems[j].components === 'undefined') elems[j].components = {};
+         elems[j].components[name] = instance;
+         window.components[name].push(instance);
+    }
+}
\ No newline at end of file
diff --git a/resources/assets/js/components/notification.js b/resources/assets/js/components/notification.js
new file mode 100644
index 000000000..1a9819702
--- /dev/null
+++ b/resources/assets/js/components/notification.js
@@ -0,0 +1,41 @@
+
+class Notification {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.type = elem.getAttribute('notification');
+        this.textElem = elem.querySelector('span');
+        this.autohide = this.elem.hasAttribute('data-autohide');
+        window.Events.listen(this.type, text => {
+            this.show(text);
+        });
+        elem.addEventListener('click', this.hide.bind(this));
+        if (elem.hasAttribute('data-show')) this.show(this.textElem.textContent);
+
+        this.hideCleanup = this.hideCleanup.bind(this);
+    }
+
+    show(textToShow = '') {
+        this.elem.removeEventListener('transitionend', this.hideCleanup);
+        this.textElem.textContent = textToShow;
+        this.elem.style.display = 'block';
+        setTimeout(() => {
+            this.elem.classList.add('showing');
+        }, 1);
+
+        if (this.autohide) setTimeout(this.hide.bind(this), 2000);
+    }
+
+    hide() {
+        this.elem.classList.remove('showing');
+        this.elem.addEventListener('transitionend', this.hideCleanup);
+    }
+
+    hideCleanup() {
+        this.elem.style.display = 'none';
+        this.elem.removeEventListener('transitionend', this.hideCleanup);
+    }
+
+}
+
+module.exports = Notification;
\ No newline at end of file
diff --git a/resources/assets/js/components/overlay.js b/resources/assets/js/components/overlay.js
new file mode 100644
index 000000000..6e7a598ac
--- /dev/null
+++ b/resources/assets/js/components/overlay.js
@@ -0,0 +1,39 @@
+
+class Overlay {
+
+    constructor(elem) {
+        this.container = elem;
+        elem.addEventListener('click', event => {
+             if (event.target === elem) return this.hide();
+        });
+        let closeButtons = elem.querySelectorAll('.overlay-close');
+        for (let i=0; i < closeButtons.length; i++) {
+            closeButtons[i].addEventListener('click', this.hide.bind(this));
+        }
+    }
+
+    toggle(show = true) {
+        let start = Date.now();
+        let duration = 240;
+
+        function setOpacity() {
+            let elapsedTime = (Date.now() - start);
+            let targetOpacity = show ? (elapsedTime / duration) : 1-(elapsedTime / duration);
+            this.container.style.opacity = targetOpacity;
+            if (elapsedTime > duration) {
+                this.container.style.display = show ? 'flex' : 'none';
+                this.container.style.opacity = '';
+            } else {
+                requestAnimationFrame(setOpacity.bind(this));
+            }
+        }
+
+        requestAnimationFrame(setOpacity.bind(this));
+    }
+
+    hide() { this.toggle(false); }
+    show() { this.toggle(true); }
+
+}
+
+module.exports = Overlay;
\ No newline at end of file
diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js
index ac1c3487c..8b37379fa 100644
--- a/resources/assets/js/controllers.js
+++ b/resources/assets/js/controllers.js
@@ -8,256 +8,6 @@ moment.locale('en-gb');
 
 module.exports = function (ngApp, events) {
 
-    ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService',
-        function ($scope, $attrs, $http, $timeout, imageManagerService) {
-
-            $scope.images = [];
-            $scope.imageType = $attrs.imageType;
-            $scope.selectedImage = false;
-            $scope.dependantPages = false;
-            $scope.showing = false;
-            $scope.hasMore = false;
-            $scope.imageUpdateSuccess = false;
-            $scope.imageDeleteSuccess = false;
-            $scope.uploadedTo = $attrs.uploadedTo;
-            $scope.view = 'all';
-
-            $scope.searching = false;
-            $scope.searchTerm = '';
-
-            let page = 0;
-            let previousClickTime = 0;
-            let previousClickImage = 0;
-            let dataLoaded = false;
-            let callback = false;
-
-            let preSearchImages = [];
-            let preSearchHasMore = false;
-
-            /**
-             * Used by dropzone to get the endpoint to upload to.
-             * @returns {string}
-             */
-            $scope.getUploadUrl = function () {
-                return window.baseUrl('/images/' + $scope.imageType + '/upload');
-            };
-
-            /**
-             * Cancel the current search operation.
-             */
-            function cancelSearch() {
-                $scope.searching = false;
-                $scope.searchTerm = '';
-                $scope.images = preSearchImages;
-                $scope.hasMore = preSearchHasMore;
-            }
-            $scope.cancelSearch = cancelSearch;
-
-
-            /**
-             * Runs on image upload, Adds an image to local list of images
-             * and shows a success message to the user.
-             * @param file
-             * @param data
-             */
-            $scope.uploadSuccess = function (file, data) {
-                $scope.$apply(() => {
-                    $scope.images.unshift(data);
-                });
-                events.emit('success', trans('components.image_upload_success'));
-            };
-
-            /**
-             * Runs the callback and hides the image manager.
-             * @param returnData
-             */
-            function callbackAndHide(returnData) {
-                if (callback) callback(returnData);
-                $scope.hide();
-            }
-
-            /**
-             * Image select action. Checks if a double-click was fired.
-             * @param image
-             */
-            $scope.imageSelect = function (image) {
-                let dblClickTime = 300;
-                let currentTime = Date.now();
-                let timeDiff = currentTime - previousClickTime;
-
-                if (timeDiff < dblClickTime && image.id === previousClickImage) {
-                    // If double click
-                    callbackAndHide(image);
-                } else {
-                    // If single
-                    $scope.selectedImage = image;
-                    $scope.dependantPages = false;
-                }
-                previousClickTime = currentTime;
-                previousClickImage = image.id;
-            };
-
-            /**
-             * Action that runs when the 'Select image' button is clicked.
-             * Runs the callback and hides the image manager.
-             */
-            $scope.selectButtonClick = function () {
-                callbackAndHide($scope.selectedImage);
-            };
-
-            /**
-             * Show the image manager.
-             * Takes a callback to execute later on.
-             * @param doneCallback
-             */
-            function show(doneCallback) {
-                callback = doneCallback;
-                $scope.showing = true;
-                $('#image-manager').find('.overlay').css('display', 'flex').hide().fadeIn(240);
-                // Get initial images if they have not yet been loaded in.
-                if (!dataLoaded) {
-                    fetchData();
-                    dataLoaded = true;
-                }
-            }
-
-            // Connects up the image manger so it can be used externally
-            // such as from TinyMCE.
-            imageManagerService.show = show;
-            imageManagerService.showExternal = function (doneCallback) {
-                $scope.$apply(() => {
-                    show(doneCallback);
-                });
-            };
-            window.ImageManager = imageManagerService;
-
-            /**
-             * Hide the image manager
-             */
-            $scope.hide = function () {
-                $scope.showing = false;
-                $('#image-manager').find('.overlay').fadeOut(240);
-            };
-
-            let baseUrl = window.baseUrl('/images/' + $scope.imageType + '/all/');
-
-            /**
-             * Fetch the list image data from the server.
-             */
-            function fetchData() {
-                let url = baseUrl + page + '?';
-                let components = {};
-                if ($scope.uploadedTo) components['page_id'] = $scope.uploadedTo;
-                if ($scope.searching) components['term'] = $scope.searchTerm;
-
-
-                url += Object.keys(components).map((key) => {
-                    return key + '=' + encodeURIComponent(components[key]);
-                }).join('&');
-
-                $http.get(url).then((response) => {
-                    $scope.images = $scope.images.concat(response.data.images);
-                    $scope.hasMore = response.data.hasMore;
-                    page++;
-                });
-            }
-            $scope.fetchData = fetchData;
-
-            /**
-             * Start a search operation
-             */
-            $scope.searchImages = function() {
-
-                if ($scope.searchTerm === '') {
-                    cancelSearch();
-                    return;
-                }
-
-                if (!$scope.searching) {
-                    preSearchImages = $scope.images;
-                    preSearchHasMore = $scope.hasMore;
-                }
-
-                $scope.searching = true;
-                $scope.images = [];
-                $scope.hasMore = false;
-                page = 0;
-                baseUrl = window.baseUrl('/images/' + $scope.imageType + '/search/');
-                fetchData();
-            };
-
-            /**
-             * Set the current image listing view.
-             * @param viewName
-             */
-            $scope.setView = function(viewName) {
-                cancelSearch();
-                $scope.images = [];
-                $scope.hasMore = false;
-                page = 0;
-                $scope.view = viewName;
-                baseUrl = window.baseUrl('/images/' + $scope.imageType  + '/' + viewName + '/');
-                fetchData();
-            };
-
-            /**
-             * Save the details of an image.
-             * @param event
-             */
-            $scope.saveImageDetails = function (event) {
-                event.preventDefault();
-                let url = window.baseUrl('/images/update/' + $scope.selectedImage.id);
-                $http.put(url, this.selectedImage).then(response => {
-                    events.emit('success', trans('components.image_update_success'));
-                }, (response) => {
-                    if (response.status === 422) {
-                        let errors = response.data;
-                        let message = '';
-                        Object.keys(errors).forEach((key) => {
-                            message += errors[key].join('\n');
-                        });
-                        events.emit('error', message);
-                    } else if (response.status === 403) {
-                        events.emit('error', response.data.error);
-                    }
-                });
-            };
-
-            /**
-             * Delete an image from system and notify of success.
-             * Checks if it should force delete when an image
-             * has dependant pages.
-             * @param event
-             */
-            $scope.deleteImage = function (event) {
-                event.preventDefault();
-                let force = $scope.dependantPages !== false;
-                let url = window.baseUrl('/images/' + $scope.selectedImage.id);
-                if (force) url += '?force=true';
-                $http.delete(url).then((response) => {
-                    $scope.images.splice($scope.images.indexOf($scope.selectedImage), 1);
-                    $scope.selectedImage = false;
-                    events.emit('success', trans('components.image_delete_success'));
-                }, (response) => {
-                    // Pages failure
-                    if (response.status === 400) {
-                        $scope.dependantPages = response.data;
-                    } else if (response.status === 403) {
-                        events.emit('error', response.data.error);
-                    }
-                });
-            };
-
-            /**
-             * Simple date creator used to properly format dates.
-             * @param stringDate
-             * @returns {Date}
-             */
-            $scope.getDate = function (stringDate) {
-                return new Date(stringDate);
-            };
-
-        }]);
 
     ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce',
         function ($scope, $http, $attrs, $interval, $timeout, $sce) {
@@ -379,7 +129,7 @@ module.exports = function (ngApp, events) {
          */
         $scope.discardDraft = function () {
             let url = window.baseUrl('/ajax/page/' + pageId);
-            $http.get(url).then((responseData) => {
+            $http.get(url).then(responseData => {
                 if (autoSave) $interval.cancel(autoSave);
                 $scope.draftText = trans('entities.pages_editing_page');
                 $scope.isUpdateDraft = false;
@@ -395,284 +145,225 @@ module.exports = function (ngApp, events) {
 
     }]);
 
-    ngApp.controller('PageTagController', ['$scope', '$http', '$attrs',
-        function ($scope, $http, $attrs) {
+    // Controller used to reply to and add new comments
+    ngApp.controller('CommentReplyController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
+        const MarkdownIt = require("markdown-it");
+        const md = new MarkdownIt({html: true});
+        let vm = this;
 
-            const pageId = Number($attrs.pageId);
-            $scope.tags = [];
-
-            $scope.sortOptions = {
-                handle: '.handle',
-                items: '> tr',
-                containment: "parent",
-                axis: "y"
+        vm.saveComment = function () {
+            let pageId = $scope.comment.pageId || $scope.pageId;
+            let comment = $scope.comment.text;
+            if (!comment) {
+                return events.emit('warning', trans('errors.empty_comment'));
+            }
+            let commentHTML = md.render($scope.comment.text);
+            let serviceUrl = `/ajax/page/${pageId}/comment/`;
+            let httpMethod = 'post';
+            let reqObj = {
+                text: comment,
+                html: commentHTML
             };
 
-            /**
-             * Push an empty tag to the end of the scope tags.
-             */
-            function addEmptyTag() {
-                $scope.tags.push({
-                    name: '',
-                    value: ''
-                });
+            if ($scope.isEdit === true) {
+                // this will be set when editing the comment.
+                serviceUrl = `/ajax/page/${pageId}/comment/${$scope.comment.id}`;
+                httpMethod = 'put';
+            } else if ($scope.isReply === true) {
+                // if its reply, get the parent comment id
+                reqObj.parent_id = $scope.parentId;
             }
-            $scope.addEmptyTag = addEmptyTag;
-
-            /**
-             * Get all tags for the current book and add into scope.
-             */
-            function getTags() {
-                let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`);
-                $http.get(url).then((responseData) => {
-                    $scope.tags = responseData.data;
-                    addEmptyTag();
-                });
-            }
-            getTags();
-
-            /**
-             * Set the order property on all tags.
-             */
-            function setTagOrder() {
-                for (let i = 0; i < $scope.tags.length; i++) {
-                    $scope.tags[i].order = i;
+            $http[httpMethod](window.baseUrl(serviceUrl), reqObj).then(resp => {
+                if (!isCommentOpSuccess(resp)) {
+                     return;
                 }
-            }
-
-            /**
-             * When an tag changes check if another empty editable
-             * field needs to be added onto the end.
-             * @param tag
-             */
-            $scope.tagChange = function(tag) {
-                let cPos = $scope.tags.indexOf(tag);
-                if (cPos !== $scope.tags.length-1) return;
-
-                if (tag.name !== '' || tag.value !== '') {
-                    addEmptyTag();
-                }
-            };
-
-            /**
-             * When an tag field loses focus check the tag to see if its
-             * empty and therefore could be removed from the list.
-             * @param tag
-             */
-            $scope.tagBlur = function(tag) {
-                let isLast = $scope.tags.length - 1 === $scope.tags.indexOf(tag);
-                if (tag.name === '' && tag.value === '' && !isLast) {
-                    let cPos = $scope.tags.indexOf(tag);
-                    $scope.tags.splice(cPos, 1);
-                }
-            };
-
-            /**
-             * Remove a tag from the current list.
-             * @param tag
-             */
-            $scope.removeTag = function(tag) {
-                let cIndex = $scope.tags.indexOf(tag);
-                $scope.tags.splice(cIndex, 1);
-            };
-
-        }]);
-
-
-    ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs',
-        function ($scope, $http, $attrs) {
-
-            const pageId = $scope.uploadedTo = $attrs.pageId;
-            let currentOrder = '';
-            $scope.files = [];
-            $scope.editFile = false;
-            $scope.file = getCleanFile();
-            $scope.errors = {
-                link: {},
-                edit: {}
-            };
-
-            function getCleanFile() {
-                return {
-                    page_id: pageId
-                };
-            }
-
-            // Angular-UI-Sort options
-            $scope.sortOptions = {
-                handle: '.handle',
-                items: '> tr',
-                containment: "parent",
-                axis: "y",
-                stop: sortUpdate,
-            };
-
-            /**
-             * Event listener for sort changes.
-             * Updates the file ordering on the server.
-             * @param event
-             * @param ui
-             */
-            function sortUpdate(event, ui) {
-                let newOrder = $scope.files.map(file => {return file.id}).join(':');
-                if (newOrder === currentOrder) return;
-
-                currentOrder = newOrder;
-                $http.put(window.baseUrl(`/attachments/sort/page/${pageId}`), {files: $scope.files}).then(resp => {
-                    events.emit('success', resp.data.message);
-                }, checkError('sort'));
-            }
-
-            /**
-             * Used by dropzone to get the endpoint to upload to.
-             * @returns {string}
-             */
-            $scope.getUploadUrl = function (file) {
-                let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
-                return window.baseUrl(`/attachments/upload${suffix}`);
-            };
-
-            /**
-             * Get files for the current page from the server.
-             */
-            function getFiles() {
-                let url = window.baseUrl(`/attachments/get/page/${pageId}`);
-                $http.get(url).then(resp => {
-                    $scope.files = resp.data;
-                    currentOrder = resp.data.map(file => {return file.id}).join(':');
-                }, checkError('get'));
-            }
-            getFiles();
-
-            /**
-             * Runs on file upload, Adds an file to local file list
-             * and shows a success message to the user.
-             * @param file
-             * @param data
-             */
-            $scope.uploadSuccess = function (file, data) {
-                $scope.$apply(() => {
-                    $scope.files.push(data);
-                });
-                events.emit('success', trans('entities.attachments_file_uploaded'));
-            };
-
-            /**
-             * Upload and overwrite an existing file.
-             * @param file
-             * @param data
-             */
-            $scope.uploadSuccessUpdate = function (file, data) {
-                $scope.$apply(() => {
-                    let search = filesIndexOf(data);
-                    if (search !== -1) $scope.files[search] = data;
-
-                    if ($scope.editFile) {
-                        $scope.editFile = angular.copy(data);
-                        data.link = '';
+                // hide the comments first, and then retrigger the refresh
+                if ($scope.isEdit) {
+                    updateComment($scope.comment, resp.data);
+                    $scope.$emit('evt.comment-success', $scope.comment.id);
+                } else {
+                    $scope.comment.text = '';
+                    if ($scope.isReply === true && $scope.parent.sub_comments) {
+                        $scope.parent.sub_comments.push(resp.data.comment);
+                    } else {
+                        $scope.$emit('evt.new-comment', resp.data.comment);
                     }
+                    $scope.$emit('evt.comment-success', null, true);
+                }
+                $scope.comment.is_hidden = true;
+                $timeout(function() {
+                    $scope.comment.is_hidden = false;
                 });
-                events.emit('success', trans('entities.attachments_file_updated'));
-            };
 
-            /**
-             * Delete a file from the server and, on success, the local listing.
-             * @param file
-             */
-            $scope.deleteFile = function(file) {
-                if (!file.deleting) {
-                    file.deleting = true;
+                events.emit('success', trans(resp.data.message));
+
+            }, checkError);
+
+        };
+
+        function checkError(response) {
+            let msg = null;
+            if (isCommentOpSuccess(response)) {
+                // all good
+                return;
+            } else if (response.data) {
+                msg = response.data.message;
+            } else {
+                msg = trans('errors.comment_add');
+            }
+            if (msg) {
+                events.emit('success', msg);
+            }
+        }
+    }]);
+
+    // Controller used to delete comments
+    ngApp.controller('CommentDeleteController', ['$scope', '$http', '$timeout', function ($scope, $http, $timeout) {
+        let vm = this;
+
+        vm.delete = function(comment) {
+            $http.delete(window.baseUrl(`/ajax/comment/${comment.id}`)).then(resp => {
+                if (!isCommentOpSuccess(resp)) {
                     return;
                 }
-                  $http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
-                      events.emit('success', resp.data.message);
-                      $scope.files.splice($scope.files.indexOf(file), 1);
-                  }, checkError('delete'));
-            };
-
-            /**
-             * Attach a link to a page.
-             * @param file
-             */
-            $scope.attachLinkSubmit = function(file) {
-                file.uploaded_to = pageId;
-                $http.post(window.baseUrl('/attachments/link'), file).then(resp => {
-                    $scope.files.push(resp.data);
-                    events.emit('success', trans('entities.attachments_link_attached'));
-                    $scope.file = getCleanFile();
-                }, checkError('link'));
-            };
-
-            /**
-             * Start the edit mode for a file.
-             * @param file
-             */
-            $scope.startEdit = function(file) {
-                $scope.editFile = angular.copy(file);
-                $scope.editFile.link = (file.external) ? file.path : '';
-            };
-
-            /**
-             * Cancel edit mode
-             */
-            $scope.cancelEdit = function() {
-                $scope.editFile = false;
-            };
-
-            /**
-             * Update the name and link of a file.
-             * @param file
-             */
-            $scope.updateFile = function(file) {
-                $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
-                    let search = filesIndexOf(resp.data);
-                    if (search !== -1) $scope.files[search] = resp.data;
-
-                    if ($scope.editFile && !file.external) {
-                        $scope.editFile.link = '';
-                    }
-                    $scope.editFile = false;
-                    events.emit('success', trans('entities.attachments_updated_success'));
-                }, checkError('edit'));
-            };
-
-            /**
-             * Get the url of a file.
-             */
-            $scope.getFileUrl = function(file) {
-                return window.baseUrl('/attachments/' + file.id);
-            };
-
-            /**
-             * Search the local files via another file object.
-             * Used to search via object copies.
-             * @param file
-             * @returns int
-             */
-            function filesIndexOf(file) {
-                for (let i = 0; i < $scope.files.length; i++) {
-                    if ($scope.files[i].id == file.id) return i;
+                updateComment(comment, resp.data, $timeout, true);
+            }, function (resp) {
+                if (isCommentOpSuccess(resp)) {
+                    events.emit('success', trans('entities.comment_deleted'));
+                } else {
+                    events.emit('error', trans('error.comment_delete'));
                 }
-                return -1;
+            });
+        };
+    }]);
+
+    // Controller used to fetch all comments for a page
+    ngApp.controller('CommentListController', ['$scope', '$http', '$timeout', '$location', function ($scope, $http, $timeout, $location) {
+        let vm = this;
+        $scope.errors = {};
+        // keep track of comment levels
+        $scope.level = 1;
+        vm.totalCommentsStr = trans('entities.comments_loading');
+        vm.permissions = {};
+        vm.trans = window.trans;
+
+        $scope.$on('evt.new-comment', function (event, comment) {
+            // add the comment to the comment list.
+            vm.comments.push(comment);
+            ++vm.totalComments;
+            setTotalCommentMsg();
+            event.stopPropagation();
+            event.preventDefault();
+        });
+
+        vm.canEditDelete = function (comment, prop) {
+            if (!comment.active) {
+                return false;
+            }
+            let propAll = prop + '_all';
+            let propOwn = prop + '_own';
+
+            if (vm.permissions[propAll]) {
+                return true;
             }
 
-            /**
-             * Check for an error response in a ajax request.
-             * @param errorGroupName
-             */
-            function checkError(errorGroupName) {
-                $scope.errors[errorGroupName] = {};
-                return function(response) {
-                    if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
-                        events.emit('error', response.data.error);
-                    }
-                    if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
-                        $scope.errors[errorGroupName] = response.data.validation;
-                        console.log($scope.errors[errorGroupName])
-                    }
-                }
+            if (vm.permissions[propOwn] && comment.created_by.id === vm.current_user_id) {
+                return true;
             }
 
-        }]);
+            return false;
+        };
 
+        vm.canComment = function () {
+            return vm.permissions.comment_create;
+        };
+
+        // check if there are is any direct linking
+        let linkedCommentId = $location.search().cm;
+
+        $timeout(function() {
+            $http.get(window.baseUrl(`/ajax/page/${$scope.pageId}/comments/`)).then(resp => {
+                if (!isCommentOpSuccess(resp)) {
+                    // just show that no comments are available.
+                    vm.totalComments = 0;
+                    setTotalCommentMsg();
+                    return;
+                }
+                vm.comments = resp.data.comments;
+                vm.totalComments = +resp.data.total;
+                vm.permissions = resp.data.permissions;
+                vm.current_user_id = resp.data.user_id;
+                setTotalCommentMsg();
+                if (!linkedCommentId) {
+                    return;
+                }
+                $timeout(function() {
+                    // wait for the UI to render.
+                    focusLinkedComment(linkedCommentId);
+                });
+            }, checkError);
+        });
+
+        function setTotalCommentMsg () {
+            if (vm.totalComments === 0) {
+                vm.totalCommentsStr = trans('entities.no_comments');
+            } else if (vm.totalComments === 1) {
+                vm.totalCommentsStr = trans('entities.one_comment');
+            } else {
+                vm.totalCommentsStr = trans('entities.x_comments', {
+                    numComments: vm.totalComments
+                });
+            }
+        }
+
+        function focusLinkedComment(linkedCommentId) {
+            let comment = angular.element('#' + linkedCommentId);
+            if (comment.length === 0) {
+                return;
+            }
+
+            window.setupPageShow.goToText(linkedCommentId);
+        }
+
+        function checkError(response) {
+            let msg = null;
+            if (isCommentOpSuccess(response)) {
+                // all good
+                return;
+            } else if (response.data) {
+                msg = response.data.message;
+            } else {
+                msg = trans('errors.comment_list');
+            }
+            if (msg) {
+                events.emit('success', msg);
+            }
+        }
+    }]);
+
+    function updateComment(comment, resp, $timeout, isDelete) {
+        comment.text = resp.comment.text;
+        comment.updated = resp.comment.updated;
+        comment.updated_by = resp.comment.updated_by;
+        comment.active = resp.comment.active;
+        if (isDelete && !resp.comment.active) {
+            comment.html = trans('entities.comment_deleted');
+        } else {
+            comment.html = resp.comment.html;
+        }
+        if (!$timeout) {
+            return;
+        }
+        comment.is_hidden = true;
+        $timeout(function() {
+            comment.is_hidden = false;
+        });
+    }
+
+    function isCommentOpSuccess(resp) {
+        if (resp && resp.data && resp.data.status === 'success') {
+            return true;
+        }
+        return false;
+    }
 };
diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js
index 51f1b7579..fc92121ff 100644
--- a/resources/assets/js/directives.js
+++ b/resources/assets/js/directives.js
@@ -1,152 +1,10 @@
 "use strict";
-const DropZone = require("dropzone");
 const MarkdownIt = require("markdown-it");
 const mdTasksLists = require('markdown-it-task-lists');
 const code = require('./code');
 
 module.exports = function (ngApp, events) {
 
-    /**
-     * Common tab controls using simple jQuery functions.
-     */
-    ngApp.directive('tabContainer', function() {
-        return {
-            restrict: 'A',
-            link: function (scope, element, attrs) {
-                const $content = element.find('[tab-content]');
-                const $buttons = element.find('[tab-button]');
-
-                if (attrs.tabContainer) {
-                    let initial = attrs.tabContainer;
-                    $buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
-                    $content.hide().filter(`[tab-content="${initial}"]`).show();
-                } else {
-                    $content.hide().first().show();
-                    $buttons.first().addClass('selected');
-                }
-
-                $buttons.click(function() {
-                    let clickedTab = $(this);
-                    $buttons.removeClass('selected');
-                    $content.hide();
-                    let name = clickedTab.addClass('selected').attr('tab-button');
-                    $content.filter(`[tab-content="${name}"]`).show();
-                });
-            }
-        };
-    });
-
-    /**
-     * Sub form component to allow inner-form sections to act like their own forms.
-     */
-    ngApp.directive('subForm', function() {
-        return {
-            restrict: 'A',
-            link: function (scope, element, attrs) {
-                element.on('keypress', e => {
-                    if (e.keyCode === 13) {
-                        submitEvent(e);
-                    }
-                });
-
-                element.find('button[type="submit"]').click(submitEvent);
-
-                function submitEvent(e) {
-                    e.preventDefault();
-                    if (attrs.subForm) scope.$eval(attrs.subForm);
-                }
-            }
-        };
-    });
-
-    /**
-     * DropZone
-     * Used for uploading images
-     */
-    ngApp.directive('dropZone', [function () {
-        return {
-            restrict: 'E',
-            template: `
-            <div class="dropzone-container">
-                <div class="dz-message">{{message}}</div>
-            </div>
-            `,
-            scope: {
-                uploadUrl: '@',
-                eventSuccess: '=',
-                eventError: '=',
-                uploadedTo: '@',
-            },
-            link: function (scope, element, attrs) {
-                scope.message = attrs.message;
-                if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
-                let dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
-                    url: scope.uploadUrl,
-                    init: function () {
-                        let dz = this;
-                        dz.on('sending', function (file, xhr, data) {
-                            let token = window.document.querySelector('meta[name=token]').getAttribute('content');
-                            data.append('_token', token);
-                            let uploadedTo = typeof scope.uploadedTo === 'undefined' ? 0 : scope.uploadedTo;
-                            data.append('uploaded_to', uploadedTo);
-                        });
-                        if (typeof scope.eventSuccess !== 'undefined') dz.on('success', scope.eventSuccess);
-                        dz.on('success', function (file, data) {
-                            $(file.previewElement).fadeOut(400, function () {
-                                dz.removeFile(file);
-                            });
-                        });
-                        if (typeof scope.eventError !== 'undefined') dz.on('error', scope.eventError);
-                        dz.on('error', function (file, errorMessage, xhr) {
-                            console.log(errorMessage);
-                            console.log(xhr);
-                            function setMessage(message) {
-                                $(file.previewElement).find('[data-dz-errormessage]').text(message);
-                            }
-
-                            if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
-                            if (errorMessage.file) setMessage(errorMessage.file[0]);
-
-                        });
-                    }
-                });
-            }
-        };
-    }]);
-
-    /**
-     * Dropdown
-     * Provides some simple logic to create small dropdown menus
-     */
-    ngApp.directive('dropdown', [function () {
-        return {
-            restrict: 'A',
-            link: function (scope, element, attrs) {
-                const menu = element.find('ul');
-                element.find('[dropdown-toggle]').on('click', function () {
-                    menu.show().addClass('anim menuIn');
-                    let inputs = menu.find('input');
-                    let hasInput = inputs.length > 0;
-                    if (hasInput) {
-                        inputs.first().focus();
-                        element.on('keypress', 'input', event => {
-                            if (event.keyCode === 13) {
-                                event.preventDefault();
-                                menu.hide();
-                                menu.removeClass('anim menuIn');
-                                return false;
-                            }
-                        });
-                    }
-                    element.mouseleave(function () {
-                        menu.hide();
-                        menu.removeClass('anim menuIn');
-                    });
-                });
-            }
-        };
-    }]);
-
     /**
      * TinyMCE
      * An angular wrapper around the tinyMCE editor.
@@ -187,30 +45,6 @@ module.exports = function (ngApp, events) {
                 }
 
                 scope.tinymce.extraSetups.push(tinyMceSetup);
-
-                // Custom tinyMCE plugins
-                tinymce.PluginManager.add('customhr', function (editor) {
-                    editor.addCommand('InsertHorizontalRule', function () {
-                        let hrElem = document.createElement('hr');
-                        let cNode = editor.selection.getNode();
-                        let parentNode = cNode.parentNode;
-                        parentNode.insertBefore(hrElem, cNode);
-                    });
-
-                    editor.addButton('hr', {
-                        icon: 'hr',
-                        tooltip: 'Horizontal line',
-                        cmd: 'InsertHorizontalRule'
-                    });
-
-                    editor.addMenuItem('hr', {
-                        icon: 'hr',
-                        text: 'Horizontal line',
-                        cmd: 'InsertHorizontalRule',
-                        context: 'insert'
-                    });
-                });
-
                 tinymce.init(scope.tinymce);
             }
         }
@@ -251,6 +85,21 @@ module.exports = function (ngApp, events) {
                 extraKeys[`${metaKey}-S`] = function(cm) {scope.$emit('save-draft');};
                 // Show link selector
                 extraKeys[`Shift-${metaKey}-K`] = function(cm) {showLinkSelector()};
+                // Insert Link
+                extraKeys[`${metaKey}-K`] = function(cm) {insertLink()};
+                // FormatShortcuts
+                extraKeys[`${metaKey}-1`] = function(cm) {replaceLineStart('##');};
+                extraKeys[`${metaKey}-2`] = function(cm) {replaceLineStart('###');};
+                extraKeys[`${metaKey}-3`] = function(cm) {replaceLineStart('####');};
+                extraKeys[`${metaKey}-4`] = function(cm) {replaceLineStart('#####');};
+                extraKeys[`${metaKey}-5`] = function(cm) {replaceLineStart('');};
+                extraKeys[`${metaKey}-d`] = function(cm) {replaceLineStart('');};
+                extraKeys[`${metaKey}-6`] = function(cm) {replaceLineStart('>');};
+                extraKeys[`${metaKey}-q`] = function(cm) {replaceLineStart('>');};
+                extraKeys[`${metaKey}-7`] = function(cm) {wrapSelection('\n```\n', '\n```');};
+                extraKeys[`${metaKey}-8`] = function(cm) {wrapSelection('`', '`');};
+                extraKeys[`Shift-${metaKey}-E`] = function(cm) {wrapSelection('`', '`');};
+                extraKeys[`${metaKey}-9`] = function(cm) {wrapSelection('<p class="callout info">', '</div>');};
                 cm.setOption('extraKeys', extraKeys);
 
                 // Update data on content change
@@ -303,6 +152,73 @@ module.exports = function (ngApp, events) {
                     cm.setSelections(cursor);
                 }
 
+                // Helper to replace the start of the line
+                function replaceLineStart(newStart) {
+                    let cursor = cm.getCursor();
+                    let lineContent = cm.getLine(cursor.line);
+                    let lineLen = lineContent.length;
+                    let lineStart = lineContent.split(' ')[0];
+
+                    // Remove symbol if already set
+                    if (lineStart === newStart) {
+                        lineContent = lineContent.replace(`${newStart} `, '');
+                        cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
+                        cm.setCursor({line: cursor.line, ch: cursor.ch - (newStart.length + 1)});
+                        return;
+                    }
+
+                    let alreadySymbol = /^[#>`]/.test(lineStart);
+                    let posDif = 0;
+                    if (alreadySymbol) {
+                        posDif = newStart.length - lineStart.length;
+                        lineContent = lineContent.replace(lineStart, newStart).trim();
+                    } else if (newStart !== '') {
+                        posDif = newStart.length + 1;
+                        lineContent = newStart + ' ' + lineContent;
+                    }
+                    cm.replaceRange(lineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
+                    cm.setCursor({line: cursor.line, ch: cursor.ch + posDif});
+                }
+
+                function wrapLine(start, end) {
+                    let cursor = cm.getCursor();
+                    let lineContent = cm.getLine(cursor.line);
+                    let lineLen = lineContent.length;
+                    let newLineContent = lineContent;
+
+                    if (lineContent.indexOf(start) === 0 && lineContent.slice(-end.length) === end) {
+                        newLineContent = lineContent.slice(start.length, lineContent.length - end.length);
+                    } else {
+                        newLineContent = `${start}${lineContent}${end}`;
+                    }
+
+                    cm.replaceRange(newLineContent, {line: cursor.line, ch: 0}, {line: cursor.line, ch: lineLen});
+                    cm.setCursor({line: cursor.line, ch: cursor.ch + (newLineContent.length - lineLen)});
+                }
+
+                function wrapSelection(start, end) {
+                    let selection = cm.getSelection();
+                    if (selection === '') return wrapLine(start, end);
+                    let newSelection = selection;
+                    let frontDiff = 0;
+                    let endDiff = 0;
+
+                    if (selection.indexOf(start) === 0 && selection.slice(-end.length) === end) {
+                        newSelection = selection.slice(start.length, selection.length - end.length);
+                        endDiff = -(end.length + start.length);
+                    } else {
+                        newSelection = `${start}${selection}${end}`;
+                        endDiff = start.length + end.length;
+                    }
+
+                    let selections = cm.listSelections()[0];
+                    cm.replaceSelection(newSelection);
+                    let headFirst = selections.head.ch <= selections.anchor.ch;
+                    selections.head.ch += headFirst ? frontDiff : endDiff;
+                    selections.anchor.ch += headFirst ? endDiff : frontDiff;
+                    cm.setSelections([selections]);
+                }
+
                 // Handle image upload and add image into markdown content
                 function uploadImage(file) {
                     if (file === null || file.type.indexOf('image') !== 0) return;
@@ -345,10 +261,20 @@ module.exports = function (ngApp, events) {
                     });
                 }
 
+                function insertLink() {
+                    let cursorPos = cm.getCursor('from');
+                    let selectedText = cm.getSelection() || '';
+                    let newText = `[${selectedText}]()`;
+                    cm.focus();
+                    cm.replaceSelection(newText);
+                    let cursorPosDiff = (selectedText === '') ? -3 : -1;
+                    cm.setCursor(cursorPos.line, cursorPos.ch + newText.length+cursorPosDiff);
+                }
+
                 // Show the image manager and handle image insertion
                 function showImageManager() {
                     let cursorPos = cm.getCursor('from');
-                    window.ImageManager.showExternal(image => {
+                    window.ImageManager.show(image => {
                         let selectedText = cm.getSelection();
                         let newText = "![" + (selectedText || image.name) + "](" + image.thumbs.display + ")";
                         cm.focus();
@@ -461,188 +387,6 @@ module.exports = function (ngApp, events) {
         }
     }]);
 
-    /**
-     * Tag Autosuggestions
-     * Listens to child inputs and provides autosuggestions depending on field type
-     * and input. Suggestions provided by server.
-     */
-    ngApp.directive('tagAutosuggestions', ['$http', function ($http) {
-        return {
-            restrict: 'A',
-            link: function (scope, elem, attrs) {
-
-                // Local storage for quick caching.
-                const localCache = {};
-
-                // Create suggestion element
-                const suggestionBox = document.createElement('ul');
-                suggestionBox.className = 'suggestion-box';
-                suggestionBox.style.position = 'absolute';
-                suggestionBox.style.display = 'none';
-                const $suggestionBox = $(suggestionBox);
-
-                // General state tracking
-                let isShowing = false;
-                let currentInput = false;
-                let active = 0;
-
-                // Listen to input events on autosuggest fields
-                elem.on('input focus', '[autosuggest]', function (event) {
-                    let $input = $(this);
-                    let val = $input.val();
-                    let url = $input.attr('autosuggest');
-                    let type = $input.attr('autosuggest-type');
-
-                    // Add name param to request if for a value
-                    if (type.toLowerCase() === 'value') {
-                        let $nameInput = $input.closest('tr').find('[autosuggest-type="name"]').first();
-                        let nameVal = $nameInput.val();
-                        if (nameVal !== '') {
-                            url += '?name=' + encodeURIComponent(nameVal);
-                        }
-                    }
-
-                    let suggestionPromise = getSuggestions(val.slice(0, 3), url);
-                    suggestionPromise.then(suggestions => {
-                        if (val.length === 0) {
-                            displaySuggestions($input, suggestions.slice(0, 6));
-                        } else  {
-                            suggestions = suggestions.filter(item => {
-                                return item.toLowerCase().indexOf(val.toLowerCase()) !== -1;
-                            }).slice(0, 4);
-                            displaySuggestions($input, suggestions);
-                        }
-                    });
-                });
-
-                // Hide autosuggestions when input loses focus.
-                // Slight delay to allow clicks.
-                let lastFocusTime = 0;
-                elem.on('blur', '[autosuggest]', function (event) {
-                    let startTime = Date.now();
-                    setTimeout(() => {
-                        if (lastFocusTime < startTime) {
-                            $suggestionBox.hide();
-                            isShowing = false;
-                        }
-                    }, 200)
-                });
-                elem.on('focus', '[autosuggest]', function (event) {
-                    lastFocusTime = Date.now();
-                });
-
-                elem.on('keydown', '[autosuggest]', function (event) {
-                    if (!isShowing) return;
-
-                    let suggestionElems = suggestionBox.childNodes;
-                    let suggestCount = suggestionElems.length;
-
-                    // Down arrow
-                    if (event.keyCode === 40) {
-                        let newActive = (active === suggestCount - 1) ? 0 : active + 1;
-                        changeActiveTo(newActive, suggestionElems);
-                    }
-                    // Up arrow
-                    else if (event.keyCode === 38) {
-                        let newActive = (active === 0) ? suggestCount - 1 : active - 1;
-                        changeActiveTo(newActive, suggestionElems);
-                    }
-                    // Enter or tab key
-                    else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
-                        currentInput[0].value = suggestionElems[active].textContent;
-                        currentInput.focus();
-                        $suggestionBox.hide();
-                        isShowing = false;
-                        if (event.keyCode === 13) {
-                            event.preventDefault();
-                            return false;
-                        }
-                    }
-                });
-
-                // Change the active suggestion to the given index
-                function changeActiveTo(index, suggestionElems) {
-                    suggestionElems[active].className = '';
-                    active = index;
-                    suggestionElems[active].className = 'active';
-                }
-
-                // Display suggestions on a field
-                let prevSuggestions = [];
-
-                function displaySuggestions($input, suggestions) {
-
-                    // Hide if no suggestions
-                    if (suggestions.length === 0) {
-                        $suggestionBox.hide();
-                        isShowing = false;
-                        prevSuggestions = suggestions;
-                        return;
-                    }
-
-                    // Otherwise show and attach to input
-                    if (!isShowing) {
-                        $suggestionBox.show();
-                        isShowing = true;
-                    }
-                    if ($input !== currentInput) {
-                        $suggestionBox.detach();
-                        $input.after($suggestionBox);
-                        currentInput = $input;
-                    }
-
-                    // Return if no change
-                    if (prevSuggestions.join() === suggestions.join()) {
-                        prevSuggestions = suggestions;
-                        return;
-                    }
-
-                    // Build suggestions
-                    $suggestionBox[0].innerHTML = '';
-                    for (let i = 0; i < suggestions.length; i++) {
-                        let suggestion = document.createElement('li');
-                        suggestion.textContent = suggestions[i];
-                        suggestion.onclick = suggestionClick;
-                        if (i === 0) {
-                            suggestion.className = 'active';
-                            active = 0;
-                        }
-                        $suggestionBox[0].appendChild(suggestion);
-                    }
-
-                    prevSuggestions = suggestions;
-                }
-
-                // Suggestion click event
-                function suggestionClick(event) {
-                    currentInput[0].value = this.textContent;
-                    currentInput.focus();
-                    $suggestionBox.hide();
-                    isShowing = false;
-                }
-
-                // Get suggestions & cache
-                function getSuggestions(input, url) {
-                    let hasQuery = url.indexOf('?') !== -1;
-                    let searchUrl = url + (hasQuery ? '&' : '?') + 'search=' + encodeURIComponent(input);
-
-                    // Get from local cache if exists
-                    if (typeof localCache[searchUrl] !== 'undefined') {
-                        return new Promise((resolve, reject) => {
-                            resolve(localCache[searchUrl]);
-                        });
-                    }
-
-                    return $http.get(searchUrl).then(response => {
-                        localCache[searchUrl] = response.data;
-                        return response.data;
-                    });
-                }
-
-            }
-        }
-    }]);
-
     ngApp.directive('entityLinkSelector', [function($http) {
         return {
             restrict: 'A',
@@ -678,6 +422,7 @@ module.exports = function (ngApp, events) {
                 function hide() {
                     element.fadeOut(240);
                 }
+                scope.hide = hide;
 
                 // Listen to confirmation of entity selections (doubleclick)
                 events.listen('entity-select-confirm', entity => {
@@ -789,4 +534,128 @@ module.exports = function (ngApp, events) {
             }
         };
     }]);
+
+    ngApp.directive('commentReply', [function () {
+        return {
+            restrict: 'E',
+            templateUrl: 'comment-reply.html',
+            scope: {
+              pageId: '=',
+              parentId: '=',
+              parent: '='
+            },
+            link: function (scope, element) {
+                scope.isReply = true;
+                element.find('textarea').focus();
+                scope.$on('evt.comment-success', function (event) {
+                    // no need for the event to do anything more.
+                    event.stopPropagation();
+                    event.preventDefault();
+                    scope.closeBox();
+                });
+
+                scope.closeBox = function () {
+                    element.remove();
+                    scope.$destroy();
+                };
+            }
+        };
+    }]);
+
+    ngApp.directive('commentEdit', [function () {
+         return {
+            restrict: 'E',
+            templateUrl: 'comment-reply.html',
+            scope: {
+              comment: '='
+            },
+            link: function (scope, element) {
+                scope.isEdit = true;
+                element.find('textarea').focus();
+                scope.$on('evt.comment-success', function (event, commentId) {
+                   // no need for the event to do anything more.
+                   event.stopPropagation();
+                   event.preventDefault();
+                   if (commentId === scope.comment.id && !scope.isNew) {
+                       scope.closeBox();
+                   }
+                });
+
+                scope.closeBox = function () {
+                    element.remove();
+                    scope.$destroy();
+                };
+            }
+        };
+    }]);
+
+
+    ngApp.directive('commentReplyLink', ['$document', '$compile', function ($document, $compile) {
+        return {
+            scope: {
+                comment: '='
+            },
+            link: function (scope, element, attr) {
+                element.on('$destroy', function () {
+                    element.off('click');
+                    scope.$destroy();
+                });
+
+                element.on('click', function (e) {
+                    e.preventDefault();
+                    var $container = element.parents('.comment-actions').first();
+                    if (!$container.length) {
+                        console.error('commentReplyLink directive should be placed inside a container with class comment-box!');
+                        return;
+                    }
+                    if (attr.noCommentReplyDupe) {
+                        removeDupe();
+                    }
+
+                    compileHtml($container, scope, attr.isReply === 'true');
+                });
+            }
+        };
+
+        function compileHtml($container, scope, isReply) {
+            let lnkFunc = null;
+            if (isReply) {
+                lnkFunc = $compile('<comment-reply page-id="comment.pageId" parent-id="comment.id" parent="comment"></comment-reply>');
+            } else {
+                lnkFunc = $compile('<comment-edit comment="comment"></comment-add>');
+            }
+            var compiledHTML = lnkFunc(scope);
+            $container.append(compiledHTML);
+        }
+
+        function removeDupe() {
+            let $existingElement = $document.find('.comments-list comment-reply, .comments-list comment-edit');
+            if (!$existingElement.length) {
+                return;
+            }
+
+            $existingElement.remove();
+        }
+    }]);
+
+    ngApp.directive('commentDeleteLink', ['$window', function ($window) {
+        return {
+            controller: 'CommentDeleteController',
+            scope: {
+                comment: '='
+            },
+            link: function (scope, element, attr, ctrl) {
+
+                element.on('click', function(e) {
+                    e.preventDefault();
+                    var resp = $window.confirm(trans('entities.comment_delete_confirm'));
+                    if (!resp) {
+                        return;
+                    }
+
+                    ctrl.delete(scope.comment);
+                });
+            }
+        };
+    }]);
 };
diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js
index 1ee5bf0d3..966adb75d 100644
--- a/resources/assets/js/global.js
+++ b/resources/assets/js/global.js
@@ -1,4 +1,5 @@
 "use strict";
+require("babel-polyfill");
 
 // Url retrieval function
 window.baseUrl = function(path) {
@@ -8,37 +9,6 @@ window.baseUrl = function(path) {
     return basePath + '/' + path;
 };
 
-const Vue = require("vue");
-const axios = require("axios");
-
-let axiosInstance = axios.create({
-    headers: {
-        'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'),
-        'baseURL': window.baseUrl('')
-    }
-});
-window.$http = axiosInstance;
-
-Vue.prototype.$http = axiosInstance;
-
-require("./vues/vues");
-
-
-// AngularJS - Create application and load components
-const angular = require("angular");
-require("angular-resource");
-require("angular-animate");
-require("angular-sanitize");
-require("angular-ui-sortable");
-
-let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
-
-// Translation setup
-// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
-const Translations = require("./translations");
-let translator = new Translations(window.translations);
-window.trans = translator.get.bind(translator);
-
 // Global Event System
 class EventManager {
     constructor() {
@@ -63,13 +33,51 @@ class EventManager {
 }
 
 window.Events = new EventManager();
+
+const Vue = require("vue");
+const axios = require("axios");
+
+let axiosInstance = axios.create({
+    headers: {
+        'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'),
+        'baseURL': window.baseUrl('')
+    }
+});
+axiosInstance.interceptors.request.use(resp => {
+    return resp;
+}, err => {
+    if (typeof err.response === "undefined" || typeof err.response.data === "undefined") return Promise.reject(err);
+    if (typeof err.response.data.error !== "undefined") window.Events.emit('error', err.response.data.error);
+    if (typeof err.response.data.message !== "undefined") window.Events.emit('error', err.response.data.message);
+});
+window.$http = axiosInstance;
+
+Vue.prototype.$http = axiosInstance;
 Vue.prototype.$events = window.Events;
 
+
+// AngularJS - Create application and load components
+const angular = require("angular");
+require("angular-resource");
+require("angular-animate");
+require("angular-sanitize");
+require("angular-ui-sortable");
+
+let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']);
+
+// Translation setup
+// Creates a global function with name 'trans' to be used in the same way as Laravel's translation system
+const Translations = require("./translations");
+let translator = new Translations(window.translations);
+window.trans = translator.get.bind(translator);
+
+
+require("./vues/vues");
+require("./components");
+
 // Load in angular specific items
-const Services = require('./services');
 const Directives = require('./directives');
 const Controllers = require('./controllers');
-Services(ngApp, window.Events);
 Directives(ngApp, window.Events);
 Controllers(ngApp, window.Events);
 
@@ -174,7 +182,7 @@ $('.overlay').click(function(event) {
 if(navigator.userAgent.indexOf('MSIE')!==-1
     || navigator.appVersion.indexOf('Trident/') > 0
     || navigator.userAgent.indexOf('Safari') !== -1){
-    $('body').addClass('flexbox-support');
+    document.body.classList.add('flexbox-support');
 }
 
 // Page specific items
diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js
index 81d1cdea6..08e4c0c34 100644
--- a/resources/assets/js/pages/page-form.js
+++ b/resources/assets/js/pages/page-form.js
@@ -52,14 +52,36 @@ function editorPaste(e, editor) {
 function registerEditorShortcuts(editor) {
     // Headers
     for (let i = 1; i < 5; i++) {
-        editor.addShortcut('meta+' + i, '', ['FormatBlock', false, 'h' + i]);
+        editor.shortcuts.add('meta+' + i, '', ['FormatBlock', false, 'h' + (i+1)]);
     }
 
     // Other block shortcuts
-    editor.addShortcut('meta+q', '', ['FormatBlock', false, 'blockquote']);
-    editor.addShortcut('meta+d', '', ['FormatBlock', false, 'p']);
-    editor.addShortcut('meta+e', '', ['codeeditor', false, 'pre']);
-    editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
+    editor.shortcuts.add('meta+5', '', ['FormatBlock', false, 'p']);
+    editor.shortcuts.add('meta+d', '', ['FormatBlock', false, 'p']);
+    editor.shortcuts.add('meta+6', '', ['FormatBlock', false, 'blockquote']);
+    editor.shortcuts.add('meta+q', '', ['FormatBlock', false, 'blockquote']);
+    editor.shortcuts.add('meta+7', '', ['codeeditor', false, 'pre']);
+    editor.shortcuts.add('meta+e', '', ['codeeditor', false, 'pre']);
+    editor.shortcuts.add('meta+8', '', ['FormatBlock', false, 'code']);
+    editor.shortcuts.add('meta+shift+E', '', ['FormatBlock', false, 'code']);
+    // Loop through callout styles
+    editor.shortcuts.add('meta+9', '', function() {
+        let selectedNode = editor.selection.getNode();
+        let formats = ['info', 'success', 'warning', 'danger'];
+
+        if (!selectedNode || selectedNode.className.indexOf('callout') === -1) {
+            editor.formatter.apply('calloutinfo');
+            return;
+        }
+
+        for (let i = 0; i < formats.length; i++) {
+            if (selectedNode.className.indexOf(formats[i]) === -1) continue;
+            let newFormat = (i === formats.length -1) ? formats[0] : formats[i+1];
+            editor.formatter.apply('callout' + newFormat);
+            return;
+        }
+        editor.formatter.apply('p');
+    });
 }
 
 
@@ -120,7 +142,7 @@ function codePlugin() {
         $codeMirrorContainer.replaceWith($pre);
     }
 
-    window.tinymce.PluginManager.add('codeeditor', (editor, url) => {
+    window.tinymce.PluginManager.add('codeeditor', function(editor, url) {
 
         let $ = editor.$;
 
@@ -173,7 +195,32 @@ function codePlugin() {
     });
 }
 
+function hrPlugin() {
+    window.tinymce.PluginManager.add('customhr', function (editor) {
+        editor.addCommand('InsertHorizontalRule', function () {
+            let hrElem = document.createElement('hr');
+            let cNode = editor.selection.getNode();
+            let parentNode = cNode.parentNode;
+            parentNode.insertBefore(hrElem, cNode);
+        });
+
+        editor.addButton('hr', {
+            icon: 'hr',
+            tooltip: 'Horizontal line',
+            cmd: 'InsertHorizontalRule'
+        });
+
+        editor.addMenuItem('hr', {
+            icon: 'hr',
+            text: 'Horizontal line',
+            cmd: 'InsertHorizontalRule',
+            context: 'insert'
+        });
+    });
+}
+
 module.exports = function() {
+    hrPlugin();
     codePlugin();
     let settings = {
         selector: '#html-editor',
@@ -207,10 +254,10 @@ module.exports = function() {
             {title: "Code Block", icon: "code", cmd: 'codeeditor', format: 'codeeditor'},
             {title: "Inline Code", icon: "code", inline: "code"},
             {title: "Callouts", items: [
-                {title: "Success", block: 'p', exact: true, attributes : {'class' : 'callout success'}},
-                {title: "Info", block: 'p', exact: true, attributes : {'class' : 'callout info'}},
-                {title: "Warning", block: 'p', exact: true, attributes : {'class' : 'callout warning'}},
-                {title: "Danger", block: 'p', exact: true, attributes : {'class' : 'callout danger'}}
+                {title: "Info", format: 'calloutinfo'},
+                {title: "Success", format: 'calloutsuccess'},
+                {title: "Warning", format: 'calloutwarning'},
+                {title: "Danger", format: 'calloutdanger'}
             ]},
         ],
         style_formats_merge: false,
@@ -219,6 +266,10 @@ module.exports = function() {
             alignleft: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-left'},
             aligncenter: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-center'},
             alignright: {selector: 'p,h1,h2,h3,h4,h5,h6,td,th,div,ul,ol,li,table,img', classes: 'align-right'},
+            calloutsuccess: {block: 'p', exact: true, attributes: {class: 'callout success'}},
+            calloutinfo: {block: 'p', exact: true, attributes: {class: 'callout info'}},
+            calloutwarning: {block: 'p', exact: true, attributes: {class: 'callout warning'}},
+            calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}}
         },
         file_browser_callback: function (field_name, url, type, win) {
 
@@ -232,7 +283,7 @@ module.exports = function() {
 
             if (type === 'image') {
                 // Show image manager
-                window.ImageManager.showExternal(function (image) {
+                window.ImageManager.show(function (image) {
 
                     // Set popover link input to image url then fire change event
                     // to ensure the new value sticks
@@ -314,7 +365,7 @@ module.exports = function() {
                 icon: 'image',
                 tooltip: 'Insert an image',
                 onclick: function () {
-                    window.ImageManager.showExternal(function (image) {
+                    window.ImageManager.show(function (image) {
                         let html = `<a href="${image.url}" target="_blank">`;
                         html += `<img src="${image.thumbs.display}" alt="${image.name}">`;
                         html += '</a>';
diff --git a/resources/assets/js/pages/page-show.js b/resources/assets/js/pages/page-show.js
index 67d339d63..7754840af 100644
--- a/resources/assets/js/pages/page-show.js
+++ b/resources/assets/js/pages/page-show.js
@@ -1,5 +1,3 @@
-"use strict";
-// Configure ZeroClipboard
 const Clipboard = require("clipboard");
 const Code = require('../code');
 
@@ -161,6 +159,8 @@ let setupPageShow = window.setupPageShow = function (pageId) {
         }
     });
 
+    // in order to call from other places.
+    window.setupPageShow.goToText = goToText;
 };
 
 module.exports = setupPageShow;
\ No newline at end of file
diff --git a/resources/assets/js/services.js b/resources/assets/js/services.js
deleted file mode 100644
index cd2759c54..000000000
--- a/resources/assets/js/services.js
+++ /dev/null
@@ -1,12 +0,0 @@
-"use strict";
-
-module.exports = function(ngApp, events) {
-
-    ngApp.factory('imageManagerService', function() {
-        return {
-            show: false,
-            showExternal: false
-        };
-    });
-
-};
\ No newline at end of file
diff --git a/resources/assets/js/vues/attachment-manager.js b/resources/assets/js/vues/attachment-manager.js
new file mode 100644
index 000000000..635622b93
--- /dev/null
+++ b/resources/assets/js/vues/attachment-manager.js
@@ -0,0 +1,138 @@
+const draggable = require('vuedraggable');
+const dropzone = require('./components/dropzone');
+
+function mounted() {
+    this.pageId = this.$el.getAttribute('page-id');
+    this.file = this.newFile();
+
+    this.$http.get(window.baseUrl(`/attachments/get/page/${this.pageId}`)).then(resp => {
+        this.files = resp.data;
+    }).catch(err => {
+        this.checkValidationErrors('get', err);
+    });
+}
+
+let data = {
+    pageId: null,
+    files: [],
+    fileToEdit: null,
+    file: {},
+    tab: 'list',
+    editTab: 'file',
+    errors: {link: {}, edit: {}, delete: {}}
+};
+
+const components = {dropzone, draggable};
+
+let methods = {
+
+    newFile() {
+        return {page_id: this.pageId};
+    },
+
+    getFileUrl(file) {
+        return window.baseUrl(`/attachments/${file.id}`);
+    },
+
+    fileSortUpdate() {
+        this.$http.put(window.baseUrl(`/attachments/sort/page/${this.pageId}`), {files: this.files}).then(resp => {
+            this.$events.emit('success', resp.data.message);
+        }).catch(err => {
+            this.checkValidationErrors('sort', err);
+        });
+    },
+
+    startEdit(file) {
+        this.fileToEdit = Object.assign({}, file);
+        this.fileToEdit.link = file.external ? file.path : '';
+        this.editTab = file.external ? 'link' : 'file';
+    },
+
+    deleteFile(file) {
+        if (!file.deleting) return file.deleting = true;
+
+        this.$http.delete(window.baseUrl(`/attachments/${file.id}`)).then(resp => {
+            this.$events.emit('success', resp.data.message);
+            this.files.splice(this.files.indexOf(file), 1);
+        }).catch(err => {
+            this.checkValidationErrors('delete', err)
+        });
+    },
+
+    uploadSuccess(upload) {
+        this.files.push(upload.data);
+        this.$events.emit('success', trans('entities.attachments_file_uploaded'));
+    },
+
+    uploadSuccessUpdate(upload) {
+        let fileIndex = this.filesIndex(upload.data);
+        if (fileIndex === -1) {
+            this.files.push(upload.data)
+        } else {
+            this.files.splice(fileIndex, 1, upload.data);
+        }
+
+        if (this.fileToEdit && this.fileToEdit.id === upload.data.id) {
+            this.fileToEdit = Object.assign({}, upload.data);
+        }
+        this.$events.emit('success', trans('entities.attachments_file_updated'));
+    },
+
+    checkValidationErrors(groupName, err) {
+        console.error(err);
+        if (typeof err.response.data === "undefined" && typeof err.response.data.validation === "undefined") return;
+        this.errors[groupName] = err.response.data.validation;
+        console.log(this.errors[groupName]);
+    },
+
+    getUploadUrl(file) {
+        let url = window.baseUrl(`/attachments/upload`);
+        if (typeof file !== 'undefined') url += `/${file.id}`;
+        return url;
+    },
+
+    cancelEdit() {
+        this.fileToEdit = null;
+    },
+
+    attachNewLink(file) {
+        file.uploaded_to = this.pageId;
+        this.$http.post(window.baseUrl('/attachments/link'), file).then(resp => {
+            this.files.push(resp.data);
+            this.file = this.newFile();
+            this.$events.emit('success', trans('entities.attachments_link_attached'));
+        }).catch(err => {
+            this.checkValidationErrors('link', err);
+        });
+    },
+
+    updateFile(file) {
+        $http.put(window.baseUrl(`/attachments/${file.id}`), file).then(resp => {
+            let search = this.filesIndex(resp.data);
+            if (search === -1) {
+                this.files.push(resp.data);
+            } else {
+                this.files.splice(search, 1, resp.data);
+            }
+
+            if (this.fileToEdit && !file.external) this.fileToEdit.link = '';
+            this.fileToEdit = false;
+
+            this.$events.emit('success', trans('entities.attachments_updated_success'));
+        }).catch(err => {
+            this.checkValidationErrors('edit', err);
+        });
+    },
+
+    filesIndex(file) {
+        for (let i = 0, len = this.files.length; i < len; i++) {
+            if (this.files[i].id === file.id) return i;
+        }
+        return -1;
+    }
+
+};
+
+module.exports = {
+    data, methods, mounted, components,
+};
\ No newline at end of file
diff --git a/resources/assets/js/vues/components/autosuggest.js b/resources/assets/js/vues/components/autosuggest.js
new file mode 100644
index 000000000..4d6b97e55
--- /dev/null
+++ b/resources/assets/js/vues/components/autosuggest.js
@@ -0,0 +1,130 @@
+
+const template = `
+    <div>
+        <input :value="value" :autosuggest-type="type" ref="input"
+            :placeholder="placeholder" :name="name"
+            @input="inputUpdate($event.target.value)" @focus="inputUpdate($event.target.value)"
+            @blur="inputBlur"
+            @keydown="inputKeydown"
+        />
+        <ul class="suggestion-box" v-if="showSuggestions">
+            <li v-for="(suggestion, i) in suggestions"
+                @click="selectSuggestion(suggestion)"
+                :class="{active: (i === active)}">{{suggestion}}</li>
+        </ul>
+    </div>
+    
+`;
+
+function data() {
+    return {
+        suggestions: [],
+        showSuggestions: false,
+        active: 0,
+    };
+}
+
+const ajaxCache = {};
+
+const props = ['url', 'type', 'value', 'placeholder', 'name'];
+
+function getNameInputVal(valInput) {
+    let parentRow = valInput.parentNode.parentNode;
+    let nameInput = parentRow.querySelector('[autosuggest-type="name"]');
+    return (nameInput === null) ? '' : nameInput.value;
+}
+
+const methods = {
+
+    inputUpdate(inputValue) {
+        this.$emit('input', inputValue);
+        let params = {};
+
+        if (this.type === 'value') {
+            let nameVal = getNameInputVal(this.$el);
+            if (nameVal !== "") params.name = nameVal;
+        }
+
+        this.getSuggestions(inputValue.slice(0, 3), params).then(suggestions => {
+            if (inputValue.length === 0) {
+                this.displaySuggestions(suggestions.slice(0, 6));
+                return;
+            }
+            // Filter to suggestions containing searched term
+            suggestions = suggestions.filter(item => {
+                return item.toLowerCase().indexOf(inputValue.toLowerCase()) !== -1;
+            }).slice(0, 4);
+            this.displaySuggestions(suggestions);
+        });
+    },
+
+    inputBlur() {
+        setTimeout(() => {
+            this.$emit('blur');
+            this.showSuggestions = false;
+        }, 100);
+    },
+
+    inputKeydown(event) {
+        if (event.keyCode === 13) event.preventDefault();
+        if (!this.showSuggestions) return;
+
+        // Down arrow
+        if (event.keyCode === 40) {
+            this.active = (this.active === this.suggestions.length - 1) ? 0 : this.active+1;
+        }
+        // Up Arrow
+        else if (event.keyCode === 38) {
+            this.active = (this.active === 0) ? this.suggestions.length - 1 : this.active-1;
+        }
+        // Enter or tab keys
+        else if ((event.keyCode === 13 || event.keyCode === 9) && !event.shiftKey) {
+            this.selectSuggestion(this.suggestions[this.active]);
+        }
+        // Escape key
+        else if (event.keyCode === 27) {
+            this.showSuggestions = false;
+        }
+    },
+
+    displaySuggestions(suggestions) {
+        if (suggestions.length === 0) {
+            this.suggestions = [];
+            this.showSuggestions = false;
+            return;
+        }
+
+        this.suggestions = suggestions;
+        this.showSuggestions = true;
+        this.active = 0;
+    },
+
+    selectSuggestion(suggestion) {
+        this.$refs.input.value = suggestion;
+        this.$refs.input.focus();
+        this.$emit('input', suggestion);
+        this.showSuggestions = false;
+    },
+
+    /**
+     * Get suggestions from BookStack. Store and use local cache if already searched.
+     * @param {String} input
+     * @param {Object} params
+     */
+    getSuggestions(input, params) {
+        params.search = input;
+        let cacheKey = `${this.url}:${JSON.stringify(params)}`;
+
+        if (typeof ajaxCache[cacheKey] !== "undefined") return Promise.resolve(ajaxCache[cacheKey]);
+
+        return this.$http.get(this.url, {params}).then(resp => {
+            ajaxCache[cacheKey] = resp.data;
+            return resp.data;
+        });
+    }
+
+};
+
+const computed = [];
+
+module.exports = {template, data, props, methods, computed};
\ No newline at end of file
diff --git a/resources/assets/js/vues/components/dropzone.js b/resources/assets/js/vues/components/dropzone.js
new file mode 100644
index 000000000..0f31bd579
--- /dev/null
+++ b/resources/assets/js/vues/components/dropzone.js
@@ -0,0 +1,60 @@
+const DropZone = require("dropzone");
+
+const template = `
+    <div class="dropzone-container">
+        <div class="dz-message">{{placeholder}}</div>
+    </div>
+`;
+
+const props = ['placeholder', 'uploadUrl', 'uploadedTo'];
+
+// TODO - Remove jQuery usage
+function mounted() {
+   let container = this.$el;
+   let _this = this;
+   new DropZone(container, {
+        url: function() {
+            return _this.uploadUrl;
+        },
+        init: function () {
+            let dz = this;
+
+            dz.on('sending', function (file, xhr, data) {
+                let token = window.document.querySelector('meta[name=token]').getAttribute('content');
+                data.append('_token', token);
+                let uploadedTo = typeof _this.uploadedTo === 'undefined' ? 0 : _this.uploadedTo;
+                data.append('uploaded_to', uploadedTo);
+            });
+
+            dz.on('success', function (file, data) {
+                _this.$emit('success', {file, data});
+                $(file.previewElement).fadeOut(400, function () {
+                    dz.removeFile(file);
+                });
+            });
+
+            dz.on('error', function (file, errorMessage, xhr) {
+                _this.$emit('error', {file, errorMessage, xhr});
+                console.log(errorMessage);
+                console.log(xhr);
+                function setMessage(message) {
+                    $(file.previewElement).find('[data-dz-errormessage]').text(message);
+                }
+
+                if (xhr.status === 413) setMessage(trans('errors.server_upload_limit'));
+                if (errorMessage.file) setMessage(errorMessage.file[0]);
+            });
+        }
+   });
+}
+
+function data() {
+    return {}
+}
+
+module.exports = {
+    template,
+    props,
+    mounted,
+    data,
+};
\ No newline at end of file
diff --git a/resources/assets/js/vues/entity-search.js b/resources/assets/js/vues/entity-dashboard.js
similarity index 100%
rename from resources/assets/js/vues/entity-search.js
rename to resources/assets/js/vues/entity-dashboard.js
diff --git a/resources/assets/js/vues/image-manager.js b/resources/assets/js/vues/image-manager.js
new file mode 100644
index 000000000..12ccc970d
--- /dev/null
+++ b/resources/assets/js/vues/image-manager.js
@@ -0,0 +1,178 @@
+const dropzone = require('./components/dropzone');
+
+let page = 0;
+let previousClickTime = 0;
+let previousClickImage = 0;
+let dataLoaded = false;
+let callback = false;
+let baseUrl = '';
+
+let preSearchImages = [];
+let preSearchHasMore = false;
+
+const data = {
+    images: [],
+
+    imageType: false,
+    uploadedTo: false,
+
+    selectedImage: false,
+    dependantPages: false,
+    showing: false,
+    view: 'all',
+    hasMore: false,
+    searching: false,
+    searchTerm: '',
+
+    imageUpdateSuccess: false,
+    imageDeleteSuccess: false,
+};
+
+const methods = {
+
+    show(providedCallback) {
+        callback = providedCallback;
+        this.showing = true;
+        this.$el.children[0].components.overlay.show();
+
+        // Get initial images if they have not yet been loaded in.
+        if (dataLoaded) return;
+        this.fetchData();
+        dataLoaded = true;
+    },
+
+    hide() {
+        this.showing = false;
+        this.$el.children[0].components.overlay.hide();
+    },
+
+    fetchData() {
+        let url = baseUrl + page;
+        let query = {};
+        if (this.uploadedTo !== false) query.page_id = this.uploadedTo;
+        if (this.searching) query.term = this.searchTerm;
+
+        this.$http.get(url, {params: query}).then(response => {
+            this.images = this.images.concat(response.data.images);
+            this.hasMore = response.data.hasMore;
+            page++;
+        });
+    },
+
+    setView(viewName) {
+        this.cancelSearch();
+        this.images = [];
+        this.hasMore = false;
+        page = 0;
+        this.view = viewName;
+        baseUrl = window.baseUrl(`/images/${this.imageType}/${viewName}/`);
+        this.fetchData();
+    },
+
+    searchImages() {
+        if (this.searchTerm === '') return this.cancelSearch();
+
+        // Cache current settings for later
+        if (!this.searching) {
+            preSearchImages = this.images;
+            preSearchHasMore = this.hasMore;
+        }
+
+        this.searching = true;
+        this.images = [];
+        this.hasMore = false;
+        page = 0;
+        baseUrl = window.baseUrl(`/images/${this.imageType}/search/`);
+        this.fetchData();
+    },
+
+    cancelSearch() {
+        this.searching = false;
+        this.searchTerm = '';
+        this.images = preSearchImages;
+        this.hasMore = preSearchHasMore;
+    },
+
+    imageSelect(image) {
+        let dblClickTime = 300;
+        let currentTime = Date.now();
+        let timeDiff = currentTime - previousClickTime;
+        let isDblClick = timeDiff < dblClickTime && image.id === previousClickImage;
+
+        if (isDblClick) {
+            this.callbackAndHide(image);
+        } else {
+            this.selectedImage = image;
+            this.dependantPages = false;
+        }
+
+        previousClickTime = currentTime;
+        previousClickImage = image.id;
+    },
+
+    callbackAndHide(imageResult) {
+        if (callback) callback(imageResult);
+        this.hide();
+    },
+
+    saveImageDetails() {
+        let url = window.baseUrl(`/images/update/${this.selectedImage.id}`);
+        this.$http.put(url, this.selectedImage).then(response => {
+            this.$events.emit('success', trans('components.image_update_success'));
+        }).catch(error => {
+            if (error.response.status === 422) {
+                let errors = error.response.data;
+                let message = '';
+                Object.keys(errors).forEach((key) => {
+                    message += errors[key].join('\n');
+                });
+                this.$events.emit('error', message);
+            }
+        });
+    },
+
+    deleteImage() {
+        let force = this.dependantPages !== false;
+        let url = window.baseUrl('/images/' + this.selectedImage.id);
+        if (force) url += '?force=true';
+        this.$http.delete(url).then(response => {
+            this.images.splice(this.images.indexOf(this.selectedImage), 1);
+            this.selectedImage = false;
+            this.$events.emit('success', trans('components.image_delete_success'));
+        }).catch(error=> {
+            if (error.response.status === 400) {
+                this.dependantPages = error.response.data;
+            }
+        });
+    },
+
+    getDate(stringDate) {
+        return new Date(stringDate);
+    },
+
+    uploadSuccess(event) {
+        this.images.unshift(event.data);
+        this.$events.emit('success', trans('components.image_upload_success'));
+    },
+};
+
+const computed = {
+    uploadUrl() {
+        return window.baseUrl(`/images/${this.imageType}/upload`);
+    }
+};
+
+function mounted() {
+    window.ImageManager = this;
+    this.imageType = this.$el.getAttribute('image-type');
+    this.uploadedTo = this.$el.getAttribute('uploaded-to');
+    baseUrl = window.baseUrl('/images/' + this.imageType + '/all/')
+}
+
+module.exports = {
+    mounted,
+    methods,
+    data,
+    computed,
+    components: {dropzone},
+};
\ No newline at end of file
diff --git a/resources/assets/js/vues/search.js b/resources/assets/js/vues/search.js
index 515ca3bc9..8cb790d24 100644
--- a/resources/assets/js/vues/search.js
+++ b/resources/assets/js/vues/search.js
@@ -149,7 +149,7 @@ let methods = {
 
     updateSearch(e) {
         e.preventDefault();
-        window.location = '/search?term=' + encodeURIComponent(this.termString);
+        window.location = window.baseUrl('/search?term=' + encodeURIComponent(this.termString));
     },
 
     enableDate(optionName) {
@@ -192,4 +192,4 @@ function created() {
 
 module.exports = {
     data, computed, methods, created
-};
\ No newline at end of file
+};
diff --git a/resources/assets/js/vues/tag-manager.js b/resources/assets/js/vues/tag-manager.js
new file mode 100644
index 000000000..d97ceb96b
--- /dev/null
+++ b/resources/assets/js/vues/tag-manager.js
@@ -0,0 +1,68 @@
+const draggable = require('vuedraggable');
+const autosuggest = require('./components/autosuggest');
+
+let data = {
+    pageId: false,
+    tags: [],
+};
+
+const components = {draggable, autosuggest};
+const directives = {};
+
+let computed = {};
+
+let methods = {
+
+    addEmptyTag() {
+        this.tags.push({name: '', value: '', key: Math.random().toString(36).substring(7)});
+    },
+
+    /**
+     * When an tag changes check if another empty editable field needs to be added onto the end.
+     * @param tag
+     */
+    tagChange(tag) {
+        let tagPos = this.tags.indexOf(tag);
+        if (tagPos === this.tags.length-1 && (tag.name !== '' || tag.value !== '')) this.addEmptyTag();
+    },
+
+    /**
+     * When an tag field loses focus check the tag to see if its
+     * empty and therefore could be removed from the list.
+     * @param tag
+     */
+    tagBlur(tag) {
+        let isLast = (this.tags.indexOf(tag) === this.tags.length-1);
+        if (tag.name !== '' || tag.value !== '' || isLast) return;
+        let cPos = this.tags.indexOf(tag);
+        this.tags.splice(cPos, 1);
+    },
+
+    removeTag(tag) {
+        let tagPos = this.tags.indexOf(tag);
+        if (tagPos === -1) return;
+        this.tags.splice(tagPos, 1);
+    },
+
+    getTagFieldName(index, key) {
+        return `tags[${index}][${key}]`;
+    },
+};
+
+function mounted() {
+    this.pageId = Number(this.$el.getAttribute('page-id'));
+
+    let url = window.baseUrl(`/ajax/tags/get/page/${this.pageId}`);
+    this.$http.get(url).then(response => {
+        let tags = response.data;
+        for (let i = 0, len = tags.length; i < len; i++) {
+            tags[i].key = Math.random().toString(36).substring(7);
+        }
+        this.tags = tags;
+        this.addEmptyTag();
+    });
+}
+
+module.exports = {
+    data, computed, methods, mounted, components, directives
+};
\ No newline at end of file
diff --git a/resources/assets/js/vues/vues.js b/resources/assets/js/vues/vues.js
index 31d833bfb..a70d32009 100644
--- a/resources/assets/js/vues/vues.js
+++ b/resources/assets/js/vues/vues.js
@@ -6,16 +6,19 @@ function exists(id) {
 
 let vueMapping = {
     'search-system': require('./search'),
-    'entity-dashboard': require('./entity-search'),
-    'code-editor': require('./code-editor')
+    'entity-dashboard': require('./entity-dashboard'),
+    'code-editor': require('./code-editor'),
+    'image-manager': require('./image-manager'),
+    'tag-manager': require('./tag-manager'),
+    'attachment-manager': require('./attachment-manager'),
 };
 
 window.vues = {};
 
-Object.keys(vueMapping).forEach(id => {
-    if (exists(id)) {
-        let config = vueMapping[id];
-        config.el = '#' + id;
-        window.vues[id] = new Vue(config);
-    }
-});
\ No newline at end of file
+let ids = Object.keys(vueMapping);
+for (let i = 0, len = ids.length; i < len; i++) {
+    if (!exists(ids[i])) continue;
+    let config = vueMapping[ids[i]];
+    config.el = '#' + ids[i];
+    window.vues[ids[i]] = new Vue(config);
+}
\ No newline at end of file
diff --git a/resources/assets/sass/_animations.scss b/resources/assets/sass/_animations.scss
index 467399a66..015a23ab1 100644
--- a/resources/assets/sass/_animations.scss
+++ b/resources/assets/sass/_animations.scss
@@ -36,41 +36,12 @@
   }
 }
 
-.anim.notification {
-  transform: translate3d(580px, 0, 0);
-  animation-name: notification;
-  animation-duration: 3s;
-  animation-timing-function: ease-in-out;
-  animation-fill-mode: forwards;
-  &.stopped {
-    animation-name: notificationStopped;
-  }
-}
-
-@keyframes notification {
-  0% {
-    transform: translate3d(580px, 0, 0);
-  }
-  10% {
-    transform: translate3d(0, 0, 0);
-  }
-  90% {
-    transform: translate3d(0, 0, 0);
-  }
-  100% {
-    transform: translate3d(580px, 0, 0);
-  }
-}
-@keyframes notificationStopped {
-  0% {
-    transform: translate3d(580px, 0, 0);
-  }
-  10% {
-    transform: translate3d(0, 0, 0);
-  }
-  100% {
-    transform: translate3d(0, 0, 0);
-  }
+.anim.menuIn {
+  transform-origin: 100% 0%;
+  animation-name: menuIn;
+  animation-duration: 120ms;
+  animation-delay: 0s;
+  animation-timing-function: cubic-bezier(.62, .28, .23, .99);
 }
 
 @keyframes menuIn {
@@ -85,14 +56,6 @@
   }
 }
 
-.anim.menuIn {
-  transform-origin: 100% 0%;
-  animation-name: menuIn;
-  animation-duration: 120ms;
-  animation-delay: 0s;
-  animation-timing-function: cubic-bezier(.62, .28, .23, .99);
-}
-
 @keyframes loadingBob {
   0% {
     transform: translate3d(0, 0, 0);
diff --git a/resources/assets/sass/_blocks.scss b/resources/assets/sass/_blocks.scss
index bd3f8ff4e..e3a0d6952 100644
--- a/resources/assets/sass/_blocks.scss
+++ b/resources/assets/sass/_blocks.scss
@@ -134,8 +134,7 @@
 .callout {
   border-left: 3px solid #BBB;
   background-color: #EEE;
-  padding: $-s;
-  padding-left: $-xl;
+  padding: $-s $-s $-s $-xl;
   display: block;
   position: relative;
   &:before {
diff --git a/resources/assets/sass/_buttons.scss b/resources/assets/sass/_buttons.scss
index 6e03c9217..202eb935b 100644
--- a/resources/assets/sass/_buttons.scss
+++ b/resources/assets/sass/_buttons.scss
@@ -31,7 +31,6 @@ $button-border-radius: 2px;
   display: inline-block;
   border: none;
   font-weight: 500;
-  font-family: $text;
   outline: 0;
   border-radius: $button-border-radius;
   cursor: pointer;
@@ -65,6 +64,7 @@ $button-border-radius: 2px;
   padding: 0;
   margin: 0;
   border: none;
+  user-select: none;
   &:focus, &:active {
     outline: 0;
   }
diff --git a/resources/assets/sass/_codemirror.scss b/resources/assets/sass/_codemirror.scss
index bd85218a5..e281d4c0d 100644
--- a/resources/assets/sass/_codemirror.scss
+++ b/resources/assets/sass/_codemirror.scss
@@ -2,7 +2,6 @@
 
 .CodeMirror {
   /* Set height, width, borders, and global font properties here */
-  font-family: monospace;
   height: 300px;
   color: black;
 }
@@ -235,7 +234,6 @@ div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #f22;}
   -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0;
   border-width: 0;
   background: transparent;
-  font-family: inherit;
   font-size: inherit;
   margin: 0;
   white-space: pre;
diff --git a/resources/assets/sass/_comments.scss b/resources/assets/sass/_comments.scss
new file mode 100644
index 000000000..5da53a14d
--- /dev/null
+++ b/resources/assets/sass/_comments.scss
@@ -0,0 +1,82 @@
+.comments-list {
+    .comment-box {
+        border-bottom: 1px solid $comment-border;
+    }
+
+    .comment-box:last-child {
+        border-bottom: 0px;
+    }
+}
+.page-comment {
+    .comment-container {
+        margin-left: 42px;
+    }
+
+    .comment-actions {
+        font-size: 0.8em;
+        padding-bottom: 2px;
+
+        ul {
+            padding-left: 0px;
+            margin-bottom: 2px;
+        }
+        li {
+            float: left;
+            list-style-type: none;
+        }
+
+        li:after {
+            content: '•';
+            color: #707070;
+            padding: 0 5px;
+            font-size: 1em;
+        }
+
+        li:last-child:after {
+            content: none;
+        }
+    }
+
+    .comment-actions {
+        border-bottom: 1px solid #DDD;
+    }
+
+    .comment-actions:last-child {
+        border-bottom: 0px;
+    }
+
+    .comment-header {
+        font-size: 1.25em;
+        margin-top: 0.6em;
+    }
+
+    .comment-body p {
+        margin-bottom: 1em;
+    }
+
+    .comment-inactive {
+        font-style: italic;
+        font-size: 0.85em;
+        padding-top: 5px;
+    }
+
+    .user-image {
+        float: left;
+        margin-right: 10px;
+        width: 32px;
+        img {
+            width: 100%;
+        }
+    }
+}
+
+.comment-editor {
+    margin-top: 2em;
+
+    textarea {
+        display: block;
+        width: 100%;
+        max-width: 100%;
+        min-height: 120px;
+    }
+}
diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss
index 12babae73..525b4f8f1 100644
--- a/resources/assets/sass/_components.scss
+++ b/resources/assets/sass/_components.scss
@@ -1,4 +1,65 @@
-.overlay {
+// System wide notifications
+[notification] {
+  position: fixed;
+  top: 0;
+  right: 0;
+  margin: $-xl*2 $-xl;
+  padding: $-l $-xl;
+  background-color: #EEE;
+  border-radius: 3px;
+  box-shadow: $bs-med;
+  z-index: 999999;
+  display: block;
+  cursor: pointer;
+  max-width: 480px;
+  transition: transform ease-in-out 360ms;
+  transform: translate3d(580px, 0, 0);
+  i, span {
+    display: table-cell;
+  }
+  i {
+    font-size: 2em;
+    padding-right: $-l;
+  }
+  span {
+    vertical-align: middle;
+  }
+  &.pos {
+    background-color: $positive;
+    color: #EEE;
+  }
+  &.neg {
+    background-color: $negative;
+    color: #EEE;
+  }
+  &.warning {
+    background-color: $secondary;
+    color: #EEE;
+  }
+  &.showing {
+    transform: translate3d(0, 0, 0);
+  }
+}
+
+[chapter-toggle] {
+  cursor: pointer;
+  margin: 0;
+  transition: all ease-in-out 180ms;
+  user-select: none;
+  i.zmdi-caret-right {
+    transition: all ease-in-out 180ms;
+    transform: rotate(0deg);
+    transform-origin: 25% 50%;
+  }
+  &.open {
+    //margin-bottom: 0;
+  }
+  &.open i.zmdi-caret-right {
+    transform: rotate(90deg);
+  }
+}
+
+[overlay] {
   background-color: rgba(0, 0, 0, 0.333);
   position: fixed;
   z-index: 95536;
@@ -451,7 +512,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 
 
-[tab-container] .nav-tabs {
+.tab-container .nav-tabs {
   text-align: left;
   border-bottom: 1px solid #DDD;
   margin-bottom: $-m;
diff --git a/resources/assets/sass/_fonts.scss b/resources/assets/sass/_fonts.scss
deleted file mode 100644
index c8e8ea833..000000000
--- a/resources/assets/sass/_fonts.scss
+++ /dev/null
@@ -1,102 +0,0 @@
-// Generated using https://google-webfonts-helper.herokuapp.com
-
-/* roboto-100 - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: normal;
-  font-weight: 100;
-  src: local('Roboto Thin'), local('Roboto-Thin'),
-  url('../fonts/roboto-v15-cyrillic_latin-100.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-100.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-100italic - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: italic;
-  font-weight: 100;
-  src: local('Roboto Thin Italic'), local('Roboto-ThinItalic'),
-  url('../fonts/roboto-v15-cyrillic_latin-100italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-100italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-300 - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: normal;
-  font-weight: 300;
-  src: local('Roboto Light'), local('Roboto-Light'),
-  url('../fonts/roboto-v15-cyrillic_latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-300italic - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: italic;
-  font-weight: 300;
-  src: local('Roboto Light Italic'), local('Roboto-LightItalic'),
-  url('../fonts/roboto-v15-cyrillic_latin-300italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-300italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-regular - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: normal;
-  font-weight: 400;
-  src: local('Roboto'), local('Roboto-Regular'),
-  url('../fonts/roboto-v15-cyrillic_latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-italic - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: italic;
-  font-weight: 400;
-  src: local('Roboto Italic'), local('Roboto-Italic'),
-  url('../fonts/roboto-v15-cyrillic_latin-italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-500 - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: normal;
-  font-weight: 500;
-  src: local('Roboto Medium'), local('Roboto-Medium'),
-  url('../fonts/roboto-v15-cyrillic_latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-500italic - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: italic;
-  font-weight: 500;
-  src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'),
-  url('../fonts/roboto-v15-cyrillic_latin-500italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-500italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-700 - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: normal;
-  font-weight: 700;
-  src: local('Roboto Bold'), local('Roboto-Bold'),
-  url('../fonts/roboto-v15-cyrillic_latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-/* roboto-700italic - cyrillic_latin */
-@font-face {
-  font-family: 'Roboto';
-  font-style: italic;
-  font-weight: 700;
-  src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'),
-  url('../fonts/roboto-v15-cyrillic_latin-700italic.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-v15-cyrillic_latin-700italic.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
-
-/* roboto-mono-regular - latin */
-@font-face {
-  font-family: 'Roboto Mono';
-  font-style: normal;
-  font-weight: 400;
-  src: local('Roboto Mono'), local('RobotoMono-Regular'),
-  url('../fonts/roboto-mono-v4-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+ */
-  url('../fonts/roboto-mono-v4-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
-}
\ No newline at end of file
diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss
index 392e9ec3e..d372359cc 100644
--- a/resources/assets/sass/_forms.scss
+++ b/resources/assets/sass/_forms.scss
@@ -5,7 +5,6 @@
   border: 1px solid #CCC;
   display: inline-block;
   font-size: $fs-s;
-  font-family: $text;
   padding: $-xs;
   color: #222;
   width: 250px;
@@ -33,7 +32,6 @@
   position: relative;
   z-index: 5;
   #markdown-editor-input {
-    font-family: 'Roboto Mono', monospace;
     font-style: normal;
     font-weight: 400;
     padding: $-xs $-m;
@@ -69,7 +67,6 @@
 .editor-toolbar {
   width: 100%;
   padding: $-xs $-m;
-  font-family: 'Roboto Mono', monospace;
   font-size: 11px;
   line-height: 1.6;
   border-bottom: 1px solid #DDD;
@@ -251,21 +248,20 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
     border: none;
     color: $primary;
     padding: 0;
-    margin: 0;
     cursor: pointer;
-    margin-left: $-s;
-  }
-  button[type="submit"] {
-    margin-left: -$-l;
+    position: absolute;
+    left: 7px;
+    top: 7px;
   }
   input {
-    padding-right: $-l;
+    display: block;
+    padding-left: $-l;
     width: 300px;
     max-width: 100%;
   }
 }
 
-input.outline {
+.outline > input {
   border: 0;
   border-bottom: 2px solid #DDD;
   border-radius: 0;
diff --git a/resources/assets/sass/_header.scss b/resources/assets/sass/_header.scss
index 16ed75545..49bd74b07 100644
--- a/resources/assets/sass/_header.scss
+++ b/resources/assets/sass/_header.scss
@@ -12,7 +12,6 @@ header {
     padding: $-m;
   }
   border-bottom: 1px solid #DDD;
-  //margin-bottom: $-l;
   .links {
     display: inline-block;
     vertical-align: top;
@@ -23,26 +22,27 @@ header {
   }
   .links a {
     display: inline-block;
-    padding: $-l;
+    padding: $-m $-l;
     color: #FFF;
     &:last-child {
       padding-right: 0;
     }
     @include smaller-than($screen-md) {
-      padding: $-l $-s;
+      padding: $-m $-s;
     }
   }
   .avatar, .user-name {
     display: inline-block;
   }
   .avatar {
-    //margin-top: (45px/2);
     width: 30px;
     height: 30px;
   }
   .user-name {
     vertical-align: top;
-    padding-top: $-l;
+    padding-top: $-m;
+    position: relative;
+    top: -3px;
     display: inline-block;
     cursor: pointer;
     > * {
@@ -66,53 +66,57 @@ header {
       }
     }
   }
-  @include smaller-than($screen-md) {
+  @include smaller-than($screen-sm) {
     text-align: center;
     .float.right {
       float: none;
     }
-  }
-  @include smaller-than($screen-sm) {
     .links a {
       padding: $-s;
     }
-    form.search-box {
-      margin-top: 0;
-    }
     .user-name {
       padding-top: $-s;
     }
   }
-  .dropdown-container {
-    font-size: 0.9em;
+}
+
+.header-search {
+  display: inline-block;
+}
+header .search-box {
+  display: inline-block;
+  margin-top: $-s;
+  input {
+    background-color: rgba(0, 0, 0, 0.2);
+    border: 1px solid rgba(255, 255, 255, 0.3);
+    color: #EEE;
+  }
+  button {
+    color: #EEE;
+  }
+  ::-webkit-input-placeholder { /* Chrome/Opera/Safari */
+    color: #DDD;
+  }
+  ::-moz-placeholder { /* Firefox 19+ */
+    color: #DDD;
+  }
+  :-ms-input-placeholder { /* IE 10+ */
+    color: #DDD;
+  }
+  :-moz-placeholder { /* Firefox 18- */
+    color: #DDD;
+  }
+  @include smaller-than($screen-lg) {
+    max-width: 250px;
+  }
+  @include smaller-than($l) {
+    max-width: 200px;
   }
 }
 
-form.search-box {
-  margin-top: $-l *0.9;
-  display: inline-block;
-  position: relative;
-  text-align: left;
-  input {
-    background-color: transparent;
-    border-radius: 24px;
-    border: 2px solid #EEE;
-    color: #EEE;
-    padding-left: $-m;
-    padding-right: $-l;
-    outline: 0;
-  }
-  button {
-    vertical-align: top;
-    margin-left: -$-l;
-    color: #FFF;
-    top: 6px;
-    right: 4px;
-    display: inline-block;
-    position: absolute;
-    &:hover {
-      color: #FFF;
-    }
+@include smaller-than($s) {
+  .header-search {
+    display: block;
   }
 }
 
@@ -128,12 +132,12 @@ form.search-box {
   font-size: 1.8em;
   color: #fff;
   font-weight: 400;
-  padding: $-l $-l $-l 0;
+  padding: 14px $-l 14px 0;
   vertical-align: top;
   line-height: 1;
 }
 .logo-image {
-  margin: $-m $-s $-m 0;
+  margin: $-xs $-s $-xs 0;
   vertical-align: top;
   height: 43px;
 }
@@ -142,7 +146,6 @@ form.search-box {
   color: #aaa;
   padding: 0 $-xs;
 }
-
 .faded {
   a, button, span, span > div {
     color: #666;
@@ -178,6 +181,8 @@ form.search-box {
     padding-left: 0;
   }
 }
+
+
 .action-buttons .dropdown-container:last-child a {
   padding-right: 0;
   padding-left: $-s;
@@ -196,6 +201,25 @@ form.search-box {
   }
 }
 
+@include smaller-than($m) {
+  .breadcrumbs .text-button, .action-buttons .text-button {
+    padding: $-s $-xs;
+  }
+  .action-buttons .dropdown-container:last-child a {
+    padding-left: $-xs;
+  }
+  .breadcrumbs .text-button {
+    font-size: 0;
+  }
+  .breadcrumbs a i {
+    font-size: $fs-m;
+    padding-right: 0;
+  }
+  .breadcrumbs span.sep {
+    padding: 0 $-xxs;
+  }
+}
+
 .nav-tabs {
   text-align: center;
   a, .tab-item {
@@ -207,4 +231,7 @@ form.search-box {
       border-bottom: 2px solid $primary;
     }
   }
+}
+.faded-small .nav-tabs a {
+  padding: $-s $-m;
 }
\ No newline at end of file
diff --git a/resources/assets/sass/_html.scss b/resources/assets/sass/_html.scss
index c061f9d64..27ca04eb7 100644
--- a/resources/assets/sass/_html.scss
+++ b/resources/assets/sass/_html.scss
@@ -12,7 +12,6 @@ html {
 }
 
 body {
-  font-family: $text;
   font-size: $fs-m;
   line-height: 1.6;
   color: #616161;
diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss
index 051268926..d08ccc9bb 100644
--- a/resources/assets/sass/_lists.scss
+++ b/resources/assets/sass/_lists.scss
@@ -9,7 +9,6 @@
   .inset-list {
     display: none;
     overflow: hidden;
-    margin-bottom: $-l;
   }
   h5 {
     display: block;
@@ -22,6 +21,9 @@
       border-left-color: $color-page-draft;
     }
   }
+  .entity-list-item {
+    margin-bottom: $-m;
+  }
   hr {
     margin-top: 0;
   }
@@ -51,23 +53,6 @@
     margin-right: $-s;
   }
 }
-.chapter-toggle {
-  cursor: pointer;
-  margin: 0 0 $-l 0;
-  transition: all ease-in-out 180ms;
-  user-select: none;
-  i.zmdi-caret-right {
-    transition: all ease-in-out 180ms;
-    transform: rotate(0deg);
-    transform-origin: 25% 50%;
-  }
-  &.open {
-    margin-bottom: 0;
-  }
-  &.open i.zmdi-caret-right {
-    transform: rotate(90deg);
-  }
-}
 
 .sidebar-page-nav {
   $nav-indent: $-s;
@@ -171,7 +156,7 @@
       background-color: rgba($color-chapter, 0.12);
     }
   }
-  .chapter-toggle {
+  [chapter-toggle] {
     padding-left: $-s;
   }
   .list-item-chapter {
@@ -336,8 +321,10 @@ ul.pagination {
   h4, a {
     line-height: 1.2;
   }
-  p {
+  .entity-item-snippet {
     display: none;
+  }
+  p {
     font-size: $fs-m * 0.8;
     padding-top: $-xs;
     margin: 0;
diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss
index e5334c69c..65fdfbc4b 100755
--- a/resources/assets/sass/_pages.scss
+++ b/resources/assets/sass/_pages.scss
@@ -226,7 +226,7 @@
     width: 100%;
     min-width: 50px;
   }
-  .tags td {
+  .tags td, .tag-table > div > div > div {
     padding-right: $-s;
     padding-top: $-s;
     position: relative;
@@ -310,4 +310,8 @@
       background-color: #EEE;
     }
   }
+}
+
+.comment-editor .CodeMirror, .comment-editor .CodeMirror-scroll {
+  min-height: 175px;
 }
\ No newline at end of file
diff --git a/resources/assets/sass/_tables.scss b/resources/assets/sass/_tables.scss
index 21553b839..31ac92f60 100644
--- a/resources/assets/sass/_tables.scss
+++ b/resources/assets/sass/_tables.scss
@@ -59,12 +59,16 @@ table.list-table {
   }
 }
 
-table.file-table {
-  @extend .no-style;
-  td {
-    padding: $-xs;
+.fake-table {
+  display: table;
+  width: 100%;
+  > div {
+    display: table-row-group;
   }
-  .ui-sortable-helper {
-    display: table;
+  > div > div {
+    display: table-row;
+  }
+  > div > div > div {
+    display: table-cell;
   }
 }
\ No newline at end of file
diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss
index ccef2a70f..d38a5c515 100644
--- a/resources/assets/sass/_text.scss
+++ b/resources/assets/sass/_text.scss
@@ -1,3 +1,14 @@
+/**
+ * Fonts
+ */
+
+body, button, input, select, label {
+  font-family: $text;
+}
+.Codemirror, pre, #markdown-editor-input, .editor-toolbar, .code-base {
+  font-family: $mono;
+}
+
 /*
  * Header Styles
  */
@@ -58,7 +69,6 @@ a, .link {
   cursor: pointer;
   text-decoration: none;
   transition: color ease-in-out 80ms;
-  font-family: $text;
   line-height: 1.6;
   &:hover {
     text-decoration: underline;
@@ -131,7 +141,6 @@ sub, .subscript {
 }
 
 pre {
-  font-family: monospace;
   font-size: 12px;
   background-color: #f5f5f5;
   border: 1px solid #DDD;
@@ -152,6 +161,14 @@ pre {
   }
 }
 
+@media print {
+  pre {
+    padding-left: 12px;
+  }
+  pre:after {
+    display: none;
+  }
+}
 
 blockquote {
   display: block;
@@ -172,7 +189,6 @@ blockquote {
 
 .code-base {
     background-color: #F8F8F8;
-    font-family: monospace;
     font-size: 0.80em;
     border: 1px solid #DDD;
     border-radius: 3px;
diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss
index 23bf2b219..18880fa5b 100644
--- a/resources/assets/sass/_variables.scss
+++ b/resources/assets/sass/_variables.scss
@@ -27,8 +27,12 @@ $-xs: 6px;
 $-xxs: 3px;
 
 // Fonts
-$heading:  'Roboto', 'DejaVu Sans', Helvetica,  Arial, sans-serif;
-$text: 'Roboto', 'DejaVu Sans', Helvetica,  Arial, sans-serif;
+$text: -apple-system, BlinkMacSystemFont,
+"Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell",
+"Fira Sans", "Droid Sans", "Helvetica Neue",
+sans-serif;
+$mono: "Lucida Console", "DejaVu Sans Mono", "Ubunto Mono", Monaco, monospace;
+$heading: $text;
 $fs-m: 15px;
 $fs-s: 14px;
 
@@ -56,3 +60,6 @@ $text-light: #EEE;
 $bs-light: 0 0 4px 1px #CCC;
 $bs-med: 0 1px 3px 1px rgba(76, 76, 76, 0.26);
 $bs-hover: 0 2px 2px 1px rgba(0,0,0,.13);
+
+// comments
+$comment-border: #DDD;
\ No newline at end of file
diff --git a/resources/assets/sass/export-styles.scss b/resources/assets/sass/export-styles.scss
index 60450f3e2..19579004b 100644
--- a/resources/assets/sass/export-styles.scss
+++ b/resources/assets/sass/export-styles.scss
@@ -1,4 +1,3 @@
-//@import "reset";
 @import "variables";
 @import "mixins";
 @import "html";
@@ -10,6 +9,7 @@
 @import "header";
 @import "lists";
 @import "pages";
+@import "comments";
 
 table {
   border-spacing: 0;
diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss
index 0e8861905..51e47ea74 100644
--- a/resources/assets/sass/styles.scss
+++ b/resources/assets/sass/styles.scss
@@ -1,6 +1,5 @@
 @import "reset";
 @import "variables";
-@import "fonts";
 @import "mixins";
 @import "html";
 @import "text";
@@ -16,13 +15,13 @@
 @import "header";
 @import "lists";
 @import "pages";
+@import "comments";
 
-[v-cloak], [v-show] {
+[v-cloak] {
   display: none; opacity: 0;
   animation-name: none !important;
 }
 
-
 [ng\:cloak], [ng-cloak], .ng-cloak {
   display: none !important;
   user-select: none;
@@ -65,44 +64,6 @@ body.dragging, body.dragging * {
   }
 }
 
-// System wide notifications
-.notification {
-  position: fixed;
-  top: 0;
-  right: 0;
-  margin: $-xl*2 $-xl;
-  padding: $-l $-xl;
-  background-color: #EEE;
-  border-radius: 3px;
-  box-shadow: $bs-med;
-  z-index: 999999;
-  display: block;
-  cursor: pointer;
-  max-width: 480px;
-  i, span {
-    display: table-cell;
-  }
-  i {
-    font-size: 2em;
-    padding-right: $-l;
-  }
-  span {
-    vertical-align: middle;
-  }
-  &.pos {
-    background-color: $positive;
-    color: #EEE;
-  }
-  &.neg {
-    background-color: $negative;
-    color: #EEE;
-  }
-  &.warning {
-    background-color: $secondary;
-    color: #EEE;
-  }
-}
-
 // Loading icon
 $loadingSize: 10px;
 .loading-container {
@@ -150,7 +111,7 @@ $loadingSize: 10px;
 
 // Back to top link
 $btt-size: 40px;
-#back-to-top {
+[back-to-top] {
   background-color: $primary;
   position: fixed;
   bottom: $-m;
diff --git a/resources/lang/de/activities.php b/resources/lang/de/activities.php
index c2d20b3a6..3318ea752 100644
--- a/resources/lang/de/activities.php
+++ b/resources/lang/de/activities.php
@@ -8,33 +8,33 @@ return [
      */
 
     // Pages
-    'page_create'                 => 'Seite erstellt',
-    'page_create_notification'    => 'Seite erfolgreich erstellt',
-    'page_update'                 => 'Seite aktualisiert',
-    'page_update_notification'    => 'Seite erfolgreich aktualisiert',
-    'page_delete'                 => 'Seite gel&ouml;scht',
-    'page_delete_notification'    => 'Seite erfolgreich gel&ouml;scht',
-    'page_restore'                => 'Seite wiederhergstellt',
-    'page_restore_notification'   => 'Seite erfolgreich wiederhergstellt',
-    'page_move'                   => 'Seite verschoben',
+    'page_create'                 => 'hat Seite erstellt:',
+    'page_create_notification'    => 'hat Seite erfolgreich erstellt:',
+    'page_update'                 => 'hat Seite aktualisiert:',
+    'page_update_notification'    => 'hat Seite erfolgreich aktualisiert:',
+    'page_delete'                 => 'hat Seite gelöscht:',
+    'page_delete_notification'    => 'hat Seite erfolgreich gelöscht:',
+    'page_restore'                => 'hat Seite wiederhergstellt:',
+    'page_restore_notification'   => 'hat Seite erfolgreich wiederhergstellt:',
+    'page_move'                   => 'hat Seite verschoben:',
 
     // Chapters
-    'chapter_create'              => 'Kapitel erstellt',
-    'chapter_create_notification' => 'Kapitel erfolgreich erstellt',
-    'chapter_update'              => 'Kapitel aktualisiert',
-    'chapter_update_notification' => 'Kapitel erfolgreich aktualisiert',
-    'chapter_delete'              => 'Kapitel gel&ouml;scht',
-    'chapter_delete_notification' => 'Kapitel erfolgreich gel&ouml;scht',
-    'chapter_move'                => 'Kapitel verschoben',
+    'chapter_create'              => 'hat Kapitel erstellt:',
+    'chapter_create_notification' => 'hat Kapitel erfolgreich erstellt:',
+    'chapter_update'              => 'hat Kapitel aktualisiert:',
+    'chapter_update_notification' => 'hat Kapitel erfolgreich aktualisiert:',
+    'chapter_delete'              => 'hat Kapitel gelöscht',
+    'chapter_delete_notification' => 'hat Kapitel erfolgreich gelöscht:',
+    'chapter_move'                => 'hat Kapitel verschoben:',
 
     // Books
-    'book_create'                 => 'Buch erstellt',
-    'book_create_notification'    => 'Buch erfolgreich erstellt',
-    'book_update'                 => 'Buch aktualisiert',
-    'book_update_notification'    => 'Buch erfolgreich aktualisiert',
-    'book_delete'                 => 'Buch gel&ouml;scht',
-    'book_delete_notification'    => 'Buch erfolgreich gel&ouml;scht',
-    'book_sort'                   => 'Buch sortiert',
-    'book_sort_notification'      => 'Buch erfolgreich neu sortiert',
+    'book_create'                 => 'hat Buch erstellt:',
+    'book_create_notification'    => 'hat Buch erfolgreich erstellt:',
+    'book_update'                 => 'hat Buch aktualisiert:',
+    'book_update_notification'    => 'hat Buch erfolgreich aktualisiert:',
+    'book_delete'                 => 'hat Buch gelöscht:',
+    'book_delete_notification'    => 'hat Buch erfolgreich gelöscht:',
+    'book_sort'                   => 'hat Buch sortiert:',
+    'book_sort_notification'      => 'hat Buch erfolgreich neu sortiert:',
 
 ];
diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php
index f253cdfa1..8f4afe654 100644
--- a/resources/lang/de/auth.php
+++ b/resources/lang/de/auth.php
@@ -10,8 +10,8 @@ return [
     | these language lines according to your application's requirements.
     |
     */
-    'failed' => 'Dies sind keine g&uuml;ltigen Anmeldedaten.',
-    'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen sie es in :seconds Sekunden erneut.',
+    'failed' => 'Die eingegebenen Anmeldedaten sind ungültig.',
+    'throttle' => 'Zu viele Anmeldeversuche. Bitte versuchen Sie es in :seconds Sekunden erneut.',
 
     /**
      * Login & Register
@@ -29,16 +29,16 @@ return [
     'forgot_password' => 'Passwort vergessen?',
     'remember_me' => 'Angemeldet bleiben',
     'ldap_email_hint' => 'Bitte geben Sie eine E-Mail-Adresse ein, um diese mit dem Account zu nutzen.',
-    'create_account' => 'Account anlegen',
-    'social_login' => 'Social Login',
-    'social_registration' => 'Social Registrierung',
-    'social_registration_text' => 'Mit einem dieser Möglichkeiten registrieren oder anmelden.',
+    'create_account' => 'Account registrieren',
+    'social_login' => 'Mit Sozialem Netzwerk anmelden',
+    'social_registration' => 'Mit Sozialem Netzwerk registrieren',
+    'social_registration_text' => 'Mit einer dieser Dienste registrieren oder anmelden',
 
 
     'register_thanks' => 'Vielen Dank für Ihre Registrierung!',
-    'register_confirm' => 'Bitte prüfen Sie Ihren E-Mail Eingang und klicken auf den Verifizieren-Button, um :appName nutzen zu können.',
-    'registrations_disabled' => 'Die Registrierung ist momentan nicht möglich',
-    'registration_email_domain_invalid' => 'Diese E-Mail-Domain ist für die Benutzer der Applikation nicht freigeschaltet.',
+    'register_confirm' => 'Bitte prüfen Sie Ihren Posteingang und bestätigen Sie die Registrierung.',
+    'registrations_disabled' => 'Eine Registrierung ist momentan nicht möglich',
+    'registration_email_domain_invalid' => 'Sie können sich mit dieser E-Mail nicht registrieren.',
     'register_success' => 'Vielen Dank für Ihre Registrierung! Die Daten sind gespeichert und Sie sind angemeldet.',
 
 
@@ -46,30 +46,30 @@ return [
      * Password Reset
      */
     'reset_password' => 'Passwort vergessen',
-    'reset_password_send_instructions' => 'Bitte geben Sie unten Ihre E-Mail-Adresse ein und Sie erhalten eine E-Mail, um Ihr Passwort zurück zu setzen.',
+    'reset_password_send_instructions' => 'Bitte geben Sie Ihre E-Mail-Adresse ein. Danach erhalten Sie eine E-Mail mit einem Link zum Zurücksetzen Ihres Passwortes.',
     'reset_password_send_button' => 'Passwort zurücksetzen',
-    'reset_password_sent_success' => 'Eine E-Mail mit den Instruktionen, um Ihr Passwort zurückzusetzen wurde an :email gesendet.',
-    'reset_password_success' => 'Ihr Passwort wurde erfolgreich zurück gesetzt.',
+    'reset_password_sent_success' => 'Eine E-Mail mit dem Link zum Zurücksetzen Ihres Passwortes wurde an :email gesendet.',
+    'reset_password_success' => 'Ihr Passwort wurde erfolgreich zurückgesetzt.',
 
     'email_reset_subject' => 'Passwort zurücksetzen für :appName',
-    'email_reset_text' => 'Sie erhalten diese E-Mail, weil eine Passwort-Rücksetzung für Ihren Account beantragt wurde.',
-    'email_reset_not_requested' => 'Wenn Sie die Passwort-Rücksetzung nicht ausgelöst haben, ist kein weiteres Handeln notwendig.',
+    'email_reset_text' => 'Sie erhalten diese E-Mail, weil jemand versucht hat, Ihr Passwort zurückzusetzen.',
+    'email_reset_not_requested' => 'Wenn Sie das nicht waren, brauchen Sie nichts weiter zu tun.',
 
 
     /**
      * Email Confirmation
      */
-    'email_confirm_subject' => 'Best&auml;tigen sie ihre E-Mail Adresse bei :appName',
-    'email_confirm_greeting' => 'Danke, dass sie :appName beigetreten sind!',
-    'email_confirm_text' => 'Bitte best&auml;tigen sie ihre E-Mail Adresse, indem sie auf den Button klicken:',
-    'email_confirm_action' => 'E-Mail Adresse best&auml;tigen',
-    'email_confirm_send_error' => 'Best&auml;tigungs-E-Mail ben&ouml;tigt, aber das System konnte die E-Mail nicht versenden. Kontaktieren sie den Administrator, um sicherzustellen, dass das Sytsem korrekt eingerichtet ist.',
-    'email_confirm_success' => 'Ihre E-Mail Adresse wurde best&auml;tigt!',
-    'email_confirm_resent' => 'Best&auml;tigungs-E-Mail wurde erneut versendet, bitte &uuml;berpr&uuml;fen sie ihren Posteingang.',
+    'email_confirm_subject' => 'Bestätigen Sie Ihre E-Mail-Adresse für :appName',
+    'email_confirm_greeting' => 'Danke, dass Sie sich für :appName registriert haben!',
+    'email_confirm_text' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf die Schaltfläche klicken:',
+    'email_confirm_action' => 'E-Mail-Adresse bestätigen',
+    'email_confirm_send_error' => 'Leider konnte die für die Registrierung notwendige E-Mail zur bestätigung Ihrer E-Mail-Adresse nicht versandt werden. Bitte kontaktieren Sie den Systemadministrator!',
+    'email_confirm_success' => 'Ihre E-Mail-Adresse wurde best&auml;tigt!',
+    'email_confirm_resent' => 'Bestätigungs-E-Mail wurde erneut versendet, bitte überprüfen Sie Ihren Posteingang.',
 
     'email_not_confirmed' => 'E-Mail-Adresse ist nicht bestätigt',
     'email_not_confirmed_text' => 'Ihre E-Mail-Adresse ist bisher nicht bestätigt.',
     'email_not_confirmed_click_link' => 'Bitte klicken Sie auf den Link in der E-Mail, die Sie nach der Registrierung erhalten haben.',
     'email_not_confirmed_resend' => 'Wenn Sie die E-Mail nicht erhalten haben, können Sie die Nachricht erneut anfordern. Füllen Sie hierzu bitte das folgende Formular aus:',
-    'email_not_confirmed_resend_button' => 'Bestätigungs E-Mail erneut senden',
+    'email_not_confirmed_resend_button' => 'Bestätigungs-E-Mail erneut senden',
 ];
diff --git a/resources/lang/de/common.php b/resources/lang/de/common.php
index 8c1bb4569..af2793dfc 100644
--- a/resources/lang/de/common.php
+++ b/resources/lang/de/common.php
@@ -30,9 +30,9 @@ return [
     'edit' => 'Bearbeiten',
     'sort' => 'Sortieren',
     'move' => 'Verschieben',
-    'delete' => 'L&ouml;schen',
+    'delete' => 'Löschen',
     'search' => 'Suchen',
-    'search_clear' => 'Suche l&ouml;schen',
+    'search_clear' => 'Suche löschen',
     'reset' => 'Zurücksetzen',
     'remove' => 'Entfernen',
 
@@ -40,9 +40,9 @@ return [
     /**
      * Misc
      */
-    'deleted_user' => 'Gel&ouml;schte Benutzer',
-    'no_activity' => 'Keine Aktivit&auml;ten zum Anzeigen',
-    'no_items' => 'Keine Eintr&auml;ge gefunden.',
+    'deleted_user' => 'Gelöschte Benutzer',
+    'no_activity' => 'Keine Aktivitäten zum Anzeigen',
+    'no_items' => 'Keine Einträge gefunden.',
     'back_to_top' => 'nach oben',
     'toggle_details' => 'Details zeigen/verstecken',
     'toggle_thumbnails' => 'Thumbnails zeigen/verstecken',
@@ -55,6 +55,6 @@ return [
     /**
      * Email Content
      */
-    'email_action_help' => 'Sollte es beim Anklicken des ":actionText" Buttons Probleme geben, kopieren Sie folgende URL und fügen diese in Ihrem Webbrowser ein:',
+    'email_action_help' => 'Sollte es beim Anklicken der Schaltfläche ":action_text" Probleme geben, öffnen Sie folgende URL in Ihrem Browser:',
     'email_rights' => 'Alle Rechte vorbehalten',
-];
\ No newline at end of file
+];
diff --git a/resources/lang/de/components.php b/resources/lang/de/components.php
index a8538c465..26bf3e626 100644
--- a/resources/lang/de/components.php
+++ b/resources/lang/de/components.php
@@ -13,12 +13,12 @@ return [
     'image_uploaded' => 'Hochgeladen am :uploadedDate',
     'image_load_more' => 'Mehr',
     'image_image_name' => 'Bildname',
-    'image_delete_confirm' => 'Dieses Bild wird auf den folgenden Seiten benutzt. Bitte klicken Sie erneut auf löschen, wenn Sie dieses Bild tatsächlich entfernen möchten.',
+    'image_delete_confirm' => 'Dieses Bild wird auf den folgenden Seiten benutzt. Bitte klicken Sie erneut auf löschen, wenn Sie dieses Bild wirklich entfernen möchten.',
     'image_select_image' => 'Bild auswählen',
-    'image_dropzone' => 'Ziehen Sie Bilder hier hinein oder klicken Sie hier, um ein Bild auszuwählen',
+    'image_dropzone' => 'Ziehen Sie Bilder hierher oder klicken Sie, um ein Bild auszuwählen',
     'images_deleted' => 'Bilder gelöscht',
     'image_preview' => 'Bildvorschau',
     'image_upload_success' => 'Bild erfolgreich hochgeladen',
     'image_update_success' => 'Bilddetails erfolgreich aktualisiert',
     'image_delete_success' => 'Bild erfolgreich gelöscht'
-];
\ No newline at end of file
+];
diff --git a/resources/lang/de/entities.php b/resources/lang/de/entities.php
index c9feb8497..910218a58 100644
--- a/resources/lang/de/entities.php
+++ b/resources/lang/de/entities.php
@@ -4,38 +4,39 @@ return [
     /**
      * Shared
      */
-    'recently_created' => 'K&uuml;rzlich angelegt',
-    'recently_created_pages' => 'K&uuml;rzlich angelegte Seiten',
-    'recently_updated_pages' => 'K&uuml;rzlich aktualisierte Seiten',
-    'recently_created_chapters' => 'K&uuml;rzlich angelegte Kapitel',
-    'recently_created_books' => 'K&uuml;rzlich angelegte B&uuml;cher',
-    'recently_update' => 'K&uuml;rzlich aktualisiert',
-    'recently_viewed' => 'K&uuml;rzlich angesehen',
-    'recent_activity' => 'K&uuml;rzliche Aktivit&auml;t',
+    'recently_created' => 'Kürzlich angelegt',
+    'recently_created_pages' => 'Kürzlich angelegte Seiten',
+    'recently_updated_pages' => 'Kürzlich aktualisierte Seiten',
+    'recently_created_chapters' => 'Kürzlich angelegte Kapitel',
+    'recently_created_books' => 'Kürzlich angelegte Bücher',
+    'recently_update' => 'Kürzlich aktualisiert',
+    'recently_viewed' => 'Kürzlich angesehen',
+    'recent_activity' => 'Kürzliche Aktivität',
     'create_now' => 'Jetzt anlegen',
-    'revisions' => 'Revisionen',
-    'meta_created' => 'Angelegt am :timeLength',
-    'meta_created_name' => 'Angelegt am :timeLength durch :user',
-    'meta_updated' => 'Aktualisiert am :timeLength',
-    'meta_updated_name' => 'Aktualisiert am :timeLength durch :user',
+    'revisions' => 'Versionen',
+    'meta_revision' => 'Version #:revisionCount',
+    'meta_created' => 'Erstellt: :timeLength',
+    'meta_created_name' => 'Erstellt: :timeLength von :user',
+    'meta_updated' => 'Zuletzt aktualisiert: :timeLength',
+    'meta_updated_name' => 'Zuletzt aktualisiert: :timeLength von :user',
     'x_pages' => ':count Seiten',
-    'entity_select' => 'Eintrag ausw&auml;hlen',
+    'entity_select' => 'Eintrag auswählen',
     'images' => 'Bilder',
-    'my_recent_drafts' => 'Meine k&uuml;rzlichen Entw&uuml;rfe',
-    'my_recently_viewed' => 'K&uuml;rzlich von mir angesehen',
+    'my_recent_drafts' => 'Meine kürzlichen Entwürfe',
+    'my_recently_viewed' => 'Kürzlich von mir angesehen',
     'no_pages_viewed' => 'Sie haben bisher keine Seiten angesehen.',
     'no_pages_recently_created' => 'Sie haben bisher keine Seiten angelegt.',
     'no_pages_recently_updated' => 'Sie haben bisher keine Seiten aktualisiert.',
     'export' => 'Exportieren',
     'export_html' => 'HTML-Datei',
     'export_pdf' => 'PDF-Datei',
-    'export_text' => 'Text-Datei',
+    'export_text' => 'Textdatei',
 
     /**
      * Permissions and restrictions
      */
     'permissions' => 'Berechtigungen',
-    'permissions_intro' => 'Wenn individuelle Berechtigungen aktiviert werden, &uuml;berschreiben diese Einstellungen durch Rollen zugewiesene Berechtigungen.',
+    'permissions_intro' => 'Wenn individuelle Berechtigungen aktiviert werden, überschreiben diese Einstellungen durch Rollen zugewiesene Berechtigungen.',
     'permissions_enable' => 'Individuelle Berechtigungen aktivieren',
     'permissions_save' => 'Berechtigungen speichern',
 
@@ -43,41 +44,58 @@ return [
      * Search
      */
     'search_results' => 'Suchergebnisse',
-    'search_clear' => 'Suche zur&uuml;cksetzen',
-    'search_no_pages' => 'Es wurden keine passenden Suchergebnisse gefunden',
-    'search_for_term' => 'Suche nach :term',
+    'search_total_results_found' => ':count Ergebnis gefunden|:count Ergebnisse gesamt',
+    'search_clear' => 'Filter löschen',
+    'search_no_pages' => 'Keine Seiten gefunden',
+    'search_for_term' => 'Nach :term suchen',
+    'search_more' => 'Mehr Ergebnisse',
+    'search_filters' => 'Filter',
+    'search_content_type' => 'Inhaltstyp',
+    'search_exact_matches' => 'Exakte Treffer',
+    'search_tags' => 'Nach Schlagwort suchen',
+    'search_viewed_by_me' => 'Schon von mir angesehen',
+    'search_not_viewed_by_me' => 'Noch nicht von mir angesehen',
+    'search_permissions_set' => 'Berechtigungen gesetzt',
+    'search_created_by_me' => 'Von mir erstellt',
+    'search_updated_by_me' => 'Von mir aktualisiert',
+    'search_updated_before' => 'Aktualisiert vor',
+    'search_updated_after' => 'Aktualisiert nach',
+    'search_created_before' => 'Erstellt vor',
+    'search_created_after' => 'Erstellt nach',
+    'search_set_date' => 'Datum auswählen',
+    'search_update' => 'Suche aktualisieren',
 
     /**
      * Books
      */
     'book' => 'Buch',
-    'books' => 'B&uuml;cher',
-    'books_empty' => 'Es wurden keine B&uuml;cher angelegt',
-    'books_popular' => 'Popul&auml;re B&uuml;cher',
-    'books_recent' => 'K&uuml;rzlich genutzte B&uuml;cher',
-    'books_popular_empty' => 'Die popul&auml;rsten B&uuml;cher werden hier angezeigt.',
-    'books_create' => 'Neues Buch anlegen',
-    'books_delete' => 'Buch l&ouml;schen',
-    'books_delete_named' => 'Buch :bookName l&ouml;schen',
-    'books_delete_explain' => 'Sie m&ouml;chten das Buch \':bookName\' l&ouml;schen und alle Seiten und Kapitel entfernen.',
-    'books_delete_confirmation' => 'Sind Sie sicher, dass Sie dieses Buch l&ouml;schen m&ouml;chten?',
+    'books' => 'Bücher',
+    'books_empty' => 'Keine Bücher vorhanden',
+    'books_popular' => 'Beliebte Bücher',
+    'books_recent' => 'Kürzlich angesehene Bücher',
+    'books_popular_empty' => 'Die beliebtesten Bücher werden hier angezeigt.',
+    'books_create' => 'Neues Buch erstellen',
+    'books_delete' => 'Buch löschen',
+    'books_delete_named' => 'Buch ":bookName" löschen',
+    'books_delete_explain' => 'Das Buch ":bookName" wird gelöscht und alle zugehörigen Kapitel und Seiten entfernt.',
+    'books_delete_confirmation' => 'Sind Sie sicher, dass Sie dieses Buch löschen möchten?',
     'books_edit' => 'Buch bearbeiten',
-    'books_edit_named' => 'Buch :bookName bearbeiten',
-    'books_form_book_name' => 'Buchname',
+    'books_edit_named' => 'Buch ":bookName" bearbeiten',
+    'books_form_book_name' => 'Name des Buches',
     'books_save' => 'Buch speichern',
     'books_permissions' => 'Buch-Berechtigungen',
     'books_permissions_updated' => 'Buch-Berechtigungen aktualisiert',
-    'books_empty_contents' => 'Es sind noch keine Seiten oder Kapitel f&uuml;r dieses Buch angelegt.',
+    'books_empty_contents' => 'Es sind noch keine Seiten oder Kapitel zu diesem Buch hinzugefügt worden.',
     'books_empty_create_page' => 'Neue Seite anlegen',
     'books_empty_or' => 'oder',
     'books_empty_sort_current_book' => 'Aktuelles Buch sortieren',
-    'books_empty_add_chapter' => 'Neues Kapitel hinzuf&uuml;gen',
+    'books_empty_add_chapter' => 'Neues Kapitel hinzufügen',
     'books_permissions_active' => 'Buch-Berechtigungen aktiv',
     'books_search_this' => 'Dieses Buch durchsuchen',
-    'books_navigation' => 'Buch-Navigation',
+    'books_navigation' => 'Buchnavigation',
     'books_sort' => 'Buchinhalte sortieren',
-    'books_sort_named' => 'Buch :bookName sortieren',
-    'books_sort_show_other' => 'Andere B&uuml;cher zeigen',
+    'books_sort_named' => 'Buch ":bookName" sortieren',
+    'books_sort_show_other' => 'Andere Bücher anzeigen',
     'books_sort_save' => 'Neue Reihenfolge speichern',
 
     /**
@@ -85,132 +103,157 @@ return [
      */
     'chapter' => 'Kapitel',
     'chapters' => 'Kapitel',
-    'chapters_popular' => 'Popul&auml;re Kapitel',
+    'chapters_popular' => 'Beliebte Kapitel',
     'chapters_new' => 'Neues Kapitel',
     'chapters_create' => 'Neues Kapitel anlegen',
     'chapters_delete' => 'Kapitel entfernen',
-    'chapters_delete_named' => 'Kapitel :chapterName entfernen',
-    'chapters_delete_explain' => 'Sie m&ouml;chten das Kapitel \':chapterName\' l&ouml;schen und alle Seiten dem direkten Eltern-Buch hinzugef&uuml;gen.',
-    'chapters_delete_confirm' => 'Sind Sie sicher, dass Sie dieses Kapitel l&ouml;schen m&ouml;chten?',
+    'chapters_delete_named' => 'Kapitel ":chapterName" entfernen',
+    'chapters_delete_explain' => 'Das Kapitel ":chapterName" wird gelöscht und alle zugehörigen Seiten dem übergeordneten Buch zugeordnet.',
+    'chapters_delete_confirm' => 'Sind Sie sicher, dass Sie dieses Kapitel löschen möchten?',
     'chapters_edit' => 'Kapitel bearbeiten',
-    'chapters_edit_named' => 'Kapitel :chapterName bearbeiten',
+    'chapters_edit_named' => 'Kapitel ":chapterName" bearbeiten',
     'chapters_save' => 'Kapitel speichern',
     'chapters_move' => 'Kapitel verschieben',
-    'chapters_move_named' => 'Kapitel :chapterName verschieben',
-    'chapter_move_success' => 'Kapitel in das Buch :bookName verschoben.',
+    'chapters_move_named' => 'Kapitel ":chapterName" verschieben',
+    'chapter_move_success' => 'Das Kapitel wurde in das Buch ":bookName" verschoben.',
     'chapters_permissions' => 'Kapitel-Berechtigungen',
-    'chapters_empty' => 'Aktuell sind keine Kapitel in diesem Buch angelegt.',
+    'chapters_empty' => 'Aktuell sind keine Kapitel diesem Buch hinzugefügt worden.',
     'chapters_permissions_active' => 'Kapitel-Berechtigungen aktiv',
     'chapters_permissions_success' => 'Kapitel-Berechtigungenen aktualisisert',
+    'chapters_search_this' => 'Dieses Kapitel durchsuchen',
 
     /**
      * Pages
      */
     'page' => 'Seite',
     'pages' => 'Seiten',
-    'pages_popular' => 'Popul&auml;re Seiten',
+    'pages_popular' => 'Beliebte Seiten',
     'pages_new' => 'Neue Seite',
-    'pages_attachments' => 'Anh&auml;nge',
+    'pages_attachments' => 'Anhänge',
     'pages_navigation' => 'Seitennavigation',
-    'pages_delete' => 'Seite l&ouml;schen',
-    'pages_delete_named' => 'Seite :pageName l&ouml;schen',
-    'pages_delete_draft_named' => 'Seitenentwurf von :pageName l&ouml;schen',
-    'pages_delete_draft' => 'Seitenentwurf l&ouml;schen',
-    'pages_delete_success' => 'Seite gel&ouml;scht',
-    'pages_delete_draft_success' => 'Seitenentwurf gel&ouml;scht',
-    'pages_delete_confirm' => 'Sind Sie sicher, dass Sie diese Seite l&ouml;schen m&ouml;chen?',
-    'pages_delete_draft_confirm' => 'Sind Sie sicher, dass Sie diesen Seitenentwurf l&ouml;schen m&ouml;chten?',
-    'pages_editing_named' => 'Seite :pageName bearbeiten',
-    'pages_edit_toggle_header' => 'Toggle header',
+    'pages_delete' => 'Seite löschen',
+    'pages_delete_named' => 'Seite ":pageName" löschen',
+    'pages_delete_draft_named' => 'Seitenentwurf von ":pageName" löschen',
+    'pages_delete_draft' => 'Seitenentwurf löschen',
+    'pages_delete_success' => 'Seite gelöscht',
+    'pages_delete_draft_success' => 'Seitenentwurf gelöscht',
+    'pages_delete_confirm' => 'Sind Sie sicher, dass Sie diese Seite löschen möchen?',
+    'pages_delete_draft_confirm' => 'Sind Sie sicher, dass Sie diesen Seitenentwurf löschen möchten?',
+    'pages_editing_named' => 'Seite ":pageName" bearbeiten',
+    'pages_edit_toggle_header' => 'Hauptmenü anzeigen/verstecken',
     'pages_edit_save_draft' => 'Entwurf speichern',
     'pages_edit_draft' => 'Seitenentwurf bearbeiten',
     'pages_editing_draft' => 'Seitenentwurf bearbeiten',
     'pages_editing_page' => 'Seite bearbeiten',
     'pages_edit_draft_save_at' => 'Entwurf gespeichert um ',
-    'pages_edit_delete_draft' => 'Entwurf l&ouml;schen',
+    'pages_edit_delete_draft' => 'Entwurf löschen',
     'pages_edit_discard_draft' => 'Entwurf verwerfen',
-    'pages_edit_set_changelog' => 'Ver&auml;nderungshinweis setzen',
-    'pages_edit_enter_changelog_desc' => 'Bitte geben Sie eine kurze Zusammenfassung Ihrer &Auml;nderungen ein',
-    'pages_edit_enter_changelog' => 'Ver&auml;nderungshinweis eingeben',
+    'pages_edit_set_changelog' => 'Änderungsprotokoll hinzufügen',
+    'pages_edit_enter_changelog_desc' => 'Bitte geben Sie eine kurze Zusammenfassung Ihrer Änderungen ein',
+    'pages_edit_enter_changelog' => 'Änderungsprotokoll eingeben',
     'pages_save' => 'Seite speichern',
     'pages_title' => 'Seitentitel',
     'pages_name' => 'Seitenname',
     'pages_md_editor' => 'Redakteur',
     'pages_md_preview' => 'Vorschau',
-    'pages_md_insert_image' => 'Bild einf&uuml;gen',
-    'pages_md_insert_link' => 'Link zu einem Objekt einf&uuml;gen',
+    'pages_md_insert_image' => 'Bild einfügen',
+    'pages_md_insert_link' => 'Link zu einem Objekt einfügen',
     'pages_not_in_chapter' => 'Seite ist in keinem Kapitel',
     'pages_move' => 'Seite verschieben',
     'pages_move_success' => 'Seite nach ":parentName" verschoben',
     'pages_permissions' => 'Seiten Berechtigungen',
     'pages_permissions_success' => 'Seiten Berechtigungen aktualisiert',
     'pages_revisions' => 'Seitenversionen',
-    'pages_revisions_named' => 'Seitenversionen von :pageName',
-    'pages_revision_named' => 'Seitenversion von :pageName',
-    'pages_revisions_created_by' => 'Angelegt von',
+    'pages_revisions_named' => 'Seitenversionen von ":pageName"',
+    'pages_revision_named' => 'Seitenversion von ":pageName"',
+    'pages_revisions_created_by' => 'Erstellt von',
     'pages_revisions_date' => 'Versionsdatum',
-    'pages_revisions_changelog' => 'Ver&auml;nderungshinweise',
-    'pages_revisions_changes' => 'Ver&auml;nderungen',
+    'pages_revisions_number' => '#',
+    'pages_revisions_changelog' => 'Änderungsprotokoll',
+    'pages_revisions_changes' => 'Änderungen',
     'pages_revisions_current' => 'Aktuelle Version',
     'pages_revisions_preview' => 'Vorschau',
-    'pages_revisions_restore' => 'Zur&uuml;ck sichern',
-    'pages_revisions_none' => 'Diese Seite hat keine &auml;lteren Versionen.',
+    'pages_revisions_restore' => 'Wiederherstellen',
+    'pages_revisions_none' => 'Diese Seite hat keine älteren Versionen.',
     'pages_copy_link' => 'Link kopieren',
     'pages_permissions_active' => 'Seiten-Berechtigungen aktiv',
-    'pages_initial_revision' => 'Erste Ver&ouml;ffentlichung',
+    'pages_initial_revision' => 'Erste Veröffentlichung',
     'pages_initial_name' => 'Neue Seite',
-    'pages_editing_draft_notification' => 'Sie bearbeiten momenten einen Entwurf, der zuletzt um :timeDiff gespeichert wurde.',
-    'pages_draft_edited_notification' => 'Diese Seite wurde seit diesem Zeitpunkt ver&auml;ndert. Wir empfehlen Ihnen, diesen Entwurf zu verwerfen.',
+    'pages_editing_draft_notification' => 'Sie bearbeiten momenten einen Entwurf, der zuletzt :timeDiff gespeichert wurde.',
+    'pages_draft_edited_notification' => 'Diese Seite wurde seit diesem Zeitpunkt verändert. Wir empfehlen Ihnen, diesen Entwurf zu verwerfen.',
     'pages_draft_edit_active' => [
-        'start_a' => ':count Benutzer haben die Bearbeitung dieser Seite begonnen.',
-        'start_b' => ':userName hat die Bearbeitung dieser Seite begonnen.',
+        'start_a' => ':count Benutzer bearbeiten derzeit diese Seite.',
+        'start_b' => ':userName bearbeitet jetzt diese Seite.',
         'time_a' => 'seit die Seiten zuletzt aktualisiert wurden.',
         'time_b' => 'in den letzten :minCount Minuten',
-        'message' => ':start :time. Achten Sie darauf keine Aktualisierungen von anderen Benutzern zu &uuml;berschreiben!',
+        'message' => ':start :time. Achten Sie darauf, keine Änderungen von anderen Benutzern zu überschreiben!',
     ],
     'pages_draft_discarded' => 'Entwurf verworfen. Der aktuelle Seiteninhalt wurde geladen.',
 
     /**
      * Editor sidebar
      */
-    'page_tags' => 'Seiten-Schlagw&ouml;rter',
+    'page_tags' => 'Seiten-Schlagwörter',
     'tag' => 'Schlagwort',
-    'tags' =>  'Schlagworte',
-    'tag_value' => 'Schlagwortinhalt (Optional)',
-    'tags_explain' => "F&uuml;gen Sie Schlagworte hinzu, um Ihren Inhalt zu kategorisieren. \n Sie k&ouml;nnen einen erkl&auml;renden Inhalt hinzuf&uuml;gen, um eine genauere Unterteilung vorzunehmen.",
-    'tags_add' => 'Weiteres Schlagwort hinzuf&uuml;gen',
-    'attachments' => 'Anh&auml;nge',
-    'attachments_explain' => 'Sie k&ouml;nnen auf Ihrer Seite Dateien hochladen oder Links anf&uuml;gen. Diese werden in der seitlich angezeigt.',
-    'attachments_explain_instant_save' => '&Auml;nderungen werden direkt gespeichert.',
-    'attachments_items' => 'Angef&uuml;gte Elemente',
+    'tags' =>  'Schlagwörter',
+    'tag_value' => 'Inhalt (Optional)',
+    'tags_explain' => "Fügen Sie Schlagwörter hinzu, um Ihren Inhalt zu kategorisieren.\nSie können einen erklärenden Inhalt hinzufügen, um eine genauere Unterteilung vorzunehmen.",
+    'tags_add' => 'Weiteres Schlagwort hinzufügen',
+    'attachments' => 'Anhänge',
+    'attachments_explain' => 'Sie können auf Ihrer Seite Dateien hochladen oder Links hinzufügen. Diese werden in der Seitenleiste angezeigt.',
+    'attachments_explain_instant_save' => 'Änderungen werden direkt gespeichert.',
+    'attachments_items' => 'Angefügte Elemente',
     'attachments_upload' => 'Datei hochladen',
-    'attachments_link' => 'Link anf&uuml;gen',
+    'attachments_link' => 'Link hinzufügen',
     'attachments_set_link' => 'Link setzen',
-    'attachments_delete_confirm' => 'Klicken Sie erneut auf l&ouml;schen, um diesen Anhang zu entfernen.',
-    'attachments_dropzone' => 'Ziehen Sie Dateien hier hinein oder klicken Sie hier, um eine Datei auszuw&auml;hlen',
+    'attachments_delete_confirm' => 'Klicken Sie erneut auf löschen, um diesen Anhang zu entfernen.',
+    'attachments_dropzone' => 'Ziehen Sie Dateien hierher oder klicken Sie, um eine Datei auszuwählen',
     'attachments_no_files' => 'Es wurden bisher keine Dateien hochgeladen.',
-    'attachments_explain_link' => 'Wenn Sie keine Datei hochladen m&ouml;chten, k&ouml;nnen Sie stattdessen einen Link anf&uuml;gen. Dieser Link kann auf eine andere Seite oder zu einer Datei in der Cloud weisen.',
+    'attachments_explain_link' => 'Wenn Sie keine Datei hochladen möchten, können Sie stattdessen einen Link hinzufügen. Dieser Link kann auf eine andere Seite oder eine Datei im Internet weisen.',
     'attachments_link_name' => 'Link-Name',
     'attachment_link' => 'Link zum Anhang',
     'attachments_link_url' => 'Link zu einer Datei',
     'attachments_link_url_hint' => 'URL einer Seite oder Datei',
-    'attach' => 'anf&uuml;gen',
+    'attach' => 'Hinzufügen',
     'attachments_edit_file' => 'Datei bearbeiten',
     'attachments_edit_file_name' => 'Dateiname',
-    'attachments_edit_drop_upload' => 'Ziehen Sie Dateien hier hinein, um diese hochzuladen und zu &uuml;berschreiben',
-    'attachments_order_updated' => 'Reihenfolge der Anh&auml;nge aktualisiert',
-    'attachments_updated_success' => 'Anhang-Details aktualisiert',
-    'attachments_deleted' => 'Anhang gel&ouml;scht',
-    'attachments_file_uploaded' => 'Datei erfolgrecich hochgeladen',
-    'attachments_file_updated' => 'Datei erfolgreich aktualisisert',
-    'attachments_link_attached' => 'Link erfolgreich der Seite hinzugef&uuml;gt',
+    'attachments_edit_drop_upload' => 'Ziehen Sie Dateien hierher, um diese hochzuladen und zu überschreiben',
+    'attachments_order_updated' => 'Reihenfolge der Anhänge aktualisiert',
+    'attachments_updated_success' => 'Anhangdetails aktualisiert',
+    'attachments_deleted' => 'Anhang gelöscht',
+    'attachments_file_uploaded' => 'Datei erfolgreich hochgeladen',
+    'attachments_file_updated' => 'Datei erfolgreich aktualisiert',
+    'attachments_link_attached' => 'Link erfolgreich der Seite hinzugefügt',
 
     /**
      * Profile View
      */
     'profile_user_for_x' => 'Benutzer seit :time',
-    'profile_created_content' => 'Angelegte Inhalte',
-    'profile_not_created_pages' => ':userName hat bisher keine Seiten angelegt.',
-    'profile_not_created_chapters' => ':userName hat bisher keine Kapitel angelegt.',
-    'profile_not_created_books' => ':userName hat bisher keine B&uuml;cher angelegt.',
-];
\ No newline at end of file
+    'profile_created_content' => 'Erstellte Inhalte',
+    'profile_not_created_pages' => ':userName hat noch keine Seiten erstellt.',
+    'profile_not_created_chapters' => ':userName hat noch keine Kapitel erstellt.',
+    'profile_not_created_books' => ':userName hat noch keine Bücher erstellt.',
+
+    /**
+     * Comnents
+     */
+    'comment' => 'Kommentar',
+    'comments' => 'Kommentare',
+    'comment_placeholder' => 'Geben Sie hier Ihre Kommentare ein (Markdown unterstützt)',
+    'no_comments' => 'Keine Kommentare',
+    'x_comments' => ':numComments Kommentare',
+    'one_comment' => '1 Kommentar',
+    'comments_loading' => 'Laden...',
+    'comment_save' => 'Kommentar speichern',
+    'comment_reply' => 'Antworten',
+    'comment_edit' => 'Bearbeiten',
+    'comment_delete' => 'Löschen',
+    'comment_cancel' => 'Abbrechen',
+    'comment_created' => 'Kommentar hinzugefügt',
+    'comment_updated' => 'Kommentar aktualisiert',
+    'comment_deleted' => 'Kommentar gelöscht',
+    'comment_updated_text' => 'Aktualisiert vor :updateDiff von',
+    'comment_delete_confirm' => 'Der Inhalt des Kommentars wird entfernt. Bist du sicher, dass du diesen Kommentar löschen möchtest?',
+    'comment_create' => 'Erstellt'
+
+];
diff --git a/resources/lang/de/errors.php b/resources/lang/de/errors.php
index e085d9915..0b961f8ee 100644
--- a/resources/lang/de/errors.php
+++ b/resources/lang/de/errors.php
@@ -7,37 +7,37 @@ return [
      */
 
     // Pages
-    'permission' => 'Sie haben keine Berechtigung auf diese Seite zuzugreifen.',
-    'permissionJson' => 'Sie haben keine Berechtigung die angeforderte Aktion auszuf&uuml;hren.',
+    'permission' => 'Sie haben keine Berechtigung, auf diese Seite zuzugreifen.',
+    'permissionJson' => 'Sie haben keine Berechtigung, die angeforderte Aktion auszuführen.',
 
     // Auth
-    'error_user_exists_different_creds' => 'Ein Benutzer mit der E-Mail-Adresse :email ist bereits mit anderen Anmeldedaten angelegt.',
-    'email_already_confirmed' => 'Die E-Mail-Adresse ist bereits best&auml;tigt. Bitte melden Sie sich an.',
-    'email_confirmation_invalid' => 'Der Best&auml;tigungs-Token ist nicht g&uuml;ltig oder wurde bereits verwendet. Bitte registrieren Sie sich erneut.',
-    'email_confirmation_expired' => 'Der Best&auml;tigungs-Token ist abgelaufen. Es wurde eine neue Best&auml;tigungs-E-Mail gesendet.',
-    'ldap_fail_anonymous' => 'Anonymer LDAP Zugriff ist fehlgeschlafgen',
-    'ldap_fail_authed' => 'LDAP Zugriff mit DN & Passwort ist fehlgeschlagen',
-    'ldap_extension_not_installed' => 'LDAP PHP Erweiterung ist nicht installiert.',
-    'ldap_cannot_connect' => 'Die Verbindung zu LDAP-Server ist fehlgeschlagen. Beim initialen Verbindungsaufbau trat ein Fehler auf.',
+    'error_user_exists_different_creds' => 'Ein Benutzer mit der E-Mail-Adresse :email ist bereits mit anderen Anmeldedaten registriert.',
+    'email_already_confirmed' => 'Die E-Mail-Adresse ist bereits bestätigt. Bitte melden Sie sich an.',
+    'email_confirmation_invalid' => 'Der Bestätigungslink ist nicht gültig oder wurde bereits verwendet. Bitte registrieren Sie sich erneut.',
+    'email_confirmation_expired' => 'Der Bestätigungslink ist abgelaufen. Es wurde eine neue Bestätigungs-E-Mail gesendet.',
+    'ldap_fail_anonymous' => 'Anonymer LDAP-Zugriff ist fehlgeschlafgen',
+    'ldap_fail_authed' => 'LDAP-Zugriff mit DN und Passwort ist fehlgeschlagen',
+    'ldap_extension_not_installed' => 'LDAP-PHP-Erweiterung ist nicht installiert.',
+    'ldap_cannot_connect' => 'Die Verbindung zum LDAP-Server ist fehlgeschlagen. Beim initialen Verbindungsaufbau trat ein Fehler auf.',
     'social_no_action_defined' => 'Es ist keine Aktion definiert',
-    'social_account_in_use' => 'Dieses :socialAccount Konto wird bereits verwendet. Bitte melden Sie sich mit dem :socialAccount Konto an.',
-    'social_account_email_in_use' => 'Die E-Mail-Adresse :email ist bereits registriert. Wenn Sie bereits registriert sind, k&ouml;nnen Sie Ihr :socialAccount Konto in Ihren Profil-Einstellungen verkn&uuml;pfen.',
-    'social_account_existing' => 'Dieses :socialAccount Konto ist bereits mit Ihrem Profil verkn&uuml;pft.',
-    'social_account_already_used_existing' => 'Dieses :socialAccount Konto wird bereits durch einen anderen Benutzer verwendet.',
-    'social_account_not_used' => 'Dieses :socialAccount Konto ist bisher keinem Benutzer zugeordnet. Bitte verkn&uuml;pfen Sie deses in Ihrem Profil-Einstellungen.',
-    'social_account_register_instructions' => 'Wenn Sie bisher keinen Social-Media Konto besitzen k&ouml;nnen Sie ein solches Konto mit der :socialAccount Option anlegen.',
-    'social_driver_not_found' => 'Social-Media Konto Treiber nicht gefunden',
-    'social_driver_not_configured' => 'Ihr :socialAccount Konto ist nicht korrekt konfiguriert.',
+    'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melden Sie sich mit dem :socialAccount-Konto an.',
+    'social_account_email_in_use' => 'Die E-Mail-Adresse ":email" ist bereits registriert. Wenn Sie bereits registriert sind, können Sie Ihr :socialAccount-Konto in Ihren Profil-Einstellungen verknüpfen.',
+    'social_account_existing' => 'Dieses :socialAccount-Konto ist bereits mit Ihrem Profil verknüpft.',
+    'social_account_already_used_existing' => 'Dieses :socialAccount-Konto wird bereits von einem anderen Benutzer verwendet.',
+    'social_account_not_used' => 'Dieses :socialAccount-Konto ist bisher keinem Benutzer zugeordnet. Sie können es in Ihren Profil-Einstellung.',
+    'social_account_register_instructions' => 'Wenn Sie bisher keinen Social-Media Konto besitzen, können Sie ein solches Konto mit der :socialAccount Option anlegen.',
+    'social_driver_not_found' => 'Treiber für Social-Media-Konten nicht gefunden',
+    'social_driver_not_configured' => 'Ihr :socialAccount-Konto ist nicht korrekt konfiguriert.',
 
     // System
     'path_not_writable' => 'Die Datei kann nicht in den angegebenen Pfad :filePath hochgeladen werden. Stellen Sie sicher, dass dieser Ordner auf dem Server beschreibbar ist.',
     'cannot_get_image_from_url' => 'Bild konnte nicht von der URL :url geladen werden.',
-    'cannot_create_thumbs' => 'Der Server kann keine Vorschau-Bilder erzeugen. Bitte pr&uuml;fen Sie, ob Sie die GD PHP Erweiterung installiert haben.',
-    'server_upload_limit' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigr&ouml;&szlig;e. Bitte versuchen Sie es mit einer kleineren Datei.',
+    'cannot_create_thumbs' => 'Der Server kann keine Vorschau-Bilder erzeugen. Bitte prüfen Sie, ob die GD PHP-Erweiterung installiert ist.',
+    'server_upload_limit' => 'Der Server verbietet das Hochladen von Dateien mit dieser Dateigröße. Bitte versuchen Sie es mit einer kleineren Datei.',
     'image_upload_error' => 'Beim Hochladen des Bildes trat ein Fehler auf.',
 
     // Attachments
-    'attachment_page_mismatch' => 'Die Seite stimmt nach dem Hochladen des Anhangs nicht &uuml;berein.',
+    'attachment_page_mismatch' => 'Die Seite stimmte nach dem Hochladen des Anhangs nicht überein.',
 
     // Pages
     'page_draft_autosave_fail' => 'Fehler beim Speichern des Entwurfs. Stellen Sie sicher, dass Sie mit dem Internet verbunden sind, bevor Sie den Entwurf dieser Seite speichern.',
@@ -47,24 +47,31 @@ return [
     'book_not_found' => 'Buch nicht gefunden',
     'page_not_found' => 'Seite nicht gefunden',
     'chapter_not_found' => 'Kapitel nicht gefunden',
-    'selected_book_not_found' => 'Das gew&auml;hlte Buch wurde nicht gefunden.',
-    'selected_book_chapter_not_found' => 'Das gew&auml;hlte Buch oder Kapitel wurde nicht gefunden.',
-    'guests_cannot_save_drafts' => 'G&auml;ste k&ouml;nnen keine Entw&uuml;rfe speichern',
+    'selected_book_not_found' => 'Das gewählte Buch wurde nicht gefunden.',
+    'selected_book_chapter_not_found' => 'Das gewählte Buch oder Kapitel wurde nicht gefunden.',
+    'guests_cannot_save_drafts' => 'Gäste können keine Entwürfe speichern',
 
     // Users
-    'users_cannot_delete_only_admin' => 'Sie k&ouml;nnen den einzigen Administrator nicht l&ouml;schen.',
-    'users_cannot_delete_guest' => 'Sie k&ouml;nnen den Gast-Benutzer nicht l&ouml;schen',
+    'users_cannot_delete_only_admin' => 'Sie können den einzigen Administrator nicht löschen.',
+    'users_cannot_delete_guest' => 'Sie können den Gast-Benutzer nicht löschen',
 
     // Roles
     'role_cannot_be_edited' => 'Diese Rolle kann nicht bearbeitet werden.',
-    'role_system_cannot_be_deleted' => 'Dies ist eine Systemrolle und kann nicht gel&ouml;scht werden',
-    'role_registration_default_cannot_delete' => 'Diese Rolle kann nicht gel&ouml;scht werden solange sie als Standardrolle f&uuml;r neue Registrierungen gesetzt ist',
+    'role_system_cannot_be_deleted' => 'Dies ist eine Systemrolle und kann nicht gelöscht werden',
+    'role_registration_default_cannot_delete' => 'Diese Rolle kann nicht gelöscht werden, solange sie als Standardrolle für neue Registrierungen gesetzt ist',
+
+    // Comments
+    'comment_list' => 'Beim Abrufen der Kommentare ist ein Fehler aufgetreten.',
+    'cannot_add_comment_to_draft' => 'Du kannst keine Kommentare zu einem Entwurf hinzufügen.',
+    'comment_add' => 'Beim Hinzufügen des Kommentars ist ein Fehler aufgetreten.',
+    'comment_delete' => 'Beim Löschen des Kommentars ist ein Fehler aufgetreten.',
+    'empty_comment' => 'Kann keinen leeren Kommentar hinzufügen',
 
     // Error pages
     '404_page_not_found' => 'Seite nicht gefunden',
-    'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Sie angefordert haben wurde nicht gefunden.',
-    'return_home' => 'Zur&uuml;ck zur Startseite',
+    'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Sie angefordert haben, wurde nicht gefunden.',
+    'return_home' => 'Zurück zur Startseite',
     'error_occurred' => 'Es ist ein Fehler aufgetreten',
     'app_down' => ':appName befindet sich aktuell im Wartungsmodus.',
-    'back_soon' => 'Wir werden so schnell wie m&ouml;glich wieder online sein.',
+    'back_soon' => 'Wir werden so schnell wie möglich wieder online sein.'
 ];
diff --git a/resources/lang/de/pagination.php b/resources/lang/de/pagination.php
index a3bf7c8c8..6ed0e30f0 100644
--- a/resources/lang/de/pagination.php
+++ b/resources/lang/de/pagination.php
@@ -14,6 +14,6 @@ return [
     */
 
     'previous' => '&laquo; Vorherige',
-    'next'     => 'N&auml;chste &raquo;',
+    'next'     => 'Nächste &raquo;',
 
 ];
diff --git a/resources/lang/de/passwords.php b/resources/lang/de/passwords.php
index c44b49baa..25ed05a04 100644
--- a/resources/lang/de/passwords.php
+++ b/resources/lang/de/passwords.php
@@ -13,10 +13,10 @@ return [
     |
     */
 
-    'password' => 'Pass&ouml;rter m&uuml;ssen mindestens sechs Zeichen enthalten und die Wiederholung muss identisch sein.',
-    'user' => "Wir k&ouml;nnen keinen Benutzer mit dieser E-Mail Adresse finden.",
-    'token' => 'Dieser Passwort-Reset-Token ist ung&uuml;ltig.',
-    'sent' => 'Wir haben Ihnen eine E-Mail mit einem Link zum Zur&uuml;cksetzen des Passworts zugesendet!',
-    'reset' => 'Ihr Passwort wurde zur&uuml;ckgesetzt!',
+    'password' => 'Passörter müssen mindestens sechs Zeichen enthalten und die Wiederholung muss übereinstimmen.',
+    'user' => "Es konnte kein Benutzer mit dieser E-Mail-Adresse gefunden werden.",
+    'token' => 'Dieser Link zum Zurücksetzen des Passwortes ist ungültig.',
+    'sent' => 'Wir haben Ihnen eine E-Mail mit einem Link zum Zurücksetzen des Passworts zugesendet!',
+    'reset' => 'Ihr Passwort wurde zurückgesetzt!',
 
 ];
diff --git a/resources/lang/de/settings.php b/resources/lang/de/settings.php
index 9d72b7942..598f9f663 100644
--- a/resources/lang/de/settings.php
+++ b/resources/lang/de/settings.php
@@ -20,17 +20,17 @@ return [
     'app_name' => 'Anwendungsname',
     'app_name_desc' => 'Dieser Name wird im Header und in E-Mails angezeigt.',
     'app_name_header' => 'Anwendungsname im Header anzeigen?',
-    'app_public_viewing' => '&Ouml;ffentliche Ansicht erlauben?',
-    'app_secure_images' => 'Erh&ouml;hte Sicherheit f&uuml;r Bilduploads aktivieren?',
-    'app_secure_images_desc' => 'Aus Leistungsgr&uuml;nden sind alle Bilder &ouml;ffentlich sichtbar. Diese Option f&uuml;gt zuf&auml;llige, schwer zu eratene, Zeichenketten vor die Bild-URLs hinzu. Stellen sie sicher, dass Verzeichnindexes deaktiviert sind, um einen einfachen Zugriff zu verhindern.',
+    'app_public_viewing' => 'Öffentliche Ansicht erlauben?',
+    'app_secure_images' => 'Erhöhte Sicherheit für hochgeladene Bilder aktivieren?',
+    'app_secure_images_desc' => 'Aus Leistungsgründen sind alle Bilder öffentlich sichtbar. Diese Option fügt zufällige, schwer zu eratene, Zeichenketten zu Bild-URLs hinzu. Stellen sie sicher, dass Verzeichnisindizes deaktiviert sind, um einen einfachen Zugriff zu verhindern.',
     'app_editor' => 'Seiteneditor',
-    'app_editor_desc' => 'W&auml;hlen sie den Editor aus, der von allen Benutzern genutzt werden soll, um Seiten zu editieren.',
+    'app_editor_desc' => 'Wählen Sie den Editor aus, der von allen Benutzern genutzt werden soll, um Seiten zu editieren.',
     'app_custom_html' => 'Benutzerdefinierter HTML <head> Inhalt',
-    'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugef&uuml;gt wird, wird am Ende der <head> Sektion jeder Seite eingef&uuml;gt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics Code hinzuzuf&uuml;gen.',
+    'app_custom_html_desc' => 'Jeder Inhalt, der hier hinzugefügt wird, wird am Ende der <head> Sektion jeder Seite eingefügt. Diese kann praktisch sein, um CSS Styles anzupassen oder Analytics-Code hinzuzufügen.',
     'app_logo' => 'Anwendungslogo',
-    'app_logo_desc' => 'Dieses Bild sollte 43px hoch sein. <br>Gr&ouml;&szlig;ere Bilder werden verkleinert.',
-    'app_primary_color' => 'Prim&auml;re Anwendungsfarbe',
-    'app_primary_color_desc' => 'Dies sollte ein HEX Wert sein. <br>Wenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zur&uuml;ckgesetzt.',
+    'app_logo_desc' => "Dieses Bild sollte 43px hoch sein.\nGrößere Bilder werden verkleinert.",
+    'app_primary_color' => 'Primäre Anwendungsfarbe',
+    'app_primary_color_desc' => "Dies sollte ein HEX Wert sein.\nWenn Sie nicht eingeben, wird die Anwendung auf die Standardfarbe zurückgesetzt.",
 
     /**
      * Registration settings
@@ -39,11 +39,11 @@ return [
     'reg_settings' => 'Registrierungseinstellungen',
     'reg_allow' => 'Registrierung erlauben?',
     'reg_default_role' => 'Standard-Benutzerrolle nach Registrierung',
-    'reg_confirm_email' => 'Best&auml;tigung per E-Mail erforderlich?',
-    'reg_confirm_email_desc' => 'Falls die Einschr&auml;nkung f&uuml;r Domains genutzt wird, ist die Best&auml;tigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.',
-    'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschr&auml;nken',
-    'reg_confirm_restrict_domain_desc' => 'F&uuml;gen sie eine, durch Komma getrennte, Liste von E-Mail Domains hinzu, auf die die Registrierung eingeschr&auml;nkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail Adresse zu best&auml;tigen, bevor sie diese Anwendung nutzen k&ouml;nnen. <br> Hinweis: Benutzer k&ouml;nnen ihre E-Mail Adresse nach erfolgreicher Registrierung &auml;ndern.',
-    'reg_confirm_restrict_domain_placeholder' => 'Keine Einschr&auml;nkung gesetzt',
+    'reg_confirm_email' => 'Bestätigung per E-Mail erforderlich?',
+    'reg_confirm_email_desc' => 'Falls die Einschränkung für Domains genutzt wird, ist die Bestätigung per E-Mail zwingend erforderlich und der untenstehende Wert wird ignoriert.',
+    'reg_confirm_restrict_domain' => 'Registrierung auf bestimmte Domains einschränken',
+    'reg_confirm_restrict_domain_desc' => "Fügen sie eine durch Komma getrennte Liste von Domains hinzu, auf die die Registrierung eingeschränkt werden soll. Benutzern wird eine E-Mail gesendet, um ihre E-Mail Adresse zu bestätigen, bevor sie diese Anwendung nutzen können.\nHinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung ändern.",
+    'reg_confirm_restrict_domain_placeholder' => 'Keine Einschränkung gesetzt',
 
     /**
      * Role settings
@@ -53,31 +53,31 @@ return [
     'role_user_roles' => 'Benutzer-Rollen',
     'role_create' => 'Neue Rolle anlegen',
     'role_create_success' => 'Rolle erfolgreich angelegt',
-    'role_delete' => 'Rolle l&ouml;schen',
-    'role_delete_confirm' => 'Sie m&ouml;chten die Rolle \':roleName\' l&ouml;schen.',
-    'role_delete_users_assigned' => 'Diese Rolle ist :userCount Benutzern zugeordnet. Sie k&ouml;nnen unten eine neue Rolle ausw&auml;hlen, die Sie diesen Benutzern zuordnen m&ouml;chten.',
+    'role_delete' => 'Rolle löschen',
+    'role_delete_confirm' => 'Sie möchten die Rolle ":roleName" löschen.',
+    'role_delete_users_assigned' => 'Diese Rolle ist :userCount Benutzern zugeordnet. Sie können unten eine neue Rolle auswählen, die Sie diesen Benutzern zuordnen möchten.',
     'role_delete_no_migration' => "Den Benutzern keine andere Rolle zuordnen",
-    'role_delete_sure' => 'Sind Sie sicher, dass Sie diese Rolle l&ouml;schen m&ouml;chten?',
-    'role_delete_success' => 'Rolle erfolgreich gel&ouml;scht',
+    'role_delete_sure' => 'Sind Sie sicher, dass Sie diese Rolle löschen möchten?',
+    'role_delete_success' => 'Rolle erfolgreich gelöscht',
     'role_edit' => 'Rolle bearbeiten',
-    'role_details' => 'Rollen-Details',
+    'role_details' => 'Rollendetails',
     'role_name' => 'Rollenname',
     'role_desc' => 'Kurzbeschreibung der Rolle',
     'role_system' => 'System-Berechtigungen',
     'role_manage_users' => 'Benutzer verwalten',
-    'role_manage_roles' => 'Rollen & Rollen-Berechtigungen verwalten',
-    'role_manage_entity_permissions' => 'Alle Buch-, Kapitel und Seiten-Berechtigungen verwalten',
-    'role_manage_own_entity_permissions' => 'Nur Berechtigungen eigener B&uuml;cher, Kapitel und Seiten verwalten',
-    'role_manage_settings' => 'Globaleinstellungen verwalrten',
+    'role_manage_roles' => 'Rollen und Rollen-Berechtigungen verwalten',
+    'role_manage_entity_permissions' => 'Alle Buch-, Kapitel- und Seiten-Berechtigungen verwalten',
+    'role_manage_own_entity_permissions' => 'Nur Berechtigungen eigener Bücher, Kapitel und Seiten verwalten',
+    'role_manage_settings' => 'Globaleinstellungen verwalten',
     'role_asset' => 'Berechtigungen',
-    'role_asset_desc' => 'Diese Berechtigungen gelten f&uuml;r den Standard-Zugriff innerhalb des Systems. Berechtigungen f&uuml;r B&uuml;cher, Kapitel und Seiten &uuml;berschreiben diese Berechtigungenen.',
+    'role_asset_desc' => 'Diese Berechtigungen gelten für den Standard-Zugriff innerhalb des Systems. Berechtigungen für Bücher, Kapitel und Seiten überschreiben diese Berechtigungenen.',
     'role_all' => 'Alle',
     'role_own' => 'Eigene',
-    'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
+    'role_controlled_by_asset' => 'Berechtigungen werden vom Uploadziel bestimmt',
     'role_save' => 'Rolle speichern',
     'role_update_success' => 'Rolle erfolgreich gespeichert',
     'role_users' => 'Dieser Rolle zugeordnete Benutzer',
-    'role_users_none' => 'Bisher sind dieser Rolle keiner Benutzer zugeordnet,',
+    'role_users_none' => 'Bisher sind dieser Rolle keine Benutzer zugeordnet',
 
     /**
      * Users
@@ -85,28 +85,28 @@ return [
 
     'users' => 'Benutzer',
     'user_profile' => 'Benutzerprofil',
-    'users_add_new' => 'Benutzer hinzuf&uuml;gen',
+    'users_add_new' => 'Benutzer hinzufügen',
     'users_search' => 'Benutzer suchen',
     'users_role' => 'Benutzerrollen',
     'users_external_auth_id' => 'Externe Authentifizierungs-ID',
-    'users_password_warning' => 'F&uuml;llen Sie die folgenden Felder nur aus, wenn Sie Ihr Passwort &auml;ndern m&ouml;chten:',
-    'users_system_public' => 'Dieser Benutzer repr&auml;sentiert alle Gast-Benutzer, die diese Seite betrachten. Er kann nicht zum Anmelden benutzt werden, sondern wird automatisch zugeordnet.',
+    'users_password_warning' => 'Füllen Sie die folgenden Felder nur aus, wenn Sie Ihr Passwort ändern möchten:',
+    'users_system_public' => 'Dieser Benutzer repräsentiert alle unangemeldeten Benutzer, die diese Seite betrachten. Er kann nicht zum Anmelden benutzt werden, sondern wird automatisch zugeordnet.',
+    'users_delete' => 'Benutzer löschen',
+    'users_delete_named' => 'Benutzer ":userName" löschen',
+    'users_delete_warning' => 'Der Benutzer ":userName" wird aus dem System gelöscht.',
+    'users_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?',
+    'users_delete_success' => 'Benutzer erfolgreich gelöscht.',
     'users_books_display_type' => 'Bevorzugtes Display-Layout für Bücher',
-    'users_delete' => 'Benutzer l&ouml;schen',
-    'users_delete_named' => 'Benutzer :userName l&ouml;schen',
-    'users_delete_warning' => 'Sie m&ouml;chten den Benutzer \':userName\' g&auml;nzlich aus dem System l&ouml;schen.',
-    'users_delete_confirm' => 'Sind Sie sicher, dass Sie diesen Benutzer l&ouml;schen m&ouml;chten?',
-    'users_delete_success' => 'Benutzer erfolgreich gel&ouml;scht.',
     'users_edit' => 'Benutzer bearbeiten',
     'users_edit_profile' => 'Profil bearbeiten',
     'users_edit_success' => 'Benutzer erfolgreich aktualisisert',
     'users_avatar' => 'Benutzer-Bild',
-    'users_avatar_desc' => 'Dieses Bild sollte einen Durchmesser von ca. 256px haben.',
+    'users_avatar_desc' => 'Das Bild sollte eine Auflösung von 256x256px haben.',
     'users_preferred_language' => 'Bevorzugte Sprache',
     'users_social_accounts' => 'Social-Media Konten',
-    'users_social_accounts_info' => 'Hier k&ouml;nnen Sie andere Social-Media Konten f&uuml;r eine schnellere und einfachere Anmeldung verkn&uuml;pfen. Wenn Sie ein Social-Media Konto hier l&ouml;sen, bleibt der Zugriff erhalteb. Entfernen Sie in diesem Falle die Berechtigung in Ihren Profil-Einstellungen des verkn&uuml;pften Social-Media Kontos.',
-    'users_social_connect' => 'Social-Media Konto verkn&uuml;pfen',
-    'users_social_disconnect' => 'Social-Media Kontoverkn&uuml;pfung l&ouml;sen',
-    'users_social_connected' => ':socialAccount Konto wurde erfolgreich mit dem Profil verkn&uuml;pft.',
-    'users_social_disconnected' => ':socialAccount Konto wurde erfolgreich vom Profil gel&ouml;st.',
+    'users_social_accounts_info' => 'Hier können Sie andere Social-Media-Konten für eine schnellere und einfachere Anmeldung verknüpfen. Wenn Sie ein Social-Media Konto lösen, bleibt der Zugriff erhalten. Entfernen Sie in diesem Falle die Berechtigung in Ihren Profil-Einstellungen des verknüpften Social-Media-Kontos.',
+    'users_social_connect' => 'Social-Media-Konto verknüpfen',
+    'users_social_disconnect' => 'Social-Media-Konto lösen',
+    'users_social_connected' => ':socialAccount-Konto wurde erfolgreich mit dem Profil verknüpft.',
+    'users_social_disconnected' => ':socialAccount-Konto wurde erfolgreich vom Profil gelöst.',
 ];
diff --git a/resources/lang/de/validation.php b/resources/lang/de/validation.php
index 3a6a1bc15..5ac4b1b27 100644
--- a/resources/lang/de/validation.php
+++ b/resources/lang/de/validation.php
@@ -19,54 +19,54 @@ return [
     'alpha'                => ':attribute kann nur Buchstaben enthalten.',
     'alpha_dash'           => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
     'alpha_num'            => ':attribute kann nur Buchstaben und Zahlen enthalten.',
-    'array'                => ':attribute muss eine Array sein.',
+    'array'                => ':attribute muss ein Array sein.',
     'before'               => ':attribute muss ein Datum vor :date sein.',
     'between'              => [
         'numeric' => ':attribute muss zwischen :min und :max liegen.',
-        'file'    => ':attribute muss zwischen :min und :max Kilobytes gro&szlig; sein.',
+        'file'    => ':attribute muss zwischen :min und :max Kilobytes groß sein.',
         'string'  => ':attribute muss zwischen :min und :max Zeichen lang sein.',
         'array'   => ':attribute muss zwischen :min und :max Elemente enthalten.',
     ],
     'boolean'              => ':attribute Feld muss wahr oder falsch sein.',
-    'confirmed'            => ':attribute Best&auml;tigung stimmt nicht &uuml;berein.',
+    'confirmed'            => ':attribute stimmt nicht überein.',
     'date'                 => ':attribute ist kein valides Datum.',
     'date_format'          => ':attribute entspricht nicht dem Format :format.',
-    'different'            => ':attribute und :other m&uuml;ssen unterschiedlich sein.',
+    'different'            => ':attribute und :other müssen unterschiedlich sein.',
     'digits'               => ':attribute muss :digits Stellen haben.',
     'digits_between'       => ':attribute muss zwischen :min und :max Stellen haben.',
-    'email'                => ':attribute muss eine valide E-Mail Adresse sein.',
-    'filled'               => ':attribute Feld ist erforderlich.',
-    'exists'               => 'Markiertes :attribute ist ung&uuml;ltig.',
+    'email'                => ':attribute muss eine valide E-Mail-Adresse sein.',
+    'filled'               => ':attribute ist erforderlich.',
+    'exists'               => ':attribute ist ungültig.',
     'image'                => ':attribute muss ein Bild sein.',
-    'in'                   => 'Markiertes :attribute ist ung&uuml;ltig.',
+    'in'                   => ':attribute ist ungültig.',
     'integer'              => ':attribute muss eine Zahl sein.',
     'ip'                   => ':attribute muss eine valide IP-Adresse sein.',
     'max'                  => [
-        'numeric' => ':attribute darf nicht gr&ouml;&szlig;er als :max sein.',
-        'file'    => ':attribute darf nicht gr&ouml;&szlig;er als :max Kilobyte sein.',
-        'string'  => ':attribute darf nicht l&auml;nger als :max Zeichen sein.',
+        'numeric' => ':attribute darf nicht größer als :max sein.',
+        'file'    => ':attribute darf nicht größer als :max Kilobyte sein.',
+        'string'  => ':attribute darf nicht länger als :max Zeichen sein.',
         'array'   => ':attribute darf nicht mehr als :max Elemente enthalten.',
     ],
     'mimes'                => ':attribute muss eine Datei vom Typ: :values sein.',
     'min'                  => [
-        'numeric' => ':attribute muss mindestens :min. sein',
-        'file'    => ':attribute muss mindestens :min Kilobyte gro&szlig; sein.',
+        'numeric' => ':attribute muss mindestens :min sein',
+        'file'    => ':attribute muss mindestens :min Kilobyte groß sein.',
         'string'  => ':attribute muss mindestens :min Zeichen lang sein.',
         'array'   => ':attribute muss mindesten :min Elemente enthalten.',
     ],
-    'not_in'               => 'Markiertes :attribute ist ung&uuml;ltig.',
+    'not_in'               => ':attribute ist ungültig.',
     'numeric'              => ':attribute muss eine Zahl sein.',
-    'regex'                => ':attribute Format ist ung&uuml;ltig.',
-    'required'             => ':attribute Feld ist erforderlich.',
-    'required_if'          => ':attribute Feld ist erforderlich, wenn :other :value ist.',
-    'required_with'        => ':attribute Feld ist erforderlich, wenn :values vorhanden ist.',
-    'required_with_all'    => ':attribute Feld ist erforderlich, wenn :values vorhanden sind.',
-    'required_without'     => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden ist.',
-    'required_without_all' => ':attribute Feld ist erforderlich, wenn :values nicht vorhanden sind.',
-    'same'                 => ':attribute und :other muss &uuml;bereinstimmen.',
+    'regex'                => ':attribute ist in einem ungültigen Format.',
+    'required'             => ':attribute ist erforderlich.',
+    'required_if'          => ':attribute ist erforderlich, wenn :other :value ist.',
+    'required_with'        => ':attribute ist erforderlich, wenn :values vorhanden ist.',
+    'required_with_all'    => ':attribute ist erforderlich, wenn :values vorhanden sind.',
+    'required_without'     => ':attribute ist erforderlich, wenn :values nicht vorhanden ist.',
+    'required_without_all' => ':attribute ist erforderlich, wenn :values nicht vorhanden sind.',
+    'same'                 => ':attribute und :other müssen übereinstimmen.',
     'size'                 => [
         'numeric' => ':attribute muss :size sein.',
-        'file'    => ':attribute muss :size Kilobytes gro&szlig; sein.',
+        'file'    => ':attribute muss :size Kilobytes groß sein.',
         'string'  => ':attribute muss :size Zeichen lang sein.',
         'array'   => ':attribute muss :size Elemente enthalten.',
     ],
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 450f4ce48..43053df10 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -234,4 +234,27 @@ return [
     'profile_not_created_pages' => ':userName has not created any pages',
     'profile_not_created_chapters' => ':userName has not created any chapters',
     'profile_not_created_books' => ':userName has not created any books',
+
+    /**
+     * Comments
+     */
+    'comment' => 'Comment',
+    'comments' => 'Comments',
+    'comment_placeholder' => 'Enter your comments here, markdown supported...',
+    'no_comments' => 'No Comments',
+    'x_comments' => ':numComments Comments',
+    'one_comment' => '1 Comment',
+    'comments_loading' => 'Loading...',
+    'comment_save' => 'Save Comment',
+    'comment_reply' => 'Reply',
+    'comment_edit' => 'Edit',
+    'comment_delete' => 'Delete',
+    'comment_cancel' => 'Cancel',
+    'comment_created' => 'Comment added',
+    'comment_updated' => 'Comment updated',
+    'comment_deleted' => 'Comment deleted',
+    'comment_updated_text' => 'Updated :updateDiff by',
+    'comment_delete_confirm' => 'This will remove the contents of the comment. Are you sure you want to delete this comment?',
+    'comment_create' => 'Created'
+
 ];
\ No newline at end of file
diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php
index c4578a37a..71bcd1f9a 100644
--- a/resources/lang/en/errors.php
+++ b/resources/lang/en/errors.php
@@ -60,6 +60,13 @@ return [
     'role_system_cannot_be_deleted' => 'This role is a system role and cannot be deleted',
     'role_registration_default_cannot_delete' => 'This role cannot be deleted while set as the default registration role',
 
+    // Comments
+    'comment_list' => 'An error occurred while fetching the comments.',
+    'cannot_add_comment_to_draft' => 'You cannot add comments to a draft.',
+    'comment_add' => 'An error occurred while adding the comment.',
+    'comment_delete' => 'An error occurred while deleting the comment.',
+    'empty_comment' => 'Cannot add an empty comment.',
+
     // Error pages
     '404_page_not_found' => 'Page Not Found',
     'sorry_page_not_found' => 'Sorry, The page you were looking for could not be found.',
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index d35857903..0a7547a58 100644
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -123,6 +123,7 @@ return [
         'pt_BR' => 'Português do Brasil',
         'sk' => 'Slovensky',
         'ja' => '日本語',
+        'pl' => 'Polski',
     ]
     ///////////////////////////////////
 ];
diff --git a/resources/lang/es/entities.php b/resources/lang/es/entities.php
index d6b2810bc..2ca55a786 100644
--- a/resources/lang/es/entities.php
+++ b/resources/lang/es/entities.php
@@ -214,4 +214,26 @@ return [
     'profile_not_created_pages' => ':userName no ha creado ninguna página',
     'profile_not_created_chapters' => ':userName no ha creado ningún capítulo',
     'profile_not_created_books' => ':userName no ha creado ningún libro',
+
+    /**
+     * Comments
+     */
+    'comment' => 'Comentario',
+    'comments' => 'Comentarios',
+    'comment_placeholder' => 'Introduzca sus comentarios aquí, markdown supported ...',
+    'no_comments' => 'No hay comentarios',
+    'x_comments' => ':numComments Comentarios',
+    'one_comment' => '1 Comentario',
+    'comments_loading' => 'Cargando ...',
+    'comment_save' => 'Guardar comentario',
+    'comment_reply' => 'Responder',
+    'comment_edit' => 'Editar',
+    'comment_delete' => 'Eliminar',
+    'comment_cancel' => 'Cancelar',
+    'comment_created' => 'Comentario añadido',
+    'comment_updated' => 'Comentario actualizado',
+    'comment_deleted' => 'Comentario eliminado',
+    'comment_updated_text' => 'Actualizado hace :updateDiff por',
+    'comment_delete_confirm' => 'Esto eliminará el contenido del comentario. ¿Estás seguro de que quieres eliminar este comentario?',
+    'comment_create' => 'Creado'
 ];
diff --git a/resources/lang/es/errors.php b/resources/lang/es/errors.php
index 1e39a3cb8..e488b6a1b 100644
--- a/resources/lang/es/errors.php
+++ b/resources/lang/es/errors.php
@@ -67,4 +67,11 @@ return [
     'error_occurred' => 'Ha ocurrido un error',
     'app_down' => 'La aplicación :appName se encuentra caída en este momento',
     'back_soon' => 'Volverá a estar operativa en corto tiempo.',
+
+    // Comments
+    'comment_list' => 'Se ha producido un error al buscar los comentarios.',
+    'cannot_add_comment_to_draft' => 'No puedes añadir comentarios a un borrador.',
+    'comment_add' => 'Se ha producido un error al añadir el comentario.',
+    'comment_delete' => 'Se ha producido un error al eliminar el comentario.',
+    'empty_comment' => 'No se puede agregar un comentario vacío.',
 ];
diff --git a/resources/lang/fr/auth.php b/resources/lang/fr/auth.php
index 41d051c5f..015bfdff0 100644
--- a/resources/lang/fr/auth.php
+++ b/resources/lang/fr/auth.php
@@ -10,7 +10,7 @@ return [
     | these language lines according to your application's requirements.
     |
     */
-    'failed' => 'Ces informations ne correspondent a aucun compte.',
+    'failed' => 'Ces informations ne correspondent à aucun compte.',
     'throttle' => "Trop d'essais, veuillez réessayer dans :seconds secondes.",
 
     /**
@@ -26,7 +26,7 @@ return [
     'password' => 'Mot de passe',
     'password_confirm' => 'Confirmez le mot de passe',
     'password_hint' => 'Doit faire plus de 5 caractères',
-    'forgot_password' => 'Mot de passe oublié?',
+    'forgot_password' => 'Mot de passe oublié ?',
     'remember_me' => 'Se souvenir de moi',
     'ldap_email_hint' => "Merci d'entrer une adresse e-mail pour ce compte",
     'create_account' => 'Créer un compte',
@@ -35,9 +35,9 @@ return [
     'social_registration_text' => "S'inscrire et se connecter avec un réseau social",
 
     'register_thanks' => 'Merci pour votre enregistrement',
-    'register_confirm' => 'Vérifiez vos e-mails et cliquer sur le lien de confirmation pour rejoindre :appName.',
+    'register_confirm' => 'Vérifiez vos e-mails et cliquez sur le lien de confirmation pour rejoindre :appName.',
     'registrations_disabled' => "L'inscription est désactivée pour le moment",
-    'registration_email_domain_invalid' => 'Cette adresse e-mail ne peux pas adcéder à l\'application',
+    'registration_email_domain_invalid' => 'Cette adresse e-mail ne peut pas accéder à l\'application',
     'register_success' => 'Merci pour votre inscription. Vous êtes maintenant inscrit(e) et connecté(e)',
 
 
@@ -51,7 +51,7 @@ return [
     'reset_password_success' => 'Votre mot de passe a été réinitialisé avec succès.',
 
     'email_reset_subject' => 'Réinitialisez votre mot de passe pour :appName',
-    'email_reset_text' => 'Vous recevez cet e-mail parceque nous avons reçu une demande de réinitialisation pour votre compte',
+    'email_reset_text' => 'Vous recevez cet e-mail parce que nous avons reçu une demande de réinitialisation pour votre compte',
     'email_reset_not_requested' => 'Si vous n\'avez pas effectué cette demande, vous pouvez ignorer cet e-mail.',
 
 
@@ -59,11 +59,11 @@ return [
      * Email Confirmation
      */
     'email_confirm_subject' => 'Confirmez votre adresse e-mail pour :appName',
-    'email_confirm_greeting' => 'Merci d\'avoir rejoint :appName!',
-    'email_confirm_text' => 'Merci de confirmer en cliquant sur le lien ci-dessous:',
+    'email_confirm_greeting' => 'Merci d\'avoir rejoint :appName !',
+    'email_confirm_text' => 'Merci de confirmer en cliquant sur le lien ci-dessous :',
     'email_confirm_action' => 'Confirmez votre adresse e-mail',
     'email_confirm_send_error' => 'La confirmation par e-mail est requise mais le système n\'a pas pu envoyer l\'e-mail. Contactez l\'administrateur système.',
-    'email_confirm_success' => 'Votre adresse e-mail a été confirmée!',
+    'email_confirm_success' => 'Votre adresse e-mail a été confirmée !',
     'email_confirm_resent' => 'L\'e-mail de confirmation a été ré-envoyé. Vérifiez votre boîte de récéption.',
 
     'email_not_confirmed' => 'Adresse e-mail non confirmée',
diff --git a/resources/lang/fr/common.php b/resources/lang/fr/common.php
index 129cf082e..74f174dca 100644
--- a/resources/lang/fr/common.php
+++ b/resources/lang/fr/common.php
@@ -9,7 +9,7 @@ return [
     'back' => 'Retour',
     'save' => 'Enregistrer',
     'continue' => 'Continuer',
-    'select' => 'Selectionner',
+    'select' => 'Sélectionner',
 
     /**
      * Form Labels
@@ -55,6 +55,6 @@ return [
     /**
      * Email Content
      */
-    'email_action_help' => 'Si vous rencontrez des problèmes pour cliquer le bouton ":actionText", copiez et collez l\'adresse ci-dessous dans votre navigateur:',
+    'email_action_help' => 'Si vous rencontrez des problèmes pour cliquer sur le bouton ":actionText", copiez et collez l\'adresse ci-dessous dans votre navigateur :',
     'email_rights' => 'Tous droits réservés',
 ];
diff --git a/resources/lang/fr/entities.php b/resources/lang/fr/entities.php
index 5562fb0fd..0d89993e9 100644
--- a/resources/lang/fr/entities.php
+++ b/resources/lang/fr/entities.php
@@ -12,7 +12,7 @@ return [
     'recently_update' => 'Mis à jour récemment',
     'recently_viewed' => 'Vus récemment',
     'recent_activity' => 'Activité récente',
-    'create_now' => 'En créer un récemment',
+    'create_now' => 'En créer un maintenant',
     'revisions' => 'Révisions',
     'meta_created' => 'Créé :timeLength',
     'meta_created_name' => 'Créé :timeLength par :user',
@@ -59,8 +59,8 @@ return [
     'books_create' => 'Créer un nouveau livre',
     'books_delete' => 'Supprimer un livre',
     'books_delete_named' => 'Supprimer le livre :bookName',
-    'books_delete_explain' => 'Ceci va supprimer le livre nommé \':bookName\', Tous les chapitres et pages seront supprimés.',
-    'books_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer ce livre?',
+    'books_delete_explain' => 'Ceci va supprimer le livre nommé \':bookName\', tous les chapitres et pages seront supprimés.',
+    'books_delete_confirmation' => 'Êtes-vous sûr(e) de vouloir supprimer ce livre ?',
     'books_edit' => 'Modifier le livre',
     'books_edit_named' => 'Modifier le livre :bookName',
     'books_form_book_name' => 'Nom du livre',
@@ -90,18 +90,18 @@ return [
     'chapters_create' => 'Créer un nouveau chapitre',
     'chapters_delete' => 'Supprimer le chapitre',
     'chapters_delete_named' => 'Supprimer le chapitre :chapterName',
-    'chapters_delete_explain' => 'Ceci va supprimer le chapitre \':chapterName\', Toutes les pages seront déplacée dans le livre parent.',
-    'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre?',
+    'chapters_delete_explain' => 'Ceci va supprimer le chapitre \':chapterName\', toutes les pages seront déplacées dans le livre parent.',
+    'chapters_delete_confirm' => 'Etes-vous sûr(e) de vouloir supprimer ce chapitre ?',
     'chapters_edit' => 'Modifier le chapitre',
     'chapters_edit_named' => 'Modifier le chapitre :chapterName',
     'chapters_save' => 'Enregistrer le chapitre',
-    'chapters_move' => 'Déplace le chapitre',
+    'chapters_move' => 'Déplacer le chapitre',
     'chapters_move_named' => 'Déplacer le chapitre :chapterName',
     'chapter_move_success' => 'Chapitre déplacé dans :bookName',
     'chapters_permissions' => 'Permissions du chapitre',
-    'chapters_empty' => 'Il n\'y a pas de pages dans ce chapitre actuellement.',
+    'chapters_empty' => 'Il n\'y a pas de page dans ce chapitre actuellement.',
     'chapters_permissions_active' => 'Permissions du chapitre activées',
-    'chapters_permissions_success' => 'Permissions du chapitres mises à jour',
+    'chapters_permissions_success' => 'Permissions du chapitre mises à jour',
 
     /**
      * Pages
@@ -118,8 +118,8 @@ return [
     'pages_delete_draft' => 'Supprimer le brouillon',
     'pages_delete_success' => 'Page supprimée',
     'pages_delete_draft_success' => 'Brouillon supprimé',
-    'pages_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cette page?',
-    'pages_delete_draft_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce brouillon?',
+    'pages_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cette page ?',
+    'pages_delete_draft_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer ce brouillon ?',
     'pages_editing_named' => 'Modification de la page :pageName',
     'pages_edit_toggle_header' => 'Afficher/cacher l\'en-tête',
     'pages_edit_save_draft' => 'Enregistrer le brouillon',
@@ -131,7 +131,7 @@ return [
     'pages_edit_discard_draft' => 'Ecarter le brouillon',
     'pages_edit_set_changelog' => 'Remplir le journal des changements',
     'pages_edit_enter_changelog_desc' => 'Entrez une brève description des changements effectués',
-    'pages_edit_enter_changelog' => 'Entrez dans le journal des changements',
+    'pages_edit_enter_changelog' => 'Entrer dans le journal des changements',
     'pages_save' => 'Enregistrez la page',
     'pages_title' => 'Titre de la page',
     'pages_name' => 'Nom de la page',
@@ -139,7 +139,7 @@ return [
     'pages_md_preview' => 'Prévisualisation',
     'pages_md_insert_image' => 'Insérer une image',
     'pages_md_insert_link' => 'Insérer un lien',
-    'pages_not_in_chapter' => 'La page n\'est pas dans un chanpitre',
+    'pages_not_in_chapter' => 'La page n\'est pas dans un chapitre',
     'pages_move' => 'Déplacer la page',
     'pages_move_success' => 'Page déplacée à ":parentName"',
     'pages_permissions' => 'Permissions de la page',
@@ -160,15 +160,15 @@ return [
     'pages_initial_revision' => 'Publication initiale',
     'pages_initial_name' => 'Nouvelle page',
     'pages_editing_draft_notification' => 'Vous éditez actuellement un brouillon qui a été sauvé :timeDiff.',
-    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visit. Vous devriez écarter ce brouillon.',
+    'pages_draft_edited_notification' => 'La page a été mise à jour depuis votre dernière visite. Vous devriez écarter ce brouillon.',
     'pages_draft_edit_active' => [
-        'start_a' => ':count utilisateurs ont commencé a éditer cette page',
+        'start_a' => ':count utilisateurs ont commencé à éditer cette page',
         'start_b' => ':userName a commencé à éditer cette page',
         'time_a' => 'depuis la dernière sauvegarde',
         'time_b' => 'dans les :minCount dernières minutes',
-        'message' => ':start :time. Attention a ne pas écraser les mises à jour de quelqu\'un d\'autre!',
+        'message' => ':start :time. Attention à ne pas écraser les mises à jour de quelqu\'un d\'autre !',
     ],
-    'pages_draft_discarded' => 'Brouuillon écarté, la page est dans sa version actuelle.',
+    'pages_draft_discarded' => 'Brouillon écarté, la page est dans sa version actuelle.',
 
     /**
      * Editor sidebar
@@ -210,7 +210,29 @@ return [
      */
     'profile_user_for_x' => 'Utilisateur depuis :time',
     'profile_created_content' => 'Contenu créé',
-    'profile_not_created_pages' => ':userName n\'a pas créé de pages',
-    'profile_not_created_chapters' => ':userName n\'a pas créé de chapitres',
-    'profile_not_created_books' => ':userName n\'a pas créé de livres',
+    'profile_not_created_pages' => ':userName n\'a pas créé de page',
+    'profile_not_created_chapters' => ':userName n\'a pas créé de chapitre',
+    'profile_not_created_books' => ':userName n\'a pas créé de livre',
+
+    /**
+     * Comments
+     */
+    'comment' => 'Commentaire',
+    'comments' => 'Commentaires',
+    'comment_placeholder' => 'Entrez vos commentaires ici, merci supporté ...',
+    'no_comments' => 'No Comments',
+    'x_comments' => ':numComments Commentaires',
+    'one_comment' => '1 Commentaire',
+    'comments_loading' => 'Loading ...',
+    'comment_save' => 'Enregistrer le commentaire',
+    'comment_reply' => 'Répondre',
+    'comment_edit' => 'Modifier',
+    'comment_delete' => 'Supprimer',
+    'comment_cancel' => 'Annuler',
+    'comment_created' => 'Commentaire ajouté',
+    'comment_updated' => 'Commentaire mis à jour',
+    'comment_deleted' => 'Commentaire supprimé',
+    'comment_updated_text' => 'Mis à jour il y a :updateDiff par',
+    'comment_delete_confirm' => 'Cela supprime le contenu du commentaire. Êtes-vous sûr de vouloir supprimer ce commentaire?',
+    'comment_create' => 'Créé'
 ];
diff --git a/resources/lang/fr/errors.php b/resources/lang/fr/errors.php
index 72af89f7f..9e20147b6 100644
--- a/resources/lang/fr/errors.php
+++ b/resources/lang/fr/errors.php
@@ -18,21 +18,21 @@ return [
     'ldap_fail_anonymous' => 'L\'accès LDAP anonyme n\'a pas abouti',
     'ldap_fail_authed' => 'L\'accès LDAP n\'a pas abouti avec cet utilisateur et ce mot de passe',
     'ldap_extension_not_installed' => 'L\'extention LDAP PHP n\'est pas installée',
-    'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed',
-    'social_no_action_defined' => 'No action defined',
-    'social_account_in_use' => 'Cet compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
-    'social_account_email_in_use' => 'L\'email :email Est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
+    'ldap_cannot_connect' => 'Impossible de se connecter au serveur LDAP, la connexion initiale a échoué',
+    'social_no_action_defined' => 'Pas d\'action définie',
+    'social_account_in_use' => 'Ce compte :socialAccount est déjà utilisé. Essayez de vous connecter via :socialAccount.',
+    'social_account_email_in_use' => 'L\'email :email est déjà utilisé. Si vous avez déjà un compte :socialAccount, vous pouvez le joindre à votre profil existant.',
     'social_account_existing' => 'Ce compte :socialAccount est déjà rattaché à votre profil.',
     'social_account_already_used_existing' => 'Ce compte :socialAccount est déjà utilisé par un autre utilisateur.',
     'social_account_not_used' => 'Ce compte :socialAccount n\'est lié à aucun utilisateur. ',
     'social_account_register_instructions' => 'Si vous n\'avez pas encore de compte, vous pouvez le lier avec l\'option :socialAccount.',
-    'social_driver_not_found' => 'Social driver not found',
-    'social_driver_not_configured' => 'Your :socialAccount social settings are not configured correctly.',
+    'social_driver_not_found' => 'Pilote de compte social absent',
+    'social_driver_not_configured' => 'Vos préférences pour le compte :socialAccount sont incorrectes.',
 
     // System
-    'path_not_writable' => 'File path :filePath could not be uploaded to. Ensure it is writable to the server.',
+    'path_not_writable' => 'Impossible d\'écrire dans :filePath. Assurez-vous d\'avoir les droits d\'écriture sur le serveur',
     'cannot_get_image_from_url' => 'Impossible de récupérer l\'image depuis :url',
-    'cannot_create_thumbs' => 'Le serveur ne peux pas créer de miniatures, vérifier que l\extensions GD PHP est installée.',
+    'cannot_create_thumbs' => 'Le serveur ne peut pas créer de miniature, vérifier que l\'extension PHP GD est installée.',
     'server_upload_limit' => 'La taille du fichier est trop grande.',
     'image_upload_error' => 'Une erreur est survenue pendant l\'envoi de l\'image',
 
@@ -57,7 +57,7 @@ return [
 
     // Roles
     'role_cannot_be_edited' => 'Ce rôle ne peut pas être modifié',
-    'role_system_cannot_be_deleted' => 'Ceci est un rôle du système et on ne peut pas le supprimer',
+    'role_system_cannot_be_deleted' => 'Ceci est un rôle du système et ne peut pas être supprimé',
     'role_registration_default_cannot_delete' => 'Ce rôle ne peut pas être supprimé tant qu\'il est le rôle par défaut',
 
     // Error pages
@@ -67,4 +67,11 @@ return [
     'error_occurred' => 'Une erreur est survenue',
     'app_down' => ':appName n\'est pas en service pour le moment',
     'back_soon' => 'Nous serons bientôt de retour.',
+
+    // comments
+    'comment_list' => 'Une erreur s\'est produite lors de la récupération des commentaires.',
+    'cannot_add_comment_to_draft' => 'Vous ne pouvez pas ajouter de commentaires à un projet.',
+    'comment_add' => 'Une erreur s\'est produite lors de l\'ajout du commentaire.',
+    'comment_delete' => 'Une erreur s\'est produite lors de la suppression du commentaire.',
+    'empty_comment' => 'Impossible d\'ajouter un commentaire vide.',
 ];
diff --git a/resources/lang/fr/passwords.php b/resources/lang/fr/passwords.php
index 7be81da23..484b4b20c 100644
--- a/resources/lang/fr/passwords.php
+++ b/resources/lang/fr/passwords.php
@@ -16,7 +16,7 @@ return [
     'password' => 'Les mots de passe doivent faire au moins 6 caractères et correspondre à la confirmation.',
     'user' => "Nous n'avons pas trouvé d'utilisateur avec cette adresse.",
     'token' => 'Le jeton de réinitialisation est invalide.',
-    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe!',
-    'reset' => 'Votre mot de passe a été réinitialisé!',
+    'sent' => 'Nous vous avons envoyé un lien de réinitialisation de mot de passe !',
+    'reset' => 'Votre mot de passe a été réinitialisé !',
 
 ];
diff --git a/resources/lang/fr/settings.php b/resources/lang/fr/settings.php
index 5d9c966e7..5516e66a4 100644
--- a/resources/lang/fr/settings.php
+++ b/resources/lang/fr/settings.php
@@ -19,27 +19,27 @@ return [
     'app_settings' => 'Préférences de l\'application',
     'app_name' => 'Nom de l\'application',
     'app_name_desc' => 'Ce nom est affiché dans l\'en-tête et les e-mails.',
-    'app_name_header' => 'Afficher le nom dans l\'en-tête?',
-    'app_public_viewing' => 'Accepter le visionnage public des pages?',
-    'app_secure_images' => 'Activer l\'ajout d\'image sécurisé?',
+    'app_name_header' => 'Afficher le nom dans l\'en-tête ?',
+    'app_public_viewing' => 'Accepter le visionnage public des pages ?',
+    'app_secure_images' => 'Activer l\'ajout d\'image sécurisé ?',
     'app_secure_images_desc' => 'Pour des questions de performances, toutes les images sont publiques. Cette option ajoute une chaîne aléatoire difficile à deviner dans les URLs des images.',
     'app_editor' => 'Editeur des pages',
     'app_editor_desc' => 'Sélectionnez l\'éditeur qui sera utilisé pour modifier les pages.',
     'app_custom_html' => 'HTML personnalisé dans l\'en-tête',
-    'app_custom_html_desc' => 'Le contenu inséré ici sera jouté en bas de la balise <head> de toutes les pages. Vous pouvez l\'utiliser pour ajouter du CSS personnalisé ou un tracker analytique.',
+    'app_custom_html_desc' => 'Le contenu inséré ici sera ajouté en bas de la balise <head> de toutes les pages. Vous pouvez l\'utiliser pour ajouter du CSS personnalisé ou un tracker analytique.',
     'app_logo' => 'Logo de l\'Application',
     'app_logo_desc' => 'Cette image doit faire 43px de hauteur. <br>Les images plus larges seront réduites.',
     'app_primary_color' => 'Couleur principale de l\'application',
-    'app_primary_color_desc' => 'This should be a hex value. <br>Leave empty to reset to the default color.',
+    'app_primary_color_desc' => 'Cela devrait être une valeur hexadécimale. <br>Laisser vide pour rétablir la couleur par défaut.',
 
     /**
      * Registration settings
      */
 
     'reg_settings' => 'Préférence pour l\'inscription',
-    'reg_allow' => 'Accepter l\'inscription?',
+    'reg_allow' => 'Accepter l\'inscription ?',
     'reg_default_role' => 'Rôle par défaut lors de l\'inscription',
-    'reg_confirm_email' => 'Obliger la confirmation par e-mail?',
+    'reg_confirm_email' => 'Obliger la confirmation par e-mail ?',
     'reg_confirm_email_desc' => 'Si la restriction de domaine est activée, la confirmation sera automatiquement obligatoire et cette valeur sera ignorée.',
     'reg_confirm_restrict_domain' => 'Restreindre l\'inscription à un domaine',
     'reg_confirm_restrict_domain_desc' => 'Entrez une liste de domaines acceptés lors de l\'inscription, séparés par une virgule. Les utilisateur recevront un e-mail de confirmation à cette adresse. <br> Les utilisateurs pourront changer leur adresse après inscription s\'ils le souhaitent.',
@@ -57,17 +57,17 @@ return [
     'role_delete_confirm' => 'Ceci va supprimer le rôle \':roleName\'.',
     'role_delete_users_assigned' => 'Ce rôle a :userCount utilisateurs assignés. Vous pouvez choisir un rôle de remplacement pour ces utilisateurs.',
     'role_delete_no_migration' => "Ne pas assigner de nouveau rôle",
-    'role_delete_sure' => 'Êtes vous sûr(e) de vouloir supprimer ce rôle?',
+    'role_delete_sure' => 'Êtes vous sûr(e) de vouloir supprimer ce rôle ?',
     'role_delete_success' => 'Le rôle a été supprimé avec succès',
     'role_edit' => 'Modifier le rôle',
     'role_details' => 'Détails du rôle',
-    'role_name' => 'Nom du Rôle',
+    'role_name' => 'Nom du rôle',
     'role_desc' => 'Courte description du rôle',
     'role_system' => 'Permissions système',
     'role_manage_users' => 'Gérer les utilisateurs',
     'role_manage_roles' => 'Gérer les rôles et permissions',
     'role_manage_entity_permissions' => 'Gérer les permissions sur les livres, chapitres et pages',
-    'role_manage_own_entity_permissions' => 'Gérer les permissions de ses propres livres chapitres et pages',
+    'role_manage_own_entity_permissions' => 'Gérer les permissions de ses propres livres, chapitres, et pages',
     'role_manage_settings' => 'Gérer les préférences de l\'application',
     '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.',
@@ -95,7 +95,7 @@ return [
     'users_delete' => 'Supprimer un utilisateur',
     'users_delete_named' => 'Supprimer l\'utilisateur :userName',
     'users_delete_warning' => 'Ceci va supprimer \':userName\' du système.',
-    'users_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cet utilisateur?',
+    'users_delete_confirm' => 'Êtes-vous sûr(e) de vouloir supprimer cet utilisateur ?',
     'users_delete_success' => 'Utilisateurs supprimés avec succès',
     'users_edit' => 'Modifier l\'utilisateur',
     'users_edit_profile' => 'Modifier le profil',
@@ -107,7 +107,7 @@ return [
     'users_social_accounts_info' => 'Vous pouvez connecter des réseaux sociaux à votre compte pour vous connecter plus rapidement. Déconnecter un compte n\'enlèvera pas les accès autorisés précédemment sur votre compte de réseau social.',
     'users_social_connect' => 'Connecter le compte',
     'users_social_disconnect' => 'Déconnecter le compte',
-    'users_social_connected' => 'Votre compte :socialAccount a élté ajouté avec succès.',
+    'users_social_connected' => 'Votre compte :socialAccount a été ajouté avec succès.',
     'users_social_disconnected' => 'Votre compte :socialAccount a été déconnecté avec succès',
 
 ];
diff --git a/resources/lang/nl/entities.php b/resources/lang/nl/entities.php
index d6975e130..6df9e5dd9 100644
--- a/resources/lang/nl/entities.php
+++ b/resources/lang/nl/entities.php
@@ -214,4 +214,26 @@ return [
     'profile_not_created_pages' => ':userName heeft geen pagina\'s gemaakt',
     'profile_not_created_chapters' => ':userName heeft geen hoofdstukken gemaakt',
     'profile_not_created_books' => ':userName heeft geen boeken gemaakt',
+
+    /**
+     * Comments
+     */
+    'comment' => 'Commentaar',
+    'comments' => 'Commentaren',
+    'comment_placeholder' => 'Vul hier uw reacties in, markdown ondersteund ...',
+    'no_comments' => 'No Comments',
+    'x_comments' => ':numComments Opmerkingen',
+    'one_comment' => '1 commentaar',
+    'comments_loading' => 'Loading ...',
+    'comment_save' => 'Opslaan opslaan',
+    'comment_reply' => 'Antwoord',
+    'comment_edit' => 'Bewerken',
+    'comment_delete' => 'Verwijderen',
+    'comment_cancel' => 'Annuleren',
+    'comment_created' => 'Opmerking toegevoegd',
+    'comment_updated' => 'Opmerking bijgewerkt',
+    'comment_deleted' => 'Opmerking verwijderd',
+    'comment_updated_text' => 'Bijgewerkt :updateDiff geleden door',
+    'comment_delete_confirm' => 'Hiermee verwijdert u de inhoud van de reactie. Weet u zeker dat u deze reactie wilt verwijderen?',
+    'comment_create' => 'Gemaakt'
 ];
\ No newline at end of file
diff --git a/resources/lang/nl/errors.php b/resources/lang/nl/errors.php
index f8b635bce..b8fab59fd 100644
--- a/resources/lang/nl/errors.php
+++ b/resources/lang/nl/errors.php
@@ -67,4 +67,11 @@ return [
     'error_occurred' => 'Er Ging Iets Fout',
     'app_down' => ':appName is nu niet beschikbaar',
     'back_soon' => 'Komt snel weer online.',
+
+    // Comments
+    'comment_list' => 'Er is een fout opgetreden tijdens het ophalen van de reacties.',
+    'cannot_add_comment_to_draft' => 'U kunt geen reacties toevoegen aan een ontwerp.',
+    'comment_add' => 'Er is een fout opgetreden tijdens het toevoegen van de reactie.',
+    'comment_delete' => 'Er is een fout opgetreden tijdens het verwijderen van de reactie.',
+    'empty_comment' => 'Kan geen lege reactie toevoegen.',
 ];
\ No newline at end of file
diff --git a/resources/lang/pl/activities.php b/resources/lang/pl/activities.php
new file mode 100644
index 000000000..5ef5acab0
--- /dev/null
+++ b/resources/lang/pl/activities.php
@@ -0,0 +1,40 @@
+<?php
+
+return [
+
+    /**
+     * Activity text strings.
+     * Is used for all the text within activity logs & notifications.
+     */
+
+    // Pages
+    'page_create'                 => 'utworzono stronę',
+    'page_create_notification'    => 'Strona utworzona pomyślnie',
+    'page_update'                 => 'zaktualizowano stronę',
+    'page_update_notification'    => 'Strona zaktualizowana pomyślnie',
+    'page_delete'                 => 'usunięto stronę',
+    'page_delete_notification'    => 'Strona usunięta pomyślnie',
+    'page_restore'                => 'przywrócono stronę',
+    'page_restore_notification'   => 'Stronga przywrócona pomyślnie',
+    'page_move'                   => 'przeniesiono stronę',
+
+    // Chapters
+    'chapter_create'              => 'utworzono rozdział',
+    'chapter_create_notification' => 'Rozdział utworzony pomyślnie',
+    'chapter_update'              => 'zaktualizowano rozdział',
+    'chapter_update_notification' => 'Rozdział zaktualizowany pomyślnie',
+    'chapter_delete'              => 'usunięto rozdział',
+    'chapter_delete_notification' => 'Rozdział usunięty pomyślnie',
+    'chapter_move'                => 'przeniesiono rozdział',
+
+    // Books
+    'book_create'                 => 'utworzono księgę',
+    'book_create_notification'    => 'Księga utworzona pomyślnie',
+    'book_update'                 => 'zaktualizowano księgę',
+    'book_update_notification'    => 'Księga zaktualizowana pomyślnie',
+    'book_delete'                 => 'usunięto księgę',
+    'book_delete_notification'    => 'Księga usunięta pomyślnie',
+    'book_sort'                   => 'posortowano księgę',
+    'book_sort_notification'      => 'Księga posortowana pomyślnie',
+
+];
diff --git a/resources/lang/pl/auth.php b/resources/lang/pl/auth.php
new file mode 100644
index 000000000..740e067ca
--- /dev/null
+++ b/resources/lang/pl/auth.php
@@ -0,0 +1,76 @@
+<?php
+return [
+    /*
+    |--------------------------------------------------------------------------
+    | Authentication Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used during authentication for various
+    | messages that we need to display to the user. You are free to modify
+    | these language lines according to your application's requirements.
+    |
+    */
+    'failed' => 'These credentials do not match our records.',
+    'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
+
+    /**
+     * Login & Register
+     */
+    'sign_up' => 'Zarejestruj się',
+    'log_in' => 'Zaloguj się',
+    'log_in_with' => 'Zaloguj się za pomocą :socialDriver',
+    'sign_up_with' => 'Zarejestruj się za pomocą :socialDriver',
+    'logout' => 'Wyloguj',
+
+    'name' => 'Imię',
+    'username' => 'Nazwa użytkownika',
+    'email' => 'Email',
+    'password' => 'Hasło',
+    'password_confirm' => 'Potwierdzenie hasła',
+    'password_hint' => 'Musi mieć więcej niż 5 znaków',
+    'forgot_password' => 'Przypomnij hasło',
+    'remember_me' => 'Zapamiętaj mnie',
+    'ldap_email_hint' => 'Wprowadź adres email dla tego konta.',
+    'create_account' => 'Utwórz konto',
+    'social_login' => 'Logowanie za pomocą konta społecznościowego',
+    'social_registration' => 'Rejestracja za pomocą konta społecznościowego',
+    'social_registration_text' => 'Zarejestruj się za pomocą innej usługi.',
+
+    'register_thanks' => 'Dziękujemy za rejestrację!',
+    'register_confirm' => 'Sprawdź podany adres e-mail i kliknij w link, by uzyskać dostęp do :appName.',
+    'registrations_disabled' => 'Rejestracja jest obecnie zablokowana.',
+    'registration_email_domain_invalid' => 'Adresy e-mail z tej domeny nie mają dostępu do tej aplikacji',
+    'register_success' => 'Dziękujemy za rejestrację! Zalogowano Cię automatycznie.',
+
+
+    /**
+     * Password Reset
+     */
+    'reset_password' => 'Resetowanie hasła',
+    'reset_password_send_instructions' => 'Wprowadź adres e-mail powiązany z Twoim kontem, by otrzymać link do resetowania hasła.',
+    'reset_password_send_button' => 'Wyślij link do resetowania hasła',
+    'reset_password_sent_success' => 'Wysłano link do resetowania hasła na adres :email.',
+    'reset_password_success' => 'Hasło zostało zresetowane pomyślnie.',
+
+    'email_reset_subject' => 'Resetowanie hasła do :appName',
+    'email_reset_text' => 'Otrzymujesz tę wiadomość ponieważ ktoś zażądał zresetowania hasła do Twojego konta.',
+    'email_reset_not_requested' => 'Jeśli to nie Ty złożyłeś żądanie zresetowania hasła, zignoruj tę wiadomość.',
+
+
+    /**
+     * Email Confirmation
+     */
+    'email_confirm_subject' => 'Potwierdź swój adres email w :appName',
+    'email_confirm_greeting' => 'Dziękujemy za dołączenie do :appName!',
+    'email_confirm_text' => 'Prosimy byś potwierdził swoje hasło klikając przycisk poniżej:',
+    'email_confirm_action' => 'Potwierdź email',
+    'email_confirm_send_error' => 'Wymagane jest potwierdzenie hasła, lecz wiadomość nie mogła zostać wysłana. Skontaktuj się z administratorem w celu upewnienia się, że skrzynka została skonfigurowana prawidłowo.',
+    'email_confirm_success' => 'Adres email został potwierdzony!',
+    'email_confirm_resent' => 'Wiadomość potwierdzająca została wysłana, sprawdź swoją skrzynkę.',
+
+    'email_not_confirmed' => 'Adres email niepotwierdzony',
+    'email_not_confirmed_text' => 'Twój adres email nie został jeszcze potwierdzony.',
+    'email_not_confirmed_click_link' => 'Aby potwierdzić swoje konto kliknij w link wysłany w wiadomości po rejestracji.',
+    'email_not_confirmed_resend' => 'Jeśli wiadomość do Ciebie nie dotarła możesz wysłać ją ponownie wypełniając formularz poniżej.',
+    'email_not_confirmed_resend_button' => 'Wyślij ponownie wiadomość z potwierdzeniem',
+];
\ No newline at end of file
diff --git a/resources/lang/pl/common.php b/resources/lang/pl/common.php
new file mode 100644
index 000000000..1c8963653
--- /dev/null
+++ b/resources/lang/pl/common.php
@@ -0,0 +1,59 @@
+<?php
+return [
+
+    /**
+     * Buttons
+     */
+    'cancel' => 'Anuluj',
+    'confirm' => 'Zatwierdź',
+    'back' => 'Wstecz',
+    'save' => 'Zapisz',
+    'continue' => 'Kontynuuj',
+    'select' => 'Wybierz',
+
+    /**
+     * Form Labels
+     */
+    'name' => 'Nazwa',
+    'description' => 'Opis',
+    'role' => 'Rola',
+
+    /**
+     * Actions
+     */
+    'actions' => 'Akcje',
+    'view' => 'Widok',
+    'create' => 'Utwórz',
+    'update' => 'Zaktualizuj',
+    'edit' => 'Edytuj',
+    'sort' => 'Sortuj',
+    'move' => 'Przenieś',
+    'delete' => 'Usuń',
+    'search' => 'Szukaj',
+    'search_clear' => 'Wyczyść wyszukiwanie',
+    'reset' => 'Resetuj',
+    'remove' => 'Usuń',
+    'add' => 'Dodaj',
+
+
+    /**
+     * Misc
+     */
+    'deleted_user' => 'Użytkownik usunięty',
+    'no_activity' => 'Brak aktywności do pokazania',
+    'no_items' => 'Brak elementów do wyświetlenia',
+    'back_to_top' => 'Powrót na górę',
+    'toggle_details' => 'Włącz/wyłącz szczegóły',
+
+    /**
+     * Header
+     */
+    'view_profile' => 'Zobacz profil',
+    'edit_profile' => 'Edytuj profil',
+
+    /**
+     * Email Content
+     */
+    'email_action_help' => 'Jeśli masz problem z kliknięciem przycisku ":actionText", skopiuj i wklej poniższy adres URL w nowej karcie swojej przeglądarki:',
+    'email_rights' => 'Wszelkie prawa zastrzeżone',
+];
\ No newline at end of file
diff --git a/resources/lang/pl/components.php b/resources/lang/pl/components.php
new file mode 100644
index 000000000..c1dbcd44b
--- /dev/null
+++ b/resources/lang/pl/components.php
@@ -0,0 +1,32 @@
+<?php
+return [
+
+    /**
+     * Image Manager
+     */
+    'image_select' => 'Wybór obrazka',
+    'image_all' => 'Wszystkie',
+    'image_all_title' => 'Zobacz wszystkie obrazki',
+    'image_book_title' => 'Zobacz obrazki zapisane w tej księdze',
+    'image_page_title' => 'Zobacz obrazki zapisane na tej stronie',
+    'image_search_hint' => 'Szukaj po nazwie obrazka',
+    'image_uploaded' => 'Udostępniono :uploadedDate',
+    'image_load_more' => 'Wczytaj więcej',
+    'image_image_name' => 'Nazwa obrazka',
+    'image_delete_confirm' => 'Ten obrazek jest używany na stronach poniżej, kliknij ponownie Usuń by potwierdzić usunięcie obrazka.',
+    'image_select_image' => 'Wybierz obrazek',
+    'image_dropzone' => 'Upuść obrazki tutaj lub kliknij by wybrać obrazki do udostępnienia',
+    'images_deleted' => 'Usunięte obrazki',
+    'image_preview' => 'Podgląd obrazka',
+    'image_upload_success' => 'Obrazek wysłany pomyślnie',
+    'image_update_success' => 'Szczegóły obrazka zaktualizowane pomyślnie',
+    'image_delete_success' => 'Obrazek usunięty pomyślnie',
+
+    /**
+     * Code editor
+     */
+    'code_editor' => 'Edytuj kod',
+    'code_language' => 'Język kodu',
+    'code_content' => 'Zawartość kodu',
+    'code_save' => 'Zapisz kod',
+];
\ No newline at end of file
diff --git a/resources/lang/pl/entities.php b/resources/lang/pl/entities.php
new file mode 100644
index 000000000..30e853bce
--- /dev/null
+++ b/resources/lang/pl/entities.php
@@ -0,0 +1,237 @@
+<?php
+return [
+
+    /**
+     * Shared
+     */
+    'recently_created' => 'Ostatnio utworzone',
+    'recently_created_pages' => 'Ostatnio utworzone strony',
+    'recently_updated_pages' => 'Ostatnio zaktualizowane strony',
+    'recently_created_chapters' => 'Ostatnio utworzone rozdziały',
+    'recently_created_books' => 'Ostatnio utworzone księgi',
+    'recently_update' => 'Ostatnio zaktualizowane',
+    'recently_viewed' => 'Ostatnio wyświetlane',
+    'recent_activity' => 'Ostatnia aktywność',
+    'create_now' => 'Utwórz teraz',
+    'revisions' => 'Rewizje',
+    'meta_revision' => 'Rewizja #:revisionCount',
+    'meta_created' => 'Utworzono :timeLength',
+    'meta_created_name' => 'Utworzono :timeLength przez :user',
+    'meta_updated' => 'Zaktualizowano :timeLength',
+    'meta_updated_name' => 'Zaktualizowano :timeLength przez :user',
+    'x_pages' => ':count stron',
+    'entity_select' => 'Wybór encji',
+    'images' => 'Obrazki',
+    'my_recent_drafts' => 'Moje ostatnie szkice',
+    'my_recently_viewed' => 'Moje ostatnio wyświetlane',
+    'no_pages_viewed' => 'Nie wyświetlano żadnych stron',
+    'no_pages_recently_created' => 'Nie utworzono ostatnio żadnych stron',
+    'no_pages_recently_updated' => 'Nie zaktualizowano ostatnio żadnych stron',
+    'export' => 'Eksportuj',
+    'export_html' => 'Plik HTML',
+    'export_pdf' => 'Plik PDF',
+    'export_text' => 'Plik tekstowy',
+
+    /**
+     * Permissions and restrictions
+     */
+    'permissions' => 'Uprawnienia',
+    'permissions_intro' => 'Jeśli odblokowane, te uprawnienia będą miały priorytet względem pozostałych ustawionych uprawnień ról.',
+    'permissions_enable' => 'Odblokuj własne uprawnienia',
+    'permissions_save' => 'Zapisz uprawnienia',
+
+    /**
+     * Search
+     */
+    'search_results' => 'Wyniki wyszukiwania',
+    'search_total_results_found' => ':count znalezionych wyników|:count ogółem znalezionych wyników',
+    'search_clear' => 'Wyczyść wyszukiwanie',
+    'search_no_pages' => 'Brak stron spełniających zadane kryterium',
+    'search_for_term' => 'Szukaj :term',
+    'search_more' => 'Więcej wyników',
+    'search_filters' => 'Filtry wyszukiwania',
+    'search_content_type' => 'Rodziaj treści',
+    'search_exact_matches' => 'Dokładne frazy',
+    'search_tags' => 'Tagi wyszukiwania',
+    'search_viewed_by_me' => 'Wyświetlone przeze mnie',
+    'search_not_viewed_by_me' => 'Niewyświetlone przeze mnie',
+    'search_permissions_set' => 'Zbiór uprawnień',
+    'search_created_by_me' => 'Utworzone przeze mnie',
+    'search_updated_by_me' => 'Zaktualizowane przeze mnie',
+    'search_updated_before' => 'Zaktualizowane przed',
+    'search_updated_after' => 'Zaktualizowane po',
+    'search_created_before' => 'Utworzone przed',
+    'search_created_after' => 'Utworzone po',
+    'search_set_date' => 'Ustaw datę',
+    'search_update' => 'Zaktualizuj wyszukiwanie',
+
+    /**
+     * Books
+     */
+    'book' => 'Księga',
+    'books' => 'Księgi',
+    'books_empty' => 'Brak utworzonych ksiąg',
+    'books_popular' => 'Popularne księgi',
+    'books_recent' => 'Ostatnie księgi',
+    'books_popular_empty' => 'Najbardziej popularne księgi zostaną wyświetlone w tym miejscu.',
+    'books_create' => 'Utwórz księgę',
+    'books_delete' => 'Usuń księgę',
+    'books_delete_named' => 'Usuń księgę :bookName',
+    'books_delete_explain' => 'To spowoduje usunięcie księgi \':bookName\', Wszystkie strony i rozdziały zostaną usunięte.',
+    'books_delete_confirmation' => 'Czy na pewno chcesz usunąc tę księgę?',
+    'books_edit' => 'Edytuj księgę',
+    'books_edit_named' => 'Edytuj księgę :bookName',
+    'books_form_book_name' => 'Nazwa księgi',
+    'books_save' => 'Zapisz księgę',
+    'books_permissions' => 'Uprawnienia księgi',
+    'books_permissions_updated' => 'Zaktualizowano uprawnienia księgi',
+    'books_empty_contents' => 'Brak stron lub rozdziałów w tej księdze.',
+    'books_empty_create_page' => 'Utwórz nową stronę',
+    'books_empty_or' => 'lub',
+    'books_empty_sort_current_book' => 'posortuj bieżącą księgę',
+    'books_empty_add_chapter' => 'Dodaj rozdział',
+    'books_permissions_active' => 'Uprawnienia księgi aktywne',
+    'books_search_this' => 'Wyszukaj w tej księdze',
+    'books_navigation' => 'Nawigacja po księdze',
+    'books_sort' => 'Sortuj zawartość Księgi',
+    'books_sort_named' => 'Sortuj księgę :bookName',
+    'books_sort_show_other' => 'Pokaż inne księgi',
+    'books_sort_save' => 'Zapisz nowy porządek',
+
+    /**
+     * Chapters
+     */
+    'chapter' => 'Rozdział',
+    'chapters' => 'Rozdziały',
+    'chapters_popular' => 'Popularne rozdziały',
+    'chapters_new' => 'Nowy rozdział',
+    'chapters_create' => 'Utwórz nowy rozdział',
+    'chapters_delete' => 'Usuń rozdział',
+    'chapters_delete_named' => 'Usuń rozdział :chapterName',
+    'chapters_delete_explain' => 'To spowoduje usunięcie rozdziału \':chapterName\', Wszystkie strony zostaną usunięte
+        i dodane bezpośrednio do księgi macierzystej.',
+    'chapters_delete_confirm' => 'Czy na pewno chcesz usunąć ten rozdział?',
+    'chapters_edit' => 'Edytuj rozdział',
+    'chapters_edit_named' => 'Edytuj rozdział :chapterName',
+    'chapters_save' => 'Zapisz rozdział',
+    'chapters_move' => 'Przenieś rozdział',
+    'chapters_move_named' => 'Przenieś rozdział :chapterName',
+    'chapter_move_success' => 'Rozdział przeniesiony do :bookName',
+    'chapters_permissions' => 'Uprawienia rozdziału',
+    'chapters_empty' => 'Brak stron w tym rozdziale.',
+    'chapters_permissions_active' => 'Uprawnienia rozdziału aktywne',
+    'chapters_permissions_success' => 'Zaktualizowano uprawnienia rozdziału',
+    'chapters_search_this' => 'Przeszukaj ten rozdział',
+
+    /**
+     * Pages
+     */
+    'page' => 'Strona',
+    'pages' => 'Strony',
+    'pages_popular' => 'Popularne strony',
+    'pages_new' => 'Nowa strona',
+    'pages_attachments' => 'Załączniki',
+    'pages_navigation' => 'Nawigacja po stronie',
+    'pages_delete' => 'Usuń stronę',
+    'pages_delete_named' => 'Usuń stronę :pageName',
+    'pages_delete_draft_named' => 'Usuń szkic strony :pageName',
+    'pages_delete_draft' => 'Usuń szkic strony',
+    'pages_delete_success' => 'Strona usunięta pomyślnie',
+    'pages_delete_draft_success' => 'Szkic strony usunięty pomyślnie',
+    'pages_delete_confirm' => 'Czy na pewno chcesz usunąć tę stron?',
+    'pages_delete_draft_confirm' => 'Czy na pewno chcesz usunąć szkic strony?',
+    'pages_editing_named' => 'Edytowanie strony :pageName',
+    'pages_edit_toggle_header' => 'Włącz/wyłącz nagłówek',
+    'pages_edit_save_draft' => 'Zapisz szkic',
+    'pages_edit_draft' => 'Edytuj szkic strony',
+    'pages_editing_draft' => 'Edytowanie szkicu strony',
+    'pages_editing_page' => 'Edytowanie strony',
+    'pages_edit_draft_save_at' => 'Szkic zapisany ',
+    'pages_edit_delete_draft' => 'Usuń szkic',
+    'pages_edit_discard_draft' => 'Porzuć szkic',
+    'pages_edit_set_changelog' => 'Ustaw log zmian',
+    'pages_edit_enter_changelog_desc' => 'Opisz zmiany, które zostały wprowadzone',
+    'pages_edit_enter_changelog' => 'Wyświetl log zmian',
+    'pages_save' => 'Zapisz stronę',
+    'pages_title' => 'Tytuł strony',
+    'pages_name' => 'Nazwa strony',
+    'pages_md_editor' => 'Edytor',
+    'pages_md_preview' => 'Podgląd',
+    'pages_md_insert_image' => 'Wstaw obrazek',
+    'pages_md_insert_link' => 'Wstaw łącze do encji',
+    'pages_not_in_chapter' => 'Strona nie została umieszczona w rozdziale',
+    'pages_move' => 'Przenieś stronę',
+    'pages_move_success' => 'Strona przeniesiona do ":parentName"',
+    'pages_permissions' => 'Uprawnienia strony',
+    'pages_permissions_success' => 'Zaktualizowano uprawnienia strony',
+    'pages_revisions' => 'Rewizje strony',
+    'pages_revisions_named' => 'Rewizje strony :pageName',
+    'pages_revision_named' => 'Rewizja stroony :pageName',
+    'pages_revisions_created_by' => 'Utworzona przez',
+    'pages_revisions_date' => 'Data rewizji',
+    'pages_revisions_number' => '#',
+    'pages_revisions_changelog' => 'Log zmian',
+    'pages_revisions_changes' => 'Zmiany',
+    'pages_revisions_current' => 'Obecna wersja',
+    'pages_revisions_preview' => 'Podgląd',
+    'pages_revisions_restore' => 'Przywróć',
+    'pages_revisions_none' => 'Ta strona nie posiada żadnych rewizji',
+    'pages_copy_link' => 'Kopiuj link',
+    'pages_permissions_active' => 'Uprawnienia strony aktywne',
+    'pages_initial_revision' => 'Wydanie pierwotne',
+    'pages_initial_name' => 'Nowa strona',
+    'pages_editing_draft_notification' => 'Edytujesz obecnie szkic, który był ostatnio zapisany :timeDiff.',
+    'pages_draft_edited_notification' => 'Od tego czasu ta strona była zmieniana. Zalecane jest odrzucenie tego szkicu.',
+    'pages_draft_edit_active' => [
+        'start_a' => ':count użytkowników rozpoczęło edytowanie tej strony',
+        'start_b' => ':userName edytuje stronę',
+        'time_a' => ' od czasu ostatniej edycji',
+        'time_b' => 'w ciągu ostatnich :minCount minut',
+        'message' => ':start :time. Pamiętaj by nie nadpisywać czyichś zmian!',
+    ],
+    'pages_draft_discarded' => 'Szkic odrzucony, edytor został uzupełniony najnowszą wersją strony',
+
+    /**
+     * Editor sidebar
+     */
+    'page_tags' => 'Tagi strony',
+    'tag' => 'Tag',
+    'tags' =>  '',
+    'tag_value' => 'Wartość tagu (opcjonalnie)',
+    'tags_explain' => "Dodaj tagi by skategoryzować zawartość. \n W celu dokładniejszej organizacji zawartości możesz dodać wartości do tagów.",
+    'tags_add' => 'Dodaj kolejny tag',
+    'attachments' => 'Załączniki',
+    'attachments_explain' => 'Udostępnij kilka plików lub załącz link. Będą one widoczne na marginesie strony.',
+    'attachments_explain_instant_save' => 'Zmiany są zapisywane natychmiastowo.',
+    'attachments_items' => 'Załączniki',
+    'attachments_upload' => 'Dodaj plik',
+    'attachments_link' => 'Dodaj link',
+    'attachments_set_link' => 'Ustaw link',
+    'attachments_delete_confirm' => 'Kliknij ponownie Usuń by potwierdzić usunięcie załącznika.',
+    'attachments_dropzone' => 'Upuść pliki lub kliknij tutaj by udostępnić pliki',
+    'attachments_no_files' => 'Nie udostępniono plików',
+    'attachments_explain_link' => 'Możesz załączyć link jeśli nie chcesz udostępniać pliku. Może być to link do innej strony lub link do pliku w chmurze.',
+    'attachments_link_name' => 'Nazwa linku',
+    'attachment_link' => 'Link do załącznika',
+    'attachments_link_url' => 'Link do pliku',
+    'attachments_link_url_hint' => 'Strona lub plik',
+    'attach' => 'Załącz',
+    'attachments_edit_file' => 'Edytuj plik',
+    'attachments_edit_file_name' => 'Nazwa pliku',
+    'attachments_edit_drop_upload' => 'Upuść pliki lub kliknij tutaj by udostępnić pliki i nadpisać istniejące',
+    'attachments_order_updated' => 'Kolejność załączników zaktualizowana',
+    'attachments_updated_success' => 'Szczegóły załączników zaktualizowane',
+    'attachments_deleted' => 'Załączniki usunięte',
+    'attachments_file_uploaded' => 'Plik załączony pomyślnie',
+    'attachments_file_updated' => 'Plik zaktualizowany pomyślnie',
+    'attachments_link_attached' => 'Link pomyślnie dodany do strony',
+
+    /**
+     * Profile View
+     */
+    'profile_user_for_x' => 'Użytkownik od :time',
+    'profile_created_content' => 'Utworzona zawartość',
+    'profile_not_created_pages' => ':userName nie utworzył żadnych stron',
+    'profile_not_created_chapters' => ':userName nie utworzył żadnych rozdziałów',
+    'profile_not_created_books' => ':userName nie utworzył żadnych ksiąg',
+];
\ No newline at end of file
diff --git a/resources/lang/pl/errors.php b/resources/lang/pl/errors.php
new file mode 100644
index 000000000..633bf7a2d
--- /dev/null
+++ b/resources/lang/pl/errors.php
@@ -0,0 +1,70 @@
+<?php
+
+return [
+
+    /**
+     * Error text strings.
+     */
+
+    // Permissions
+    'permission' => 'Nie masz uprawnień do wyświetlenia tej strony.',
+    'permissionJson' => 'Nie masz uprawnień do wykonania tej akcji.',
+
+    // Auth
+    'error_user_exists_different_creds' => 'Użytkownik o adresie :email już istnieje.',
+    'email_already_confirmed' => 'Email został potwierdzony, spróbuj się zalogować.',
+    'email_confirmation_invalid' => 'Ten token jest nieprawidłowy lub został już wykorzystany. Spróbuj zarejestrować się ponownie.',
+    'email_confirmation_expired' => 'Ten token potwierdzający wygasł. Wysłaliśmy Ci kolejny.',
+    'ldap_fail_anonymous' => 'Dostęp LDAP przy użyciu anonimowego powiązania nie powiódł się',
+    'ldap_fail_authed' => 'Dostęp LDAP przy użyciu tego dn i hasła nie powiódł się',
+    'ldap_extension_not_installed' => 'Rozszerzenie LDAP PHP nie zostało zainstalowane',
+    'ldap_cannot_connect' => 'Nie można połączyć z serwerem LDAP, połączenie nie zostało ustanowione',
+    'social_no_action_defined' => 'Brak zdefiniowanej akcji',
+    'social_account_in_use' => 'To konto :socialAccount jest już w użyciu, spróbuj zalogować się za pomocą opcji :socialAccount.',
+    'social_account_email_in_use' => 'Email :email jest już w użyciu. Jeśli masz już konto, połącz konto :socialAccount z poziomu ustawień profilu.',
+    'social_account_existing' => 'Konto :socialAccount jest już połączone z Twoim profilem',
+    'social_account_already_used_existing' => 'Konto :socialAccount jest już używane przez innego użytkownika.',
+    'social_account_not_used' => 'To konto :socialAccount nie jest połączone z żadnym użytkownikiem. Połącz je ze swoim kontem w ustawieniach profilu. ',
+    'social_account_register_instructions' => 'Jeśli nie masz jeszcze konta, możesz zarejestrować je używając opcji :socialAccount.',
+    'social_driver_not_found' => 'Funkcja społecznościowa nie została odnaleziona',
+    'social_driver_not_configured' => 'Ustawienia konta :socialAccount nie są poprawne.',
+
+    // System
+    'path_not_writable' => 'Zapis do ścieżki :filePath jest niemożliwy. Upewnij się że aplikacja ma prawa do zapisu w niej.',
+    'cannot_get_image_from_url' => 'Nie można pobrać obrazka z :url',
+    'cannot_create_thumbs' => 'Serwer nie może utworzyć miniaturek. Upewnij się że rozszerzenie GD PHP zostało zainstalowane.',
+    'server_upload_limit' => 'Serwer nie pozwala na przyjęcie pliku o tym rozmiarze. Spróbuj udostępnić coś o mniejszym rozmiarze.',
+    'image_upload_error' => 'Wystąpił błąd podczas udostępniania obrazka',
+
+    // Attachments
+    'attachment_page_mismatch' => 'Niezgodność stron podczas aktualizacji załącznika',
+
+    // Pages
+    'page_draft_autosave_fail' => 'Zapis szkicu nie powiódł się. Upewnij się że posiadasz połączenie z internetem.',
+
+    // Entities
+    'entity_not_found' => 'Encja nie została odnaleziona',
+    'book_not_found' => 'Księga nie została odnaleziona',
+    'page_not_found' => 'Strona nie została odnaleziona',
+    'chapter_not_found' => 'Rozdział nie został odnaleziony',
+    'selected_book_not_found' => 'Wybrana księga nie została odnaleziona',
+    'selected_book_chapter_not_found' => 'Wybrana księga lub rozdział nie zostały odnalezione',
+    'guests_cannot_save_drafts' => 'Goście nie mogą zapisywać szkiców',
+
+    // Users
+    'users_cannot_delete_only_admin' => 'Nie możesz usunąć jedynego administratora',
+    'users_cannot_delete_guest' => 'Nie możesz usunąć użytkownika-gościa',
+
+    // Roles
+    'role_cannot_be_edited' => 'Ta rola nie może być edytowana',
+    'role_system_cannot_be_deleted' => 'Ta rola jest rolą systemową i nie może zostać usunięta',
+    'role_registration_default_cannot_delete' => 'Ta rola nie może zostać usunięta jeśli jest ustawiona jako domyślna rola użytkownika',
+
+    // Error pages
+    '404_page_not_found' => 'Strona nie została odnaleziona',
+    'sorry_page_not_found' => 'Przepraszamy, ale strona której szukasz nie została odnaleziona.',
+    'return_home' => 'Powrót do strony głównej',
+    'error_occurred' => 'Wystąpił błąd',
+    'app_down' => ':appName jest aktualnie wyłączona',
+    'back_soon' => 'Niedługo zostanie uruchomiona ponownie.',
+];
\ No newline at end of file
diff --git a/resources/lang/pl/pagination.php b/resources/lang/pl/pagination.php
new file mode 100644
index 000000000..564694190
--- /dev/null
+++ b/resources/lang/pl/pagination.php
@@ -0,0 +1,19 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Pagination Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used by the paginator library to build
+    | the simple pagination links. You are free to change them to anything
+    | you want to customize your views to better match your application.
+    |
+    */
+
+    'previous' => '&laquo; Poprzednia',
+    'next'     => 'Następna &raquo;',
+
+];
diff --git a/resources/lang/pl/passwords.php b/resources/lang/pl/passwords.php
new file mode 100644
index 000000000..a9e669f4d
--- /dev/null
+++ b/resources/lang/pl/passwords.php
@@ -0,0 +1,22 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Password Reminder Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are the default lines which match reasons
+    | that are given by the password broker for a password update attempt
+    | has failed, such as for an invalid token or invalid new password.
+    |
+    */
+
+    'password' => 'Hasło musi zawierać co najmniej 6 znaków i być zgodne z powtórzeniem.',
+    'user' => "Nie znaleziono użytkownika o takim adresie email.",
+    'token' => 'Ten token resetowania hasła jest nieprawidłowy.',
+    'sent' => 'Wysłaliśmy Ci link do resetowania hasła!',
+    'reset' => 'Twoje hasło zostało zresetowane!',
+
+];
diff --git a/resources/lang/pl/settings.php b/resources/lang/pl/settings.php
new file mode 100644
index 000000000..381e5517a
--- /dev/null
+++ b/resources/lang/pl/settings.php
@@ -0,0 +1,111 @@
+<?php
+
+return [
+
+    /**
+     * Settings text strings
+     * Contains all text strings used in the general settings sections of BookStack
+     * including users and roles.
+     */
+
+    'settings' => 'Ustawienia',
+    'settings_save' => 'Zapisz ustawienia',
+    'settings_save_success' => 'Ustawienia zapisane',
+
+    /**
+     * App settings
+     */
+
+    'app_settings' => 'Ustawienia aplikacji',
+    'app_name' => 'Nazwa aplikacji',
+    'app_name_desc' => 'Ta nazwa jest wyświetlana w nagłówku i emailach.',
+    'app_name_header' => 'Pokazać nazwę aplikacji w nagłówku?',
+    'app_public_viewing' => 'Zezwolić na publiczne przeglądanie?',
+    'app_secure_images' => 'Odblokować wyższe bezpieczeństwo obrazków?',
+    'app_secure_images_desc' => 'Ze względów wydajnościowych wszystkie obrazki są publiczne. Ta opcja dodaje dodatkowy, trudny do zgadnienia losowy ciąg na początku nazwy obrazka. Upewnij się że indeksowanie ścieżek jest zablokowane, by uniknąć problemów z dostępem do obrazka.',
+    'app_editor' => 'Edytor strony',
+    'app_editor_desc' => 'Wybierz edytor używany przez użytkowników do edycji zawartości.',
+    'app_custom_html' => 'Własna zawartość tagu <head>',
+    'app_custom_html_desc' => 'Zawartość dodana tutaj zostanie dołączona do sekcji <head> każdej strony. Przydatne przy nadpisywaniu styli lub dodawaniu analityki.',
+    'app_logo' => 'Logo aplikacji',
+    'app_logo_desc' => 'Ten obrazek powinien mieć nie więcej niż 43px w pionie. <br>Większe obrazki będą skalowane w dół.',
+    'app_primary_color' => 'Podstawowy kolor aplikacji',
+    'app_primary_color_desc' => 'To powinna być wartość HEX. <br>Zostaw to pole puste, by powrócić do podstawowego koloru.',
+
+    /**
+     * Registration settings
+     */
+
+    'reg_settings' => 'Ustawienia rejestracji',
+    'reg_allow' => 'Zezwolić na rejestrację?',
+    'reg_default_role' => 'Domyślna rola użytkownika po rejestracji',
+    'reg_confirm_email' => 'Wymagać potwierdzenia adresu email?',
+    'reg_confirm_email_desc' => 'Jeśli restrykcje domenowe zostały uzupełnione potwierdzenie adresu stanie się konieczne, a poniższa wartośc zostanie zignorowana.',
+    'reg_confirm_restrict_domain' => 'Restrykcje domenowe dot. adresu email',
+    'reg_confirm_restrict_domain_desc' => 'Wprowadź listę domen adresów email rozdzieloną przecinkami, którym chciałbyś zezwolić na rejestrację. Wymusi to konieczność potwierdzenia adresu email przez użytkownika przed uzyskaniem dostępu do aplikacji. <br> Pamiętaj, że użytkownicy będą mogli zmienić adres email po rejestracji.',
+    'reg_confirm_restrict_domain_placeholder' => 'Brak restrykcji',
+
+    /**
+     * Role settings
+     */
+
+    'roles' => 'Role',
+    'role_user_roles' => 'Role użytkownika',
+    'role_create' => 'Utwórz nową rolę',
+    'role_create_success' => 'Rola utworzona pomyślnie',
+    'role_delete' => 'Usuń rolę',
+    'role_delete_confirm' => 'To spowoduje usunięcie roli \':roleName\'.',
+    'role_delete_users_assigned' => 'Tę rolę ma przypisanych :userCount użytkowników. Jeśli chcesz zmigrować użytkowników z tej roli, wybierz nową poniżej.',
+    'role_delete_no_migration' => "Nie migruj użytkowników",
+    'role_delete_sure' => 'Czy na pewno chcesz usunąć tę rolę?',
+    'role_delete_success' => 'Rola usunięta pomyślnie',
+    'role_edit' => 'Edytuj rolę',
+    'role_details' => 'Szczegóły roli',
+    'role_name' => 'Nazwa roli',
+    'role_desc' => 'Krótki opis roli',
+    'role_system' => 'Uprawnienia systemowe',
+    'role_manage_users' => 'Zarządzanie użytkownikami',
+    'role_manage_roles' => 'Zarządzanie rolami i uprawnieniami ról',
+    'role_manage_entity_permissions' => 'Zarządzanie uprawnieniami ksiąg, rozdziałów i stron',
+    'role_manage_own_entity_permissions' => 'Zarządzanie uprawnieniami własnych ksiąg, rozdziałów i stron',
+    'role_manage_settings' => 'Zarządzanie ustawieniami aplikacji',
+    'role_asset' => 'Zarządzanie zasobami',
+    'role_asset_desc' => 'Te ustawienia kontrolują zarządzanie zasobami systemu. Uprawnienia ksiąg, rozdziałów i stron nadpisują te ustawienia.',
+    'role_all' => 'Wszyscy',
+    'role_own' => 'Własne',
+    'role_controlled_by_asset' => 'Kontrolowane przez zasób, do którego zostały udostępnione',
+    'role_save' => 'Zapisz rolę',
+    'role_update_success' => 'Rola zapisana pomyślnie',
+    'role_users' => 'Użytkownicy w tej roli',
+    'role_users_none' => 'Brak użytkowników zapisanych do tej roli',
+
+    /**
+     * Users
+     */
+
+    'users' => 'Użytkownicy',
+    'user_profile' => 'Profil użytkownika',
+    'users_add_new' => 'Dodaj użytkownika',
+    'users_search' => 'Wyszukaj użytkownika',
+    'users_role' => 'Role użytkownika',
+    'users_external_auth_id' => 'Zewnętrzne ID autentykacji',
+    'users_password_warning' => 'Wypełnij poniżej tylko jeśli chcesz zmienić swoje hasło:',
+    'users_system_public' => 'Ten użytkownik reprezentuje każdego gościa odwiedzającego tę aplikację. Nie można się na niego zalogować, lecz jest przyznawany automatycznie.',
+    'users_delete' => 'Usuń użytkownika',
+    'users_delete_named' => 'Usuń :userName',
+    'users_delete_warning' => 'To usunie użytkownika \':userName\' z systemu.',
+    'users_delete_confirm' => 'Czy na pewno chcesz usunąć tego użytkownika?',
+    'users_delete_success' => 'Użytkownik usunięty pomyślnie',
+    'users_edit' => 'Edytuj użytkownika',
+    'users_edit_profile' => 'Edytuj profil',
+    'users_edit_success' => 'Użytkownik zaktualizowany pomyśłnie',
+    'users_avatar' => 'Avatar użytkownika',
+    'users_avatar_desc' => 'Ten obrazek powinien mieć 25px x 256px.',
+    'users_preferred_language' => 'Preferowany język',
+    'users_social_accounts' => 'Konta społecznościowe',
+    'users_social_accounts_info' => 'Tutaj możesz połączyć kilka kont społecznościowych w celu łatwiejszego i szybszego logowania.',
+    'users_social_connect' => 'Podłącz konto',
+    'users_social_disconnect' => 'Odłącz konto',
+    'users_social_connected' => ':socialAccount zostało dodane do Twojego profilu.',
+    'users_social_disconnected' => ':socialAccount zostało odłączone od Twojego profilu.',
+];
diff --git a/resources/lang/pl/validation.php b/resources/lang/pl/validation.php
new file mode 100644
index 000000000..6a7c13e80
--- /dev/null
+++ b/resources/lang/pl/validation.php
@@ -0,0 +1,108 @@
+<?php
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines contain the default error messages used by
+    | the validator class. Some of these rules have multiple versions such
+    | as the size rules. Feel free to tweak each of these messages here.
+    |
+    */
+
+    'accepted'             => ':attribute musi zostać zaakceptowany.',
+    'active_url'           => ':attribute nie jest prawidłowym adresem URL.',
+    'after'                => ':attribute musi być datą następującą po :date.',
+    'alpha'                => ':attribute może zawierać wyłącznie litery.',
+    'alpha_dash'           => ':attribute może zawierać wyłącznie litery, cyfry i myślniki.',
+    'alpha_num'            => ':attribute może zawierać wyłącznie litery i cyfry.',
+    'array'                => ':attribute musi być tablicą.',
+    'before'               => ':attribute musi być datą poprzedzającą :date.',
+    'between'              => [
+        'numeric' => ':attribute musi zawierać się w przedziale od :min do :max.',
+        'file'    => 'Waga :attribute musi zawierać się pomiędzy :min i :max kilobajtów.',
+        'string'  => 'Długość :attribute musi zawierać się pomiędzy :min i :max.',
+        'array'   => ':attribute musi mieć od :min do :max elementów.',
+    ],
+    'boolean'              => ':attribute musi być wartością prawda/fałsz.',
+    'confirmed'            => ':attribute i potwierdzenie muszą być zgodne.',
+    'date'                 => ':attribute nie jest prawidłową datą.',
+    'date_format'          => ':attribute musi mieć format :format.',
+    'different'            => ':attribute i :other muszą się różnić.',
+    'digits'               => ':attribute musi mieć :digits cyfr.',
+    'digits_between'       => ':attribute musi mieć od :min do :max cyfr.',
+    'email'                => ':attribute musi być prawidłowym adresem e-mail.',
+    'filled'               => ':attribute jest wymagany.',
+    'exists'               => 'Wybrana wartość :attribute jest nieprawidłowa.',
+    'image'                => ':attribute musi być obrazkiem.',
+    'in'                   => 'Wybrana wartość :attribute jest nieprawidłowa.',
+    'integer'              => ':attribute musi być liczbą całkowitą.',
+    'ip'                   => ':attribute musi być prawidłowym adresem IP.',
+    'max'                  => [
+        'numeric' => 'Wartość :attribute nie może być większa niż :max.',
+        'file'    => 'Wielkość :attribute nie może być większa niż :max kilobajtów.',
+        'string'  => 'Długość :attribute nie może być większa niż :max znaków.',
+        'array'   => 'Rozmiar :attribute nie może być większy niż :max elementów.',
+    ],
+    'mimes'                => ':attribute musi być plikiem typu: :values.',
+    'min'                  => [
+        'numeric' => 'Wartość :attribute nie może być mniejsza od :min.',
+        'file'    => 'Wielkość :attribute nie może być mniejsza niż :min kilobajtów.',
+        'string'  => 'Długość :attribute nie może być mniejsza niż :min znaków.',
+        'array'   => 'Rozmiar :attribute musi posiadać co najmniej :min elementy.',
+    ],
+    'not_in'               => 'Wartość :attribute jest nieprawidłowa.',
+    'numeric'              => ':attribute musi być liczbą.',
+    'regex'                => 'Format :attribute jest nieprawidłowy.',
+    'required'             => 'Pole :attribute jest wymagane.',
+    'required_if'          => 'Pole :attribute jest wymagane jeśli :other ma wartość :value.',
+    'required_with'        => 'Pole :attribute jest wymagane jeśli :values zostało wprowadzone.',
+    'required_with_all'    => 'Pole :attribute jest wymagane jeśli :values są obecne.',
+    'required_without'     => 'Pole :attribute jest wymagane jeśli :values nie zostało wprowadzone.',
+    'required_without_all' => 'Pole :attribute jest wymagane jeśli żadna z wartości :values nie została podana.',
+    'same'                 => 'Pole :attribute i :other muszą być takie same.',
+    'size'                 => [
+        'numeric' => ':attribute musi mieć długość :size.',
+        'file'    => ':attribute musi mieć :size kilobajtów.',
+        'string'  => ':attribute mmusi mieć długość :size znaków.',
+        'array'   => ':attribute musi posiadać :size elementów.',
+    ],
+    'string'               => ':attribute musi być ciągiem znaków.',
+    'timezone'             => ':attribute musi być prawidłową strefą czasową.',
+    'unique'               => ':attribute zostało już zajęte.',
+    'url'                  => 'Format :attribute jest nieprawidłowy.',
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Language Lines
+    |--------------------------------------------------------------------------
+    |
+    | Here you may specify custom validation messages for attributes using the
+    | convention "attribute.rule" to name the lines. This makes it quick to
+    | specify a specific custom language line for a given attribute rule.
+    |
+    */
+
+    'custom' => [
+        'password-confirm' => [
+            'required_with' => 'Potwierdzenie hasła jest wymagane.',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Custom Validation Attributes
+    |--------------------------------------------------------------------------
+    |
+    | The following language lines are used to swap attribute place-holders
+    | with something more reader friendly such as E-Mail Address instead
+    | of "email". This simply helps us make messages a little cleaner.
+    |
+    */
+
+    'attributes' => [],
+
+];
diff --git a/resources/lang/pt_BR/entities.php b/resources/lang/pt_BR/entities.php
index 5a965fe62..e6b900fdd 100644
--- a/resources/lang/pt_BR/entities.php
+++ b/resources/lang/pt_BR/entities.php
@@ -214,4 +214,26 @@ return [
     'profile_not_created_pages' => ':userName não criou páginas',
     'profile_not_created_chapters' => ':userName não criou capítulos',
     'profile_not_created_books' => ':userName não criou livros',
+
+    /**
+     * Comments
+     */
+    'comentário' => 'Comentário',
+    'comentários' => 'Comentários',
+    'comment_placeholder' => 'Digite seus comentários aqui, markdown suportado ...',
+    'no_comments' => 'No Comments',
+    'x_comments' => ':numComments Comentários',
+    'one_comment' => '1 comentário',
+    'comments_loading' => 'Carregando ....',
+    'comment_save' => 'Salvar comentário',
+    'comment_reply' => 'Responder',
+    'comment_edit' => 'Editar',
+    'comment_delete' => 'Excluir',
+    'comment_cancel' => 'Cancelar',
+    'comment_created' => 'Comentário adicionado',
+    'comment_updated' => 'Comentário atualizado',
+    'comment_deleted' => 'Comentário eliminado',
+    'comment_updated_text' => 'Atualizado :updatedDiff atrás por',
+    'comment_delete_confirm' => 'Isso removerá o conteúdo do comentário. Tem certeza de que deseja excluir esse comentário?',
+    'comment_create' => 'Criada'
 ];
\ No newline at end of file
diff --git a/resources/lang/pt_BR/errors.php b/resources/lang/pt_BR/errors.php
index 91b85e3ef..16fc78ff5 100644
--- a/resources/lang/pt_BR/errors.php
+++ b/resources/lang/pt_BR/errors.php
@@ -67,4 +67,11 @@ return [
     'error_occurred' => 'Um erro ocorreu',
     'app_down' => ':appName está fora do ar no momento',
     'back_soon' => 'Voltaremos em seguida.',
+
+    // comments
+    'comment_list' => 'Ocorreu um erro ao buscar os comentários.',
+    'cannot_add_comment_to_draft' => 'Você não pode adicionar comentários a um rascunho.',
+    'comment_add' => 'Ocorreu um erro ao adicionar o comentário.',
+    'comment_delete' => 'Ocorreu um erro ao excluir o comentário.',
+    'empty_comment' => 'Não é possível adicionar um comentário vazio.',
 ];
\ No newline at end of file
diff --git a/resources/lang/sk/entities.php b/resources/lang/sk/entities.php
index e70864753..7c8f34368 100644
--- a/resources/lang/sk/entities.php
+++ b/resources/lang/sk/entities.php
@@ -223,4 +223,26 @@ return [
     'profile_not_created_pages' => ':userName nevytvoril žiadne stránky',
     'profile_not_created_chapters' => ':userName nevytvoril žiadne kapitoly',
     'profile_not_created_books' => ':userName nevytvoril žiadne knihy',
+
+    /**
+     * Comments
+     */
+    'comment' => 'Komentár',
+    'comments' => 'Komentáre',
+    'comment_placeholder' => 'Tu zadajte svoje pripomienky, podporované označenie ...',
+    'no_comments' => 'No Comments',
+    'x_comments' => ':numComments komentárov',
+    'one_comment' => '1 komentár',
+    'comments_loading' => 'Loading ..',
+    'comment_save' => 'Uložiť komentár',
+    'comment_reply' => 'Odpovedať',
+    'comment_edit' => 'Upraviť',
+    'comment_delete' => 'Odstrániť',
+    'comment_cancel' => 'Zrušiť',
+    'comment_created' => 'Pridaný komentár',
+    'comment_updated' => 'Komentár aktualizovaný',
+    'comment_deleted' => 'Komentár bol odstránený',
+    'comment_updated_text' => 'Aktualizované pred :updateDiff',
+    'comment_delete_confirm' => 'Tým sa odstráni obsah komentára. Naozaj chcete odstrániť tento komentár?',
+    'comment_create' => 'Vytvorené'
 ];
diff --git a/resources/lang/sk/errors.php b/resources/lang/sk/errors.php
index e3420852a..d4c7b7a3a 100644
--- a/resources/lang/sk/errors.php
+++ b/resources/lang/sk/errors.php
@@ -67,4 +67,11 @@ return [
     'error_occurred' => 'Nastala chyba',
     'app_down' => ':appName je momentálne nedostupná',
     'back_soon' => 'Čoskoro bude opäť dostupná.',
+
+    // comments
+    'comment_list' => 'Pri načítaní komentárov sa vyskytla chyba',
+    'cannot_add_comment_to_draft' => 'Do konceptu nemôžete pridávať komentáre.',
+    'comment_add' => 'Počas pridávania komentára sa vyskytla chyba',
+    'comment_delete' => 'Pri odstraňovaní komentára došlo k chybe',
+    'empty_comment' => 'Nelze pridať prázdny komentár.',
 ];
diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php
index 95a9d72b0..1c972e4fb 100644
--- a/resources/views/base.blade.php
+++ b/resources/views/base.blade.php
@@ -36,7 +36,7 @@
     <header id="header">
         <div class="container">
             <div class="row">
-                <div class="col-lg-4 col-sm-4" ng-non-bindable>
+                <div class="col-sm-4" ng-non-bindable>
                     <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">
@@ -46,14 +46,14 @@
                         @endif
                     </a>
                 </div>
-                <div class="col-lg-4 col-sm-3 text-center">
-                    <form action="{{ baseUrl('/search') }}" method="GET" class="search-box">
-                        <input id="header-search-box-input" type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
-                        <button id="header-search-box-button" type="submit" class="text-button"><i class="zmdi zmdi-search"></i></button>
-                    </form>
-                </div>
-                <div class="col-lg-4 col-sm-5">
+                <div class="col-sm-8">
                     <div class="float right">
+                        <div class="header-search">
+                            <form action="{{ baseUrl('/search') }}" method="GET" class="search-box">
+                                <button id="header-search-box-button" type="submit"><i class="zmdi zmdi-search"></i> </button>
+                                <input id="header-search-box-input" type="text" name="term" tabindex="2" placeholder="{{ trans('common.search') }}" value="{{ isset($searchTerm) ? $searchTerm : '' }}">
+                            </form>
+                        </div>
                         <div class="links text-center">
                             <a href="{{ baseUrl('/books') }}"><i class="zmdi zmdi-book"></i>{{ trans('entities.books') }}</a>
                             @if(signedInUser() && userCan('settings-manage'))
@@ -77,7 +77,7 @@
         @yield('content')
     </section>
 
-    <div id="back-to-top">
+    <div back-to-top>
         <div class="inner">
             <i class="zmdi zmdi-chevron-up"></i> <span>{{ trans('common.back_to_top') }}</span>
         </div>
diff --git a/resources/views/books/list-item.blade.php b/resources/views/books/list-item.blade.php
index 605841f7f..92d0f9e2d 100644
--- a/resources/views/books/list-item.blade.php
+++ b/resources/views/books/list-item.blade.php
@@ -1,8 +1,10 @@
 <div class="book entity-list-item"  data-entity-type="book" data-entity-id="{{$book->id}}">
     <h4 class="text-book"><a class="text-book entity-list-item-link" href="{{$book->getUrl()}}"><i class="zmdi zmdi-book"></i><span class="entity-list-item-name">{{$book->name}}</span></a></h4>
-    @if(isset($book->searchSnippet))
-        <p class="text-muted">{!! $book->searchSnippet !!}</p>
-    @else
-        <p class="text-muted">{{ $book->getExcerpt() }}</p>
-    @endif
+    <div class="entity-item-snippet">
+        @if(isset($book->searchSnippet))
+            <p class="text-muted">{!! $book->searchSnippet !!}</p>
+        @else
+            <p class="text-muted">{{ $book->getExcerpt() }}</p>
+        @endif
+    </div>
 </div>
\ No newline at end of file
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php
index ddbe7a0a4..97942ee67 100644
--- a/resources/views/books/show.blade.php
+++ b/resources/views/books/show.blade.php
@@ -5,10 +5,10 @@
     <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
-                <div class="col-sm-6 faded">
+                <div class="col-sm-6 col-xs-1  faded">
                     @include('books._breadcrumbs', ['book' => $book])
                 </div>
-                <div class="col-sm-6">
+                <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"><i class="zmdi zmdi-open-in-new"></i>{{ trans('entities.export') }}</div>
@@ -50,13 +50,13 @@
     </div>
 
 
-    <div class="container" id="entity-dashboard" entity-id="{{ $book->id }}" entity-type="book">
+    <div ng-non-bindable class="container" id="entity-dashboard" entity-id="{{ $book->id }}" entity-type="book">
         <div class="row">
             <div class="col-md-7">
 
                 <h1>{{$book->name}}</h1>
-                <div class="book-content" v-if="!searching">
-                    <p class="text-muted" v-pre>{{$book->description}}</p>
+                <div class="book-content" v-show="!searching">
+                    <p class="text-muted" v-pre>{!! nl2br(e($book->description)) !!}</p>
 
                     <div class="page-list" v-pre>
                         <hr>
@@ -87,7 +87,7 @@
                         @include('partials.entity-meta', ['entity' => $book])
                     </div>
                 </div>
-                <div class="search-results" v-cloak v-if="searching">
+                <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"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3>
                     <div v-if="!searchResults">
                         @include('partials/loading-icon')
@@ -111,14 +111,12 @@
                     </p>
                 @endif
 
-                <div class="search-box">
-                    <form v-on:submit="searchBook">
-                        <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}">
-                        <button type="submit"><i class="zmdi zmdi-search"></i></button>
-                        <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
-                    </form>
-                </div>
-                
+                <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"><i class="zmdi zmdi-search"></i></button>
+                    <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
+                </form>
+
                 <div class="activity">
                     <h3>{{ trans('entities.recent_activity') }}</h3>
                     @include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)])
@@ -127,4 +125,4 @@
         </div>
     </div>
 
-@stop
\ No newline at end of file
+@stop
diff --git a/resources/views/chapters/list-item.blade.php b/resources/views/chapters/list-item.blade.php
index 8487a63a3..1572f0d9b 100644
--- a/resources/views/chapters/list-item.blade.php
+++ b/resources/views/chapters/list-item.blade.php
@@ -2,7 +2,7 @@
     <h4>
         @if (isset($showPath) && $showPath)
             <a href="{{ $chapter->book->getUrl() }}" class="text-book">
-                <i class="zmdi zmdi-book"></i>{{ $chapter->book->name }}
+                <i class="zmdi zmdi-book"></i>{{ $chapter->book->getShortName() }}
             </a>
             <span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
         @endif
@@ -10,14 +10,18 @@
             <i class="zmdi zmdi-collection-bookmark"></i><span class="entity-list-item-name">{{ $chapter->name }}</span>
         </a>
     </h4>
-    @if(isset($chapter->searchSnippet))
-        <p class="text-muted">{!! $chapter->searchSnippet !!}</p>
-    @else
-        <p class="text-muted">{{ $chapter->getExcerpt() }}</p>
-    @endif
+
+    <div class="entity-item-snippet">
+        @if(isset($chapter->searchSnippet))
+            <p class="text-muted">{!! $chapter->searchSnippet !!}</p>
+        @else
+            <p class="text-muted">{{ $chapter->getExcerpt() }}</p>
+        @endif
+    </div>
+
 
     @if(!isset($hidePages) && count($chapter->pages) > 0)
-        <p class="text-muted chapter-toggle"><i class="zmdi zmdi-caret-right"></i> <i class="zmdi zmdi-file-text"></i> <span>{{ trans('entities.x_pages', ['count' => $chapter->pages->count()]) }}</span></p>
+        <p chapter-toggle class="text-muted"><i class="zmdi zmdi-caret-right"></i> <i class="zmdi zmdi-file-text"></i> <span>{{ trans('entities.x_pages', ['count' => $chapter->pages->count()]) }}</span></p>
         <div class="inset-list">
             @foreach($chapter->pages as $page)
                 <h5 class="@if($page->draft) draft @endif"><a href="{{ $page->getUrl() }}" class="text-page @if($page->draft) draft @endif"><i class="zmdi zmdi-file-text"></i>{{$page->name}}</a></h5>
diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php
index d4126cbcc..62a7eaa74 100644
--- a/resources/views/chapters/show.blade.php
+++ b/resources/views/chapters/show.blade.php
@@ -5,10 +5,10 @@
     <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
-                <div class="col-sm-8 faded" ng-non-bindable>
+                <div class="col-sm-6 col-xs-3 faded" ng-non-bindable>
                     @include('chapters._breadcrumbs', ['chapter' => $chapter])
                 </div>
-                <div class="col-sm-4 faded">
+                <div class="col-sm-6 col-xs-9 faded">
                     <div class="action-buttons">
                         <span dropdown class="dropdown-container">
                             <div dropdown-toggle class="text-button text-primary"><i class="zmdi zmdi-open-in-new"></i>{{ trans('entities.export') }}</div>
@@ -47,12 +47,12 @@
     </div>
 
 
-    <div class="container" id="entity-dashboard" entity-id="{{ $chapter->id }}" entity-type="chapter">
+    <div class="container" id="entity-dashboard" ng-non-bindable entity-id="{{ $chapter->id }}" entity-type="chapter">
         <div class="row">
             <div class="col-md-7">
                 <h1>{{ $chapter->name }}</h1>
-                <div class="chapter-content" v-if="!searching">
-                    <p class="text-muted">{{ $chapter->description }}</p>
+                <div class="chapter-content" v-show="!searching">
+                    <p class="text-muted">{!! nl2br(e($chapter->description)) !!}</p>
 
                     @if(count($pages) > 0)
                         <div class="page-list">
@@ -82,7 +82,7 @@
                     @include('partials.entity-meta', ['entity' => $chapter])
                 </div>
 
-                <div class="search-results" v-cloak v-if="searching">
+                <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"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3>
                     <div v-if="!searchResults">
                         @include('partials/loading-icon')
@@ -115,13 +115,11 @@
                     </div>
                 @endif
 
-                <div class="search-box">
-                    <form v-on:submit="searchBook">
-                        <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.chapters_search_this') }}">
-                        <button type="submit"><i class="zmdi zmdi-search"></i></button>
-                        <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
-                    </form>
-                </div>
+                <form v-on:submit.prevent="searchBook" class="search-box">
+                    <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.chapters_search_this') }}">
+                    <button type="submit"><i class="zmdi zmdi-search"></i></button>
+                    <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button>
+                </form>
 
                 @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree])
 
diff --git a/resources/views/comments/comment-reply.blade.php b/resources/views/comments/comment-reply.blade.php
new file mode 100644
index 000000000..02535341c
--- /dev/null
+++ b/resources/views/comments/comment-reply.blade.php
@@ -0,0 +1,12 @@
+<div class="comment-editor" ng-controller="CommentReplyController as vm" ng-cloak>
+    <form novalidate>
+        <textarea name="markdown" rows="3" ng-model="comment.text" placeholder="{{ trans('entities.comment_placeholder') }}"></textarea>
+        <input type="hidden" ng-model="comment.pageId" name="comment.pageId" value="{{$pageId}}" ng-init="comment.pageId = {{$pageId }}">
+        <button type="button" ng-if="::(isReply || isEdit)" class="button muted" ng-click="closeBox()">{{ trans('entities.comment_cancel') }}</button>
+        <button type="submit" class="button pos" ng-click="vm.saveComment()">{{ trans('entities.comment_save') }}</button>
+    </form>
+</div>
+
+@if($errors->has('markdown'))
+    <div class="text-neg text-small">{{ $errors->first('markdown') }}</div>
+@endif
\ No newline at end of file
diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php
new file mode 100644
index 000000000..ffa75cfed
--- /dev/null
+++ b/resources/views/comments/comments.blade.php
@@ -0,0 +1,18 @@
+<script type="text/ng-template" id="comment-list-item.html">
+    @include('comments/list-item')
+</script>
+<script type="text/ng-template" id="comment-reply.html">
+    @include('comments/comment-reply', ['pageId' => $pageId])
+</script>
+<div ng-controller="CommentListController as vm" ng-init="pageId = <?= $page->id ?>" class="comments-list" ng-cloak>
+<h3>@{{vm.totalCommentsStr}}</h3>
+<hr>
+    <div class="comment-box" ng-repeat="comment in vm.comments track by comment.id">
+        <div ng-include src="'comment-list-item.html'">
+
+        </div>
+    </div>
+    <div ng-if="::vm.canComment()">
+        @include('comments/comment-reply', ['pageId' => $pageId])
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/comments/list-item.blade.php b/resources/views/comments/list-item.blade.php
new file mode 100644
index 000000000..f274d2ed2
--- /dev/null
+++ b/resources/views/comments/list-item.blade.php
@@ -0,0 +1,30 @@
+<div class='page-comment' id="comment-@{{::pageId}}-@{{::comment.id}}">
+    <div class="user-image">
+        <img ng-src="@{{::comment.created_by.avatar_url}}" alt="user avatar">
+    </div>
+    <div class="comment-container">
+        <div class="comment-header">
+            <a href="@{{::comment.created_by.profile_url}}">@{{ ::comment.created_by.name }}</a>
+        </div>
+        <div ng-bind-html="comment.html" ng-if="::comment.active" class="comment-body" ng-class="!comment.active ? 'comment-inactive' : ''">
+
+        </div>
+        <div ng-if="::!comment.active" class="comment-body comment-inactive">
+            {{ trans('entities.comment_deleted') }}
+        </div>
+        <div class="comment-actions">
+            <ul ng-if="!comment.is_hidden">
+                <li ng-if="::(level < 3 && vm.canComment())"><a href="#" comment-reply-link no-comment-reply-dupe="true" comment="comment" is-reply="true">{{ trans('entities.comment_reply') }}</a></li>
+                <li ng-if="::vm.canEditDelete(comment, 'comment_update')"><a href="#" comment-reply-link no-comment-reply-dupe="true" comment="comment" >{{ trans('entities.comment_edit') }}</a></li>
+                <li ng-if="::vm.canEditDelete(comment, 'comment_delete')"><a href="#" comment-delete-link comment="comment" >{{ trans('entities.comment_delete') }}</a></li>
+                <li>{{ trans('entities.comment_create') }} <a title="@{{::comment.created.day_time_str}}" href="#?cm=comment-@{{::pageId}}-@{{::comment.id}}">@{{::comment.created.diff}}</a></li>
+                <li ng-if="::comment.updated"><span title="@{{::comment.updated.day_time_str}}">@{{ ::vm.trans('entities.comment_updated_text', { updateDiff: comment.updated.diff }) }}
+                        <a href="@{{::comment.updated_by.profile_url}}">@{{::comment.updated_by.name}}</a></span></li>
+            </ul>
+        </div>
+        <div class="comment-box" ng-repeat="comment in comments = comment.sub_comments track by comment.id" ng-init="level = level + 1">
+            <div ng-include src="'comment-list-item.html'">
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/components/code-editor.blade.php b/resources/views/components/code-editor.blade.php
index 5a385ef49..5788bd7f7 100644
--- a/resources/views/components/code-editor.blade.php
+++ b/resources/views/components/code-editor.blade.php
@@ -1,10 +1,10 @@
 <div id="code-editor">
-    <div class="overlay" ref="overlay" v-cloak @click="hide()">
+    <div overlay ref="overlay" v-cloak @click="hide()">
         <div class="popup-body" @click.stop>
 
             <div class="popup-header primary-background">
                 <div class="popup-title">{{ trans('components.code_editor') }}</div>
-                <button class="popup-close neg corner-button button" @click="hide()">x</button>
+                <button class="overlay-close neg corner-button button" @click="hide()">x</button>
             </div>
 
             <div class="padded">
diff --git a/resources/views/components/entity-selector-popup.blade.php b/resources/views/components/entity-selector-popup.blade.php
index 1c4d1fadb..39d25bfa6 100644
--- a/resources/views/components/entity-selector-popup.blade.php
+++ b/resources/views/components/entity-selector-popup.blade.php
@@ -1,9 +1,9 @@
 <div id="entity-selector-wrap">
-    <div class="overlay" entity-link-selector>
+    <div overlay entity-link-selector>
         <div class="popup-body small flex-child">
             <div class="popup-header primary-background">
                 <div class="popup-title">{{ trans('entities.entity_select') }}</div>
-                <button type="button" class="corner-button neg button popup-close">x</button>
+                <button type="button" class="corner-button neg button overlay-close">x</button>
             </div>
             @include('components.entity-selector', ['name' => 'entity-selector'])
             <div class="popup-footer">
diff --git a/resources/views/components/image-manager.blade.php b/resources/views/components/image-manager.blade.php
index 39f3bcd3c..a4612a4ac 100644
--- a/resources/views/components/image-manager.blade.php
+++ b/resources/views/components/image-manager.blade.php
@@ -1,91 +1,89 @@
-<div id="image-manager" image-type="{{ $imageType }}" ng-controller="ImageManagerController" uploaded-to="{{ $uploaded_to or 0 }}">
-    <div class="overlay" ng-cloak ng-click="hide()">
-        <div class="popup-body" ng-click="$event.stopPropagation()">
+<div id="image-manager" image-type="{{ $imageType }}" uploaded-to="{{ $uploaded_to or 0 }}">
+    <div overlay v-cloak>
+        <div class="popup-body" @click.stop="">
 
             <div class="popup-header primary-background">
                 <div class="popup-title">{{ trans('components.image_select') }}</div>
-                <button class="popup-close neg corner-button button">x</button>
+                <button class="overlay-close neg corner-button button">x</button>
             </div>
 
             <div class="flex-fill image-manager-body">
 
                 <div class="image-manager-content">
-                    <div ng-if="imageType === 'gallery'" class="container">
+                    <div v-if="imageType === 'gallery'" class="container">
                         <div class="image-manager-header row faded-small nav-tabs">
-                            <div class="col-xs-4 tab-item" title="{{ trans('components.image_all_title') }}" ng-class="{selected: (view=='all')}" ng-click="setView('all')"><i class="zmdi zmdi-collection-image"></i> {{ trans('components.image_all') }}</div>
-                            <div class="col-xs-4 tab-item" title="{{ trans('components.image_book_title') }}" ng-class="{selected: (view=='book')}" ng-click="setView('book')"><i class="zmdi zmdi-book text-book"></i> {{ trans('entities.book') }}</div>
-                            <div class="col-xs-4 tab-item" title="{{ trans('components.image_page_title') }}" ng-class="{selected: (view=='page')}" ng-click="setView('page')"><i class="zmdi zmdi-file-text text-page"></i> {{ trans('entities.page') }}</div>
+                            <div class="col-xs-4 tab-item" title="{{ trans('components.image_all_title') }}" :class="{selected: (view=='all')}" @click="setView('all')"><i class="zmdi zmdi-collection-image"></i> {{ trans('components.image_all') }}</div>
+                            <div class="col-xs-4 tab-item" title="{{ trans('components.image_book_title') }}" :class="{selected: (view=='book')}" @click="setView('book')"><i class="zmdi zmdi-book text-book"></i> {{ trans('entities.book') }}</div>
+                            <div class="col-xs-4 tab-item" title="{{ trans('components.image_page_title') }}" :class="{selected: (view=='page')}" @click="setView('page')"><i class="zmdi zmdi-file-text text-page"></i> {{ trans('entities.page') }}</div>
                         </div>
                     </div>
-                    <div ng-show="view === 'all'" >
-                        <form ng-submit="searchImages()" class="contained-search-box">
-                            <input type="text" placeholder="{{ trans('components.image_search_hint') }}" ng-model="searchTerm">
-                            <button ng-class="{active: searching}" title="{{ trans('common.search_clear') }}" type="button" ng-click="cancelSearch()" class="text-button cancel"><i class="zmdi zmdi-close-circle-o"></i></button>
-                            <button title="{{ trans('common.search') }}" class="text-button" type="submit"><i class="zmdi zmdi-search"></i></button>
+                    <div v-show="view === 'all'" >
+                        <form @submit="searchImages" class="contained-search-box">
+                            <input placeholder="{{ trans('components.image_search_hint') }}" v-model="searchTerm">
+                            <button :class="{active: searching}" title="{{ trans('common.search_clear') }}" type="button" @click="cancelSearch()" class="text-button cancel"><i class="zmdi zmdi-close-circle-o"></i></button>
+                            <button title="{{ trans('common.search') }}" class="text-button"><i class="zmdi zmdi-search"></i></button>
                         </form>
                     </div>
                     <div class="image-manager-list">
-                        <div ng-repeat="image in images">
-                            <div class="image anim fadeIn" ng-style="{animationDelay: ($index > 26) ? '160ms' : ($index * 25) + 'ms'}"
-                                 ng-class="{selected: (image==selectedImage)}" ng-click="imageSelect(image)">
-                                <img ng-src="@{{image.thumbs.gallery}}" ng-attr-alt="@{{image.title}}" ng-attr-title="@{{image.name}}">
+                        <div v-if="images.length > 0" v-for="(image, idx) in images">
+                            <div class="image anim fadeIn" :style="{animationDelay: (idx > 26) ? '160ms' : ((idx * 25) + 'ms')}"
+                                 :class="{selected: (image==selectedImage)}" @click="imageSelect(image)">
+                                <img :src="image.thumbs.gallery" :alt="image.title" :title="image.name">
                                 <div class="image-meta">
-                                    <span class="name" ng-bind="image.name"></span>
+                                    <span class="name" v-text="image.name"></span>
                                     <span class="date">{{ trans('components.image_uploaded', ['uploadedDate' => "{{ getDate(image.created_at) }" . "}"]) }}</span>
                                 </div>
                             </div>
                         </div>
-                        <div class="load-more" ng-show="hasMore" ng-click="fetchData()">{{ trans('components.image_load_more') }}</div>
+                        <div class="load-more" v-show="hasMore" @click="fetchData">{{ trans('components.image_load_more') }}</div>
                     </div>
                 </div>
 
                 <div class="image-manager-sidebar">
                     <div class="inner">
 
-                        <div class="image-manager-details anim fadeIn" ng-show="selectedImage">
+                        <div class="image-manager-details anim fadeIn" v-if="selectedImage">
 
-                            <form ng-submit="saveImageDetails($event)">
+                            <form @submit.prevent="saveImageDetails">
                                 <div>
-                                    <a ng-href="@{{selectedImage.url}}" target="_blank" style="display: block;">
-                                        <img ng-src="@{{selectedImage.thumbs.gallery}}" ng-attr-alt="@{{selectedImage.title}}" ng-attr-title="@{{selectedImage.name}}">
+                                    <a :href="selectedImage.url" target="_blank" style="display: block;">
+                                        <img :src="selectedImage.thumbs.gallery" :alt="selectedImage.title"
+                                             :title="selectedImage.name">
                                     </a>
                                 </div>
                                 <div class="form-group">
                                     <label for="name">{{ trans('components.image_image_name') }}</label>
-                                    <input type="text" id="name" name="name" ng-model="selectedImage.name">
+                                    <input id="name" name="name" v-model="selectedImage.name">
                                 </div>
                             </form>
 
-                            <div ng-show="dependantPages">
+                            <div v-show="dependantPages">
                                 <p class="text-neg text-small">
                                     {{ trans('components.image_delete_confirm') }}
                                 </p>
                                 <ul class="text-neg">
-                                    <li ng-repeat="page in dependantPages">
-                                        <a ng-href="@{{ page.url }}" target="_blank" class="text-neg" ng-bind="page.name"></a>
+                                    <li v-for="page in dependantPages">
+                                        <a :href="page.url" target="_blank" class="text-neg" v-text="page.name"></a>
                                     </li>
                                 </ul>
                             </div>
 
                             <div class="clearfix">
-                                <form class="float left" ng-submit="deleteImage($event)">
+                                <form class="float left" @submit.prevent="deleteImage">
                                     <button class="button icon neg"><i class="zmdi zmdi-delete"></i></button>
                                 </form>
-                                <button class="button pos anim fadeIn float right" ng-show="selectedImage" ng-click="selectButtonClick()">
+                                <button class="button pos anim fadeIn float right" v-show="selectedImage" @click="callbackAndHide(selectedImage)">
                                     <i class="zmdi zmdi-square-right"></i>{{ trans('components.image_select_image') }}
                                 </button>
                             </div>
 
                         </div>
 
-                        <drop-zone message="{{ trans('components.image_dropzone') }}" upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
-
+                        <dropzone placeholder="{{ trans('components.image_dropzone') }}" :upload-url="uploadUrl" :uploaded-to="uploadedTo" @success="uploadSuccess"></dropzone>
 
                     </div>
                 </div>
 
-
-
             </div>
 
         </div>
diff --git a/resources/views/components/image-picker.blade.php b/resources/views/components/image-picker.blade.php
index 6fd81dabd..2aa39d3d2 100644
--- a/resources/views/components/image-picker.blade.php
+++ b/resources/views/components/image-picker.blade.php
@@ -43,7 +43,7 @@
              }
 
              if (action === 'show-image-manager') {
-                 window.ImageManager.showExternal((image) => {
+                 window.ImageManager.show((image) => {
                      if (!resize) {
                          setImage(image);
                          return;
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php
index 49cd2a75a..87c84ec1e 100644
--- a/resources/views/home.blade.php
+++ b/resources/views/home.blade.php
@@ -7,7 +7,7 @@
             <div class="row">
                 <div class="col-sm-6 faded">
                     <div class="action-buttons text-left">
-                        <a data-action="expand-entity-list-details" class="text-primary text-button"><i class="zmdi zmdi-wrap-text"></i>{{ trans('common.toggle_details') }}</a>
+                        <a expand-toggle=".entity-list.compact .entity-item-snippet" class="text-primary text-button"><i class="zmdi zmdi-wrap-text"></i>{{ trans('common.toggle_details') }}</a>
                     </div>
                 </div>
             </div>
diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php
index ecf7619b7..bd60af89a 100644
--- a/resources/views/pages/form-toolbox.blade.php
+++ b/resources/views/pages/form-toolbox.blade.php
@@ -9,122 +9,131 @@
         @endif
     </div>
 
-    <div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
+    <div toolbox-tab-content="tags" id="tag-manager" page-id="{{ $page->id or 0 }}">
         <h4>{{ trans('entities.page_tags') }}</h4>
         <div class="padded tags">
             <p class="muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
-            <table class="no-style" tag-autosuggestions style="width: 100%;">
-                <tbody ui-sortable="sortOptions" ng-model="tags" >
-                    <tr ng-repeat="tag in tags track by $index">
-                        <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
-                        <td><input autosuggest="{{ baseUrl('/ajax/tags/suggest/names') }}" autosuggest-type="name" class="outline" ng-attr-name="tags[@{{$index}}][name]" type="text" ng-model="tag.name" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="{{ trans('entities.tag') }}"></td>
-                        <td><input autosuggest="{{ baseUrl('/ajax/tags/suggest/values') }}" autosuggest-type="value" class="outline" ng-attr-name="tags[@{{$index}}][value]" type="text" ng-model="tag.value" ng-change="tagChange(tag)" ng-blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_value') }}"></td>
-                        <td width="10" ng-show="tags.length != 1" class="text-center text-neg" style="padding: 0;" ng-click="removeTag(tag)"><i class="zmdi zmdi-close"></i></td>
-                    </tr>
-                </tbody>
-            </table>
+
+            <draggable class="fake-table no-style tag-table" :options="{handle: '.handle'}" :list="tags" element="div">
+                <transition-group tag="div">
+                    <div v-for="(tag, i) in tags" :key="tag.key">
+                        <div width="20" class="handle" ><i class="zmdi zmdi-menu"></i></div>
+                        <div>
+                            <autosuggest url="/ajax/tags/suggest/names" type="name" class="outline" :name="getTagFieldName(i, 'name')"
+                                   v-model="tag.name" @input="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag') }}"/>
+                        </div>
+                        <div>
+                            <autosuggest url="/ajax/tags/suggest/values" type="value" class="outline" :name="getTagFieldName(i, 'value')"
+                                         v-model="tag.value" @change="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag') }}"/>
+                        </div>
+                        <div width="10" v-show="tags.length !== 1" class="text-center text-neg" style="padding: 0;" @click="removeTag(tag)"><i class="zmdi zmdi-close"></i></div>
+                    </div>
+                </transition-group>
+            </draggable>
+
             <table class="no-style" style="width: 100%;">
                 <tbody>
                 <tr class="unsortable">
-                    <td  width="34"></td>
-                    <td ng-click="addEmptyTag()">
+                    <td width="34"></td>
+                    <td @click="addEmptyTag">
                         <button type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
                     </td>
                     <td></td>
                 </tr>
                 </tbody>
             </table>
+
         </div>
     </div>
 
     @if(userCan('attachment-create-all'))
-        <div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
+        <div toolbox-tab-content="files" id="attachment-manager" page-id="{{ $page->id or 0 }}">
             <h4>{{ trans('entities.attachments') }}</h4>
             <div class="padded files">
 
-                <div id="file-list" ng-show="!editFile">
+                <div id="file-list" v-show="!fileToEdit">
                     <p class="muted small">{{ trans('entities.attachments_explain') }} <span class="secondary">{{ trans('entities.attachments_explain_instant_save') }}</span></p>
 
-                    <div tab-container>
+                    <div class="tab-container">
                         <div class="nav-tabs">
-                            <div tab-button="list" class="tab-item">{{ trans('entities.attachments_items') }}</div>
-                            <div tab-button="file" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
-                            <div tab-button="link" class="tab-item">{{ trans('entities.attachments_link') }}</div>
+                            <div @click="tab = 'list'" :class="{selected: tab === 'list'}" class="tab-item">{{ trans('entities.attachments_items') }}</div>
+                            <div @click="tab = 'file'" :class="{selected: tab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
+                            <div @click="tab = 'link'" :class="{selected: tab === 'link'}" class="tab-item">{{ trans('entities.attachments_link') }}</div>
                         </div>
-                        <div tab-content="list">
-                            <table class="file-table" style="width: 100%;">
-                                <tbody ui-sortable="sortOptions" ng-model="files" >
-                                <tr ng-repeat="file in files track by $index">
-                                    <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
-                                    <td>
-                                        <a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
-                                        <div ng-if="file.deleting">
+                        <div v-show="tab === 'list'">
+                            <draggable class="fake-table no-style " style="width: 100%;" :options="{handle: '.handle'}" @change="fileSortUpdate" :list="files" element="div">
+                                <transition-group tag="div">
+                                <div v-for="(file, index) in files" :key="file.id">
+                                    <div width="20" ><i class="handle zmdi zmdi-menu"></i></div>
+                                    <div>
+                                        <a :href="getFileUrl(file)" target="_blank" v-text="file.name"></a>
+                                        <div v-if="file.deleting">
                                             <span class="neg small">{{ trans('entities.attachments_delete_confirm') }}</span>
                                             <br>
-                                            <span class="text-primary small" ng-click="file.deleting=false;">{{ trans('common.cancel') }}</span>
+                                            <span class="text-primary small" @click="file.deleting = false;">{{ trans('common.cancel') }}</span>
                                         </div>
-                                    </td>
-                                    <td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
-                                    <td width="5"></td>
-                                    <td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
-                                </tr>
-                                </tbody>
-                            </table>
-                            <p class="small muted" ng-if="files.length == 0">
+                                    </div>
+                                    <div width="10" @click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></div>
+                                    <div width="5"></div>
+                                    <div width="10" @click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></div>
+                                </div>
+                                </transition-group>
+                            </draggable>
+                            <p class="small muted" v-if="files.length === 0">
                                 {{ trans('entities.attachments_no_files') }}
                             </p>
                         </div>
-                        <div tab-content="file">
-                            <drop-zone message="{{ trans('entities.attachments_dropzone') }}" upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
+                        <div v-show="tab === 'file'">
+                            <dropzone placeholder="{{ trans('entities.attachments_dropzone') }}" :upload-url="getUploadUrl()" :uploaded-to="pageId" @success="uploadSuccess"></dropzone>
                         </div>
-                        <div tab-content="link" sub-form="attachLinkSubmit(file)">
+                        <div v-show="tab === 'link'" @keypress.enter.prevent="attachNewLink(file)">
                             <p class="muted small">{{ trans('entities.attachments_explain_link') }}</p>
                             <div class="form-group">
                                 <label for="attachment-via-link">{{ trans('entities.attachments_link_name') }}</label>
-                                <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" ng-model="file.name">
-                                <p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
+                                <input type="text" placeholder="{{ trans('entities.attachments_link_name') }}" v-model="file.name">
+                                <p class="small neg" v-for="error in errors.link.name" v-text="error"></p>
                             </div>
                             <div class="form-group">
                                 <label for="attachment-via-link">{{ trans('entities.attachments_link_url') }}</label>
-                                <input type="text" placeholder="{{ trans('entities.attachments_link_url_hint') }}" ng-model="file.link">
-                                <p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
+                                <input type="text"  placeholder="{{ trans('entities.attachments_link_url_hint') }}" v-model="file.link">
+                                <p class="small neg" v-for="error in errors.link.link" v-text="error"></p>
                             </div>
-                            <button type="submit" class="button pos">{{ trans('entities.attach') }}</button>
+                            <button @click.prevent="attachNewLink(file)" class="button pos">{{ trans('entities.attach') }}</button>
 
                         </div>
                     </div>
 
                 </div>
 
-                <div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
+                <div id="file-edit" v-if="fileToEdit" @keypress.enter.prevent="updateFile(fileToEdit)">
                     <h5>{{ trans('entities.attachments_edit_file') }}</h5>
 
                     <div class="form-group">
                         <label for="attachment-name-edit">{{ trans('entities.attachments_edit_file_name') }}</label>
-                        <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" ng-model="editFile.name">
-                        <p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
+                        <input type="text" id="attachment-name-edit" placeholder="{{ trans('entities.attachments_edit_file_name') }}" v-model="fileToEdit.name">
+                        <p class="small neg" v-for="error in errors.edit.name" v-text="error"></p>
                     </div>
 
-                    <div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
+                    <div class="tab-container">
                         <div class="nav-tabs">
-                            <div tab-button="file" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
-                            <div tab-button="link" class="tab-item">{{ trans('entities.attachments_set_link') }}</div>
+                            <div @click="editTab = 'file'" :class="{selected: editTab === 'file'}" class="tab-item">{{ trans('entities.attachments_upload') }}</div>
+                            <div @click="editTab = 'link'" :class="{selected: editTab === 'link'}" class="tab-item">{{ trans('entities.attachments_set_link') }}</div>
                         </div>
-                        <div tab-content="file">
-                            <drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" event-success="uploadSuccessUpdate"></drop-zone>
+                        <div v-if="editTab === 'file'">
+                            <dropzone :upload-url="getUploadUrl(fileToEdit)" :uploaded-to="pageId" placeholder="{{ trans('entities.attachments_edit_drop_upload') }}" @success="uploadSuccessUpdate"></dropzone>
                             <br>
                         </div>
-                        <div tab-content="link">
+                        <div v-if="editTab === 'link'">
                             <div class="form-group">
                                 <label for="attachment-link-edit">{{ trans('entities.attachments_link_url') }}</label>
-                                <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" ng-model="editFile.link">
-                                <p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
+                                <input type="text" id="attachment-link-edit" placeholder="{{ trans('entities.attachment_link') }}" v-model="fileToEdit.link">
+                                <p class="small neg" v-for="error in errors.edit.link" v-text="error"></p>
                             </div>
                         </div>
                     </div>
 
-                    <button type="button" class="button" ng-click="cancelEdit()">{{ trans('common.back') }}</button>
-                    <button type="submit" class="button pos">{{ trans('common.save') }}</button>
+                    <button type="button" class="button" @click="cancelEdit">{{ trans('common.back') }}</button>
+                    <button @click.enter.prevent="updateFile(fileToEdit)" class="button pos">{{ trans('common.save') }}</button>
                 </div>
 
             </div>
diff --git a/resources/views/pages/list-item.blade.php b/resources/views/pages/list-item.blade.php
index 70b309e7d..593c2fc3c 100644
--- a/resources/views/pages/list-item.blade.php
+++ b/resources/views/pages/list-item.blade.php
@@ -1,13 +1,27 @@
 <div class="page {{$page->draft ? 'draft' : ''}} entity-list-item" data-entity-type="page" data-entity-id="{{$page->id}}">
     <h4>
+        @if (isset($showPath) && $showPath)
+            <a href="{{ $page->book->getUrl() }}" class="text-book">
+                <i class="zmdi zmdi-book"></i>{{ $page->book->getShortName() }}
+            </a>
+            <span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
+            @if($page->chapter)
+                <a href="{{ $page->chapter->getUrl() }}" class="text-chapter">
+                    <i class="zmdi zmdi-collection-bookmark"></i>{{ $page->chapter->getShortName() }}
+                </a>
+                <span class="text-muted">&nbsp;&nbsp;&raquo;&nbsp;&nbsp;</span>
+            @endif
+        @endif
         <a href="{{ $page->getUrl() }}" class="text-page entity-list-item-link"><i class="zmdi zmdi-file-text"></i><span class="entity-list-item-name">{{ $page->name }}</span></a>
     </h4>
 
-    @if(isset($page->searchSnippet))
-        <p class="text-muted">{!! $page->searchSnippet !!}</p>
-    @else
-        <p class="text-muted">{{ $page->getExcerpt() }}</p>
-    @endif
+    <div class="entity-item-snippet">
+        @if(isset($page->searchSnippet))
+            <p class="text-muted">{!! $page->searchSnippet !!}</p>
+        @else
+            <p class="text-muted">{{ $page->getExcerpt() }}</p>
+        @endif
+    </div>
 
     @if(isset($style) && $style === 'detailed')
         <div class="row meta text-muted text-small">
diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php
index 6b2dc3c23..0d75a534a 100644
--- a/resources/views/pages/show.blade.php
+++ b/resources/views/pages/show.blade.php
@@ -5,10 +5,10 @@
     <div class="faded-small toolbar">
         <div class="container">
             <div class="row">
-                <div class="col-sm-6 faded">
+                <div class="col-sm-8 col-xs-5 faded">
                     @include('pages._breadcrumbs', ['page' => $page])
                 </div>
-                <div class="col-sm-6 faded">
+                <div class="col-sm-4 col-xs-7 faded">
                     <div class="action-buttons">
                         <span dropdown class="dropdown-container">
                             <div dropdown-toggle class="text-button text-primary"><i class="zmdi zmdi-open-in-new"></i>{{ trans('entities.export') }}</div>
@@ -46,13 +46,13 @@
     </div>
 
 
-    <div class="container" id="page-show" ng-non-bindable>
+    <div class="container" id="page-show">
         <div class="row">
             <div class="col-md-9 print-full-width">
-                <div class="page-content">
+                <div class="page-content" ng-non-bindable>
 
                     <div class="pointer-container" id="pointer">
-                        <div class="pointer anim">
+                        <div class="pointer anim" >
                             <span class="icon text-primary"><i class="zmdi zmdi-link"></i></span>
                             <input readonly="readonly" type="text" id="pointer-url" placeholder="url">
                             <button class="button icon" data-clipboard-target="#pointer-url" type="button" title="{{ trans('entities.pages_copy_link') }}"><i class="zmdi zmdi-copy"></i></button>
@@ -66,6 +66,7 @@
                     @include('partials.entity-meta', ['entity' => $page])
 
                 </div>
+                @include('comments/comments', ['pageId' => $page->id])
             </div>
 
             <div class="col-md-3 print-hidden">
@@ -109,7 +110,6 @@
 
         </div>
     </div>
-
 @stop
 
 @section('scripts')
diff --git a/resources/views/pages/sidebar-tree-list.blade.php b/resources/views/pages/sidebar-tree-list.blade.php
index 0a10987d6..90737740c 100644
--- a/resources/views/pages/sidebar-tree-list.blade.php
+++ b/resources/views/pages/sidebar-tree-list.blade.php
@@ -51,7 +51,7 @@
                 </a>
 
                 @if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
-                    <p class="text-muted chapter-toggle @if($bookChild->matchesOrContains($current)) open @endif">
+                    <p chapter-toggle class="text-muted @if($bookChild->matchesOrContains($current)) open @endif">
                         <i class="zmdi zmdi-caret-right"></i> <i class="zmdi zmdi-file-text"></i> <span>{{ trans('entities.x_pages', ['count' => $bookChild->pages->count()]) }}</span>
                     </p>
                     <ul class="menu sub-menu inset-list @if($bookChild->matchesOrContains($current)) open @endif">
diff --git a/resources/views/partials/custom-styles.blade.php b/resources/views/partials/custom-styles.blade.php
index 62bcc881f..ffa39c50a 100644
--- a/resources/views/partials/custom-styles.blade.php
+++ b/resources/views/partials/custom-styles.blade.php
@@ -1,5 +1,6 @@
 <style id="custom-styles" data-color="{{ setting('app-color') }}" data-color-light="{{ setting('app-color-light') }}">
-    header, #back-to-top, .primary-background {
+    @if(setting('app-color'))
+    header, [back-to-top], .primary-background {
         background-color: {{ setting('app-color') }} !important;
     }
     .faded-small, .primary-background-light {
@@ -17,4 +18,5 @@
     .text-primary, p.primary, p .primary, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
         color: {{ setting('app-color') }};
     }
+    @endif
 </style>
\ No newline at end of file
diff --git a/resources/views/partials/notifications.blade.php b/resources/views/partials/notifications.blade.php
index c079080db..215aee3ed 100644
--- a/resources/views/partials/notifications.blade.php
+++ b/resources/views/partials/notifications.blade.php
@@ -1,12 +1,12 @@
 
-<div class="notification anim pos" @if(!session()->has('success')) style="display:none;" @endif>
+<div notification="success" data-autohide class="pos" @if(session()->has('success')) data-show @endif>
     <i class="zmdi zmdi-check-circle"></i> <span>{!! nl2br(htmlentities(session()->get('success'))) !!}</span>
 </div>
 
-<div class="notification anim warning stopped" @if(!session()->has('warning')) style="display:none;" @endif>
+<div notification="warning" class="warning" @if(session()->has('warning')) data-show @endif>
     <i class="zmdi zmdi-info"></i> <span>{!! nl2br(htmlentities(session()->get('warning'))) !!}</span>
 </div>
 
-<div class="notification anim neg stopped" @if(!session()->has('error')) style="display:none;" @endif>
+<div notification="error" class="neg" @if(session()->has('error')) data-show @endif>
     <i class="zmdi zmdi-alert-circle"></i> <span>{!! nl2br(htmlentities(session()->get('error'))) !!}</span>
 </div>
diff --git a/resources/views/search/entity-ajax-list.blade.php b/resources/views/search/entity-ajax-list.blade.php
index 93f2633aa..9a146506f 100644
--- a/resources/views/search/entity-ajax-list.blade.php
+++ b/resources/views/search/entity-ajax-list.blade.php
@@ -2,7 +2,7 @@
     @if(count($entities) > 0)
         @foreach($entities as $index => $entity)
             @if($entity->isA('page'))
-                @include('pages/list-item', ['page' => $entity])
+                @include('pages/list-item', ['page' => $entity, 'showPath' => true])
             @elseif($entity->isA('book'))
                 @include('books/list-item', ['book' => $entity])
             @elseif($entity->isA('chapter'))
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
index 71b8f551f..02ef525ea 100644
--- a/resources/views/settings/roles/form.blade.php
+++ b/resources/views/settings/roles/form.blade.php
@@ -117,6 +117,19 @@
                             <label>@include('settings/roles/checkbox', ['permission' => 'attachment-delete-all']) {{ trans('settings.role_all') }}</label>
                         </td>
                     </tr>
+                    <tr>
+                        <td>{{ trans('entities.comments') }}</td>
+                        <td>@include('settings/roles/checkbox', ['permission' => 'comment-create-all'])</td>
+                        <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
+                        <td>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'comment-update-own']) {{ trans('settings.role_own') }}</label>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'comment-update-all']) {{ trans('settings.role_all') }}</label>
+                        </td>
+                        <td>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'comment-delete-own']) {{ trans('settings.role_own') }}</label>
+                            <label>@include('settings/roles/checkbox', ['permission' => 'comment-delete-all']) {{ trans('settings.role_all') }}</label>
+                        </td>
+                    </tr>
                 </table>
             </div>
         </div>
diff --git a/routes/web.php b/routes/web.php
index 8ecfd9465..463e4e77b 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -119,6 +119,12 @@ Route::group(['middleware' => 'auth'], function () {
 
     Route::get('/ajax/search/entities', 'SearchController@searchEntitiesAjax');
 
+    // Comments
+    Route::post('/ajax/page/{pageId}/comment/', 'CommentController@save');
+    Route::put('/ajax/page/{pageId}/comment/{commentId}', 'CommentController@save');
+    Route::delete('/ajax/comment/{id}', 'CommentController@destroy');
+    Route::get('/ajax/page/{pageId}/comments/', 'CommentController@getPageComments');
+
     // Links
     Route::get('/link/{id}', 'PageController@redirectFromLink');
 
diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php
index 681ead91c..8880c7b65 100644
--- a/tests/Auth/LdapTest.php
+++ b/tests/Auth/LdapTest.php
@@ -11,6 +11,7 @@ class LdapTest extends BrowserKitTest
     public function setUp()
     {
         parent::setUp();
+        if (!defined('LDAP_OPT_REFERRALS')) define('LDAP_OPT_REFERRALS', 1);
         app('config')->set(['auth.method' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'auth.providers.users.driver' => 'ldap']);
         $this->mockLdap = \Mockery::mock(\BookStack\Services\Ldap::class);
         $this->app['BookStack\Services\Ldap'] = $this->mockLdap;
@@ -21,6 +22,7 @@ class LdapTest extends BrowserKitTest
     {
         $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId);
         $this->mockLdap->shouldReceive('setVersion')->once();
+        $this->mockLdap->shouldReceive('setOption')->times(4);
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
@@ -49,6 +51,7 @@ class LdapTest extends BrowserKitTest
         $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId);
         $this->mockLdap->shouldReceive('setVersion')->once();
         $ldapDn = 'cn=test-user,dc=test' . config('services.ldap.base_dn');
+        $this->mockLdap->shouldReceive('setOption')->times(2);
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
@@ -72,6 +75,7 @@ class LdapTest extends BrowserKitTest
     {
         $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId);
         $this->mockLdap->shouldReceive('setVersion')->once();
+        $this->mockLdap->shouldReceive('setOption')->times(2);
         $this->mockLdap->shouldReceive('searchAndGetEntries')->times(2)
             ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array'))
             ->andReturn(['count' => 1, 0 => [
@@ -129,4 +133,4 @@ class LdapTest extends BrowserKitTest
             ->dontSee('External Authentication');
     }
 
-}
\ No newline at end of file
+}
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
new file mode 100644
index 000000000..86eb31213
--- /dev/null
+++ b/tests/Entity/CommentTest.php
@@ -0,0 +1,111 @@
+<?php namespace Tests;
+
+use BookStack\Page;
+use BookStack\Comment;
+
+class CommentTest extends BrowserKitTest
+{
+
+    public function test_add_comment()
+    {
+        $this->asAdmin();
+        $page = $this->getPage();
+
+        $this->addComment($page);
+    }
+
+    public function test_comment_reply()
+    {
+        $this->asAdmin();
+        $page = $this->getPage();
+
+        // add a normal comment
+        $createdComment = $this->addComment($page);
+
+        // reply to the added comment
+        $this->addComment($page, $createdComment['id']);
+    }
+
+    public function test_comment_edit()
+    {
+        $this->asAdmin();
+        $page = $this->getPage();
+
+        $createdComment = $this->addComment($page);
+        $comment = [
+            'id' => $createdComment['id'],
+            'page_id' => $createdComment['page_id']
+        ];
+        $this->updateComment($comment);
+    }
+
+    public function test_comment_delete()
+    {
+        $this->asAdmin();
+        $page = $this->getPage();
+
+        $createdComment = $this->addComment($page);
+
+        $this->deleteComment($createdComment['id']);
+    }
+
+    private function getPage() {
+        $page = Page::first();
+        return $page;
+    }
+
+
+    private function addComment($page, $parentCommentId = null) {
+        $comment = factory(Comment::class)->make();
+        $url = "/ajax/page/$page->id/comment/";
+        $request = [
+            'text' => $comment->text,
+            'html' => $comment->html
+        ];
+        if (!empty($parentCommentId)) {
+            $request['parent_id'] = $parentCommentId;
+        }
+        $this->call('POST', $url, $request);
+
+        $createdComment = $this->checkResponse();
+        return $createdComment;
+    }
+
+    private function updateComment($comment) {
+        $tmpComment = factory(Comment::class)->make();
+        $url = '/ajax/page/' . $comment['page_id'] . '/comment/ ' . $comment['id'];
+         $request = [
+            'text' => $tmpComment->text,
+            'html' => $tmpComment->html
+        ];
+
+        $this->call('PUT', $url, $request);
+
+        $updatedComment = $this->checkResponse();
+        return $updatedComment;
+    }
+
+    private function deleteComment($commentId) {
+        //  Route::delete('/ajax/comment/{id}', 'CommentController@destroy');
+        $url = '/ajax/comment/' . $commentId;
+        $this->call('DELETE', $url);
+
+        $deletedComment = $this->checkResponse();
+        return $deletedComment;
+    }
+
+    private function checkResponse() {
+        $expectedResp = [
+            'status' => 'success'
+        ];
+
+        $this->assertResponseOk();
+        $this->seeJsonContains($expectedResp);
+
+        $resp = $this->decodeResponseJson();
+        $createdComment = $resp['comment'];
+        $this->assertArrayHasKey('id', $createdComment);
+
+        return $createdComment;
+    }
+}
diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php
index 94e28e944..587430918 100644
--- a/tests/Entity/EntitySearchTest.php
+++ b/tests/Entity/EntitySearchTest.php
@@ -1,6 +1,9 @@
 <?php namespace Tests;
 
 
+use BookStack\Chapter;
+use BookStack\Page;
+
 class EntitySearchTest extends TestCase
 {
 
@@ -75,10 +78,10 @@ class EntitySearchTest extends TestCase
             ])
         ];
 
-        $pageA = \BookStack\Page::first();
+        $pageA = Page::first();
         $pageA->tags()->saveMany($newTags);
 
-        $pageB = \BookStack\Page::all()->last();
+        $pageB = Page::all()->last();
         $pageB->tags()->create(['name' => 'animal', 'value' => 'dog']);
 
         $this->asEditor();
@@ -160,8 +163,8 @@ class EntitySearchTest extends TestCase
 
     public function test_ajax_entity_search()
     {
-        $page = \BookStack\Page::all()->last();
-        $notVisitedPage = \BookStack\Page::first();
+        $page = Page::all()->last();
+        $notVisitedPage = Page::first();
 
         // Visit the page to make popular
         $this->asEditor()->get($page->getUrl());
@@ -176,4 +179,20 @@ class EntitySearchTest extends TestCase
         $defaultListTest->assertSee($page->name);
         $defaultListTest->assertDontSee($notVisitedPage->name);
     }
+
+    public function test_ajax_entity_serach_shows_breadcrumbs()
+    {
+        $chapter = Chapter::first();
+        $page = $chapter->pages->first();
+        $this->asEditor();
+
+        $pageSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name));
+        $pageSearch->assertSee($page->name);
+        $pageSearch->assertSee($chapter->getShortName());
+        $pageSearch->assertSee($page->book->getShortName());
+
+        $chapterSearch = $this->get('/ajax/search/entities?term=' . urlencode($chapter->name));
+        $chapterSearch->assertSee($chapter->name);
+        $chapterSearch->assertSee($chapter->book->getShortName());
+    }
 }
diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php
index eda5d092a..f131ed885 100644
--- a/tests/Permissions/RolesTest.php
+++ b/tests/Permissions/RolesTest.php
@@ -657,4 +657,112 @@ class RolesTest extends BrowserKitTest
             ->dontSee('Sort the current book');
     }
 
+    public function test_comment_create_permission () {
+        $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+
+        $this->actingAs($this->user)->addComment($ownPage);
+
+        $this->assertResponseStatus(403);
+
+        $this->giveUserPermissions($this->user, ['comment-create-all']);
+
+        $this->actingAs($this->user)->addComment($ownPage);
+        $this->assertResponseOk(200)->seeJsonContains(['status' => 'success']);
+    }
+
+
+    public function test_comment_update_own_permission () {
+        $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+        $this->giveUserPermissions($this->user, ['comment-create-all']);
+        $comment = $this->actingAs($this->user)->addComment($ownPage);
+
+        // no comment-update-own
+        $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+        $this->assertResponseStatus(403);
+
+        $this->giveUserPermissions($this->user, ['comment-update-own']);
+
+        // now has comment-update-own
+        $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+        $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+    }
+
+    public function test_comment_update_all_permission () {
+        $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+        $comment = $this->asAdmin()->addComment($ownPage);
+
+        // no comment-update-all
+        $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+        $this->assertResponseStatus(403);
+
+        $this->giveUserPermissions($this->user, ['comment-update-all']);
+
+        // now has comment-update-all
+        $this->actingAs($this->user)->updateComment($ownPage, $comment['id']);
+        $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+    }
+
+    public function test_comment_delete_own_permission () {
+        $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+        $this->giveUserPermissions($this->user, ['comment-create-all']);
+        $comment = $this->actingAs($this->user)->addComment($ownPage);
+
+        // no comment-delete-own
+        $this->actingAs($this->user)->deleteComment($comment['id']);
+        $this->assertResponseStatus(403);
+
+        $this->giveUserPermissions($this->user, ['comment-delete-own']);
+
+        // now has comment-update-own
+        $this->actingAs($this->user)->deleteComment($comment['id']);
+        $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+    }
+
+    public function test_comment_delete_all_permission () {
+        $ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
+        $comment = $this->asAdmin()->addComment($ownPage);
+
+        // no comment-delete-all
+        $this->actingAs($this->user)->deleteComment($comment['id']);
+        $this->assertResponseStatus(403);
+
+        $this->giveUserPermissions($this->user, ['comment-delete-all']);
+
+        // now has comment-delete-all
+        $this->actingAs($this->user)->deleteComment($comment['id']);
+        $this->assertResponseOk()->seeJsonContains(['status' => 'success']);
+    }
+
+    private function addComment($page) {
+        $comment = factory(\BookStack\Comment::class)->make();
+        $url = "/ajax/page/$page->id/comment/";
+        $request = [
+            'text' => $comment->text,
+            'html' => $comment->html
+        ];
+
+        $this->json('POST', $url, $request);
+        $resp = $this->decodeResponseJson();
+        if (isset($resp['comment'])) {
+            return $resp['comment'];
+        }
+        return null;
+    }
+
+    private function updateComment($page, $commentId) {
+        $comment = factory(\BookStack\Comment::class)->make();
+        $url = "/ajax/page/$page->id/comment/$commentId";
+        $request = [
+            'text' => $comment->text,
+            'html' => $comment->html
+        ];
+
+        return $this->json('PUT', $url, $request);
+    }
+
+    private function deleteComment($commentId) {
+         $url = '/ajax/comment/' . $commentId;
+         return $this->json('DELETE', $url);
+    }
+
 }
diff --git a/version b/version
index 1365ed940..de0ad7791 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-v0.17-dev
+v0.18-dev