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/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/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/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/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_01_01_130541_create_comments_table.php b/database/migrations/2017_01_01_130541_create_comments_table.php
new file mode 100644
index 000000000..f4ece31a7
--- /dev/null
+++ b/database/migrations/2017_01_01_130541_create_comments_table.php
@@ -0,0 +1,112 @@
+<?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()
+    {
+        if (Schema::hasTable('comments')) {
+            return;
+        }
+        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->index(['page_id', 'parent_id']);
+            $table->timestamps();
+
+            // Get roles with permissions we need to change
+            $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
+                ]);
+            }
+
+            // Get roles with permissions we need to change
+            /*
+            $editorRole = DB::table('roles')->where('name', '=', 'editor')->first();
+            if (!empty($editorRole)) {
+                $editorRoleId = $editorRole->id;
+                // Create & attach new entity permissions
+                $ops = ['Create All', 'Create Own', 'Update Own', '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' => $editorRoleId,
+                        'permission_id' => $permissionId
+                    ]);
+                }
+            }
+
+            // Get roles with permissions we need to change
+            $viewerRole = DB::table('roles')->where('name', '=', 'viewer')->first();
+            if (!empty($viewerRole)) {
+                $viewerRoleId = $viewerRole->id;
+                // Create & attach new entity permissions
+                $ops = ['Create All'];
+                $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' => $viewerRoleId,
+                        'permission_id' => $permissionId
+                    ]);
+                }
+            }
+            */
+
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('comments');
+        // Create & attach new entity 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/migrations/2017_06_04_060012_comments_add_active_col.php b/database/migrations/2017_06_04_060012_comments_add_active_col.php
new file mode 100644
index 000000000..3c6dd1f33
--- /dev/null
+++ b/database/migrations/2017_06_04_060012_comments_add_active_col.php
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CommentsAddActiveCol extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            // add column active
+            $table->boolean('active')->default(true);
+            $table->dropIndex('comments_page_id_parent_id_index');
+            $table->index(['page_id']);
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('comments', function (Blueprint $table) {
+            // reversing the schema
+            $table->dropIndex('comments_page_id_index');
+            $table->dropColumn('active');
+            $table->index(['page_id', 'parent_id']);
+        });
+    }
+}
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 08c8886bd..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');
diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js
index 9337ea889..e1d838bb6 100644
--- a/resources/assets/js/controllers.js
+++ b/resources/assets/js/controllers.js
@@ -675,4 +675,225 @@ module.exports = function (ngApp, events) {
 
         }]);
 
+    // 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;
+
+        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
+            };
+
+            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;
+            }
+            $http[httpMethod](window.baseUrl(serviceUrl), reqObj).then(resp => {
+                if (!isCommentOpSuccess(resp)) {
+                     return;
+                }
+                // 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(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;
+                }
+                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'));
+                }
+            });
+        };
+    }]);
+
+    // 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;
+            }
+
+            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 8d7d89cee..d8745462d 100644
--- a/resources/assets/js/directives.js
+++ b/resources/assets/js/directives.js
@@ -863,4 +863,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/pages/page-show.js b/resources/assets/js/pages/page-show.js
index 67d339d63..020229d2f 100644
--- a/resources/assets/js/pages/page-show.js
+++ b/resources/assets/js/pages/page-show.js
@@ -161,6 +161,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/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/_pages.scss b/resources/assets/sass/_pages.scss
index e5334c69c..b06892c1d 100755
--- a/resources/assets/sass/_pages.scss
+++ b/resources/assets/sass/_pages.scss
@@ -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/_variables.scss b/resources/assets/sass/_variables.scss
index 23bf2b219..3e864aaa4 100644
--- a/resources/assets/sass/_variables.scss
+++ b/resources/assets/sass/_variables.scss
@@ -56,3 +56,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..72b5b16b5 100644
--- a/resources/assets/sass/export-styles.scss
+++ b/resources/assets/sass/export-styles.scss
@@ -10,6 +10,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 afb9d531b..3b279b8bd 100644
--- a/resources/assets/sass/styles.scss
+++ b/resources/assets/sass/styles.scss
@@ -16,6 +16,7 @@
 @import "header";
 @import "lists";
 @import "pages";
+@import "comments";
 
 [v-cloak], [v-show] {
   display: none; opacity: 0;
diff --git a/resources/lang/de/entities.php b/resources/lang/de/entities.php
index c9feb8497..5d7d5cdde 100644
--- a/resources/lang/de/entities.php
+++ b/resources/lang/de/entities.php
@@ -213,4 +213,27 @@ return [
     '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.',
+
+    /**
+     * 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' => 'Damit wird der Inhalt des Kommentars entfernt. Bist du sicher, dass du diesen Kommentar löschen möchtest?',
+    'comment_create' => 'Erstellt'
+
 ];
\ No newline at end of file
diff --git a/resources/lang/de/errors.php b/resources/lang/de/errors.php
index e085d9915..ff045d628 100644
--- a/resources/lang/de/errors.php
+++ b/resources/lang/de/errors.php
@@ -67,4 +67,11 @@ return [
     '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.',
+
+    // 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',
 ];
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/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/entities.php b/resources/lang/fr/entities.php
index 17b4ea913..0d89993e9 100644
--- a/resources/lang/fr/entities.php
+++ b/resources/lang/fr/entities.php
@@ -213,4 +213,26 @@ return [
     '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 4197b1708..9e20147b6 100644
--- a/resources/lang/fr/errors.php
+++ b/resources/lang/fr/errors.php
@@ -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/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/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/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/pages/show.blade.php b/resources/views/pages/show.blade.php
index 221ed4476..0d75a534a 100644
--- a/resources/views/pages/show.blade.php
+++ b/resources/views/pages/show.blade.php
@@ -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/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/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/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);
+    }
+
 }