Просмотр исходного кода

Merge pull request #47 from crowi/feature-comment

Comment feature
Sotaro KARASAWA 10 лет назад
Родитель
Сommit
c27332f584

+ 11 - 0
lib/form/comment.js

@@ -0,0 +1,11 @@
+'use strict';
+
+var form = require('express-form')
+  , field = form.field;
+
+module.exports = form(
+  field('commentForm.page_id').trim().required(),
+  field('commentForm.revision_id').trim().required(),
+  field('commentForm.comment').trim().required(),
+  field('commentForm.comment_position').trim().toInt()
+);

+ 1 - 0
lib/form/index.js

@@ -2,6 +2,7 @@ exports.login = require('./login');
 exports.register = require('./register');
 exports.invited = require('./invited');
 exports.revision = require('./revision');
+exports.comment = require('./comment');
 exports.me = {
   user: require('./me/user'),
   password: require('./me/password'),

+ 121 - 0
lib/models/comment.js

@@ -0,0 +1,121 @@
+module.exports = function(crowi) {
+  var debug = require('debug')('crowi:models:comment')
+    , mongoose = require('mongoose')
+    , ObjectId = mongoose.Schema.Types.ObjectId
+    , USER_PUBLIC_FIELDS = '_id fbId image googleId name username email status createdAt' // TODO: どこか別の場所へ...
+    , commentSchema
+  ;
+
+  commentSchema = new mongoose.Schema({
+    page: { type: ObjectId, ref: 'Page', index: true },
+    creator: { type: ObjectId, ref: 'User', index: true  },
+    revision: { type: ObjectId, ref: 'Revision', index: true },
+    comment: { type: String, required: true },
+    commentPosition: { type: Number, default: -1 },
+    createdAt: { type: Date, default: Date.now }
+  });
+
+  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position) {
+    var Comment = this,
+      commentPosition = position || -1;
+
+
+    return new Promise(function(resolve, reject) {
+      var newComment = new Comment();
+
+      newComment.page = pageId;
+      newComment.creator = creatorId;
+      newComment.revision = revisionId;
+      newComment.comment = comment;
+      newComment.commentPosition = position;
+
+      newComment.save(function(err, data) {
+        if (err) {
+          debug('Error on saving comment.', err);
+          return reject(err);
+        }
+        debug('Comment saved.', data);
+        return resolve(data);
+      });
+    });
+  };
+
+  commentSchema.statics.getCommentsByPageId = function(id) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self
+        .find({page: id})
+        .sort({'createdAt': -1})
+        .populate('creator', USER_PUBLIC_FIELDS)
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (data.length < 1) {
+            return resolve([]);
+          }
+
+          debug('Comment loaded', data);
+          return resolve(data);
+        });
+    });
+  };
+
+  commentSchema.statics.getCommentsByRevisionId = function(id) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self
+        .find({revision: id})
+        .sort({'createdAt': -1})
+        .populate('creator', USER_PUBLIC_FIELDS)
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (data.length < 1) {
+            return resolve([]);
+          }
+
+          debug('Comment loaded', data);
+          return resolve(data);
+        });
+    });
+  };
+
+  commentSchema.statics.countCommentByPageId = function(page) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.count({page: page}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
+  /**
+   * post save hook
+   */
+  commentSchema.post('save', function(savedComment) {
+    var Page = crowi.model('Page')
+      , Comment = crowi.model('Comment')
+    ;
+
+    Comment.countCommentByPageId(savedComment.page)
+    .then(function(count) {
+      return Page.updateCommentCount(savedComment.page, count);
+    }).then(function(page) {
+      debug('CommentCount Updated', page);
+    }).catch(function() {
+    });
+  });
+
+  return mongoose.model('Comment', commentSchema);
+};

+ 3 - 0
lib/models/index.js

@@ -1,7 +1,10 @@
+'use strict';
+
 module.exports = {
   Page: require('./page'),
   User: require('./user'),
   Revision: require('./revision'),
   Bookmark: require('./bookmark'),
+  Comment: require('./comment'),
   Attachment: require('./attachment'),
 };

+ 19 - 1
lib/models/page.js

@@ -7,6 +7,7 @@ module.exports = function(crowi) {
     , GRANT_SPECIFIED = 3
     , GRANT_OWNER = 4
     , PAGE_GRANT_ERROR = 1
+    , USER_PUBLIC_FIELDS = '_id fbId image googleId name username email status createdAt' // TODO: どこか別の場所へ...
     , pageSchema;
 
   function populatePageData(pageData, revisionId, callback) {
@@ -20,7 +21,7 @@ module.exports = function(crowi) {
     pageData.seenUsersCount = pageData.seenUsers.length || 0;
 
     pageData.populate([
-      {path: 'creator', model: 'User'},
+      {path: 'creator', model: 'User', select: USER_PUBLIC_FIELDS},
       {path: 'revision', model: 'Revision'},
       {path: 'liker', options: { limit: 11 }},
       {path: 'seenUsers', options: { limit: 11 }},
@@ -38,6 +39,7 @@ module.exports = function(crowi) {
     creator: { type: ObjectId, ref: 'User', index: true },
     liker: [{ type: ObjectId, ref: 'User', index: true }],
     seenUsers: [{ type: ObjectId, ref: 'User', index: true }],
+    commentCount: { type: Number, default: 0 },
     createdAt: { type: Date, default: Date.now },
     updatedAt: Date
   });
@@ -163,6 +165,22 @@ module.exports = function(crowi) {
     });
   };
 
+  pageSchema.statics.updateCommentCount = function (page, num)
+  {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.update({_id: page}, {commentCount: num}, {}, function(err, data) {
+        if (err) {
+          debug('Update commentCount Error', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
+  };
+
   pageSchema.statics.getGrantLabels = function() {
     var grantLabels = {};
     grantLabels[GRANT_PUBLIC]     = '公開';

+ 2 - 0
lib/models/user.js

@@ -11,6 +11,7 @@ module.exports = function(crowi) {
     , STATUS_SUSPENDED  = 3
     , STATUS_DELETED    = 4
     , STATUS_INVITED    = 5
+    , USER_PUBLIC_FIELDS = '_id fbId image googleId name username email status createdAt' // TODO: どこか別の場所へ...
 
     , PAGE_ITEMS        = 20
 
@@ -534,6 +535,7 @@ module.exports = function(crowi) {
   userSchema.statics.STATUS_SUSPENDED = STATUS_SUSPENDED;
   userSchema.statics.STATUS_DELETED = STATUS_DELETED;
   userSchema.statics.STATUS_INVITED = STATUS_INVITED;
+  userSchema.statics.USER_PUBLIC_FIELDS = USER_PUBLIC_FIELDS;
 
   return mongoose.model('User', userSchema);
 };

+ 75 - 0
lib/routes/comment.js

@@ -0,0 +1,75 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routs:comment')
+    , Comment = crowi.model('Comment')
+    , User = crowi.model('User')
+    , Page = crowi.model('Page')
+    , ApiResponse = require('../util/apiResponse')
+    , actions = {}
+    , api = {};
+
+  actions.api = api;
+
+  /**
+   * @api {get} /comments.get Get comments of the page of the revision
+   * @apiName GetComments
+   * @apiGroup Comment
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id Revision Id.
+   */
+  api.get = function(req, res){
+    var pageId = req.query.page_id;
+    var revisionId = req.query.revision_id;
+
+    if (revisionId) {
+      return Comment.getCommentsByRevisionId(revisionId)
+        .then(function(comments) {
+          res.json(ApiResponse.success({comments}));
+        }).catch(function(err) {
+          res.json(ApiResponse.error(err));
+        });
+    }
+
+    return Comment.getCommentsByPageId(pageId)
+      .then(function(comments) {
+        res.json(ApiResponse.success({comments}));
+      }).catch(function(err) {
+        res.json(ApiResponse.error(err));
+      });
+  };
+
+  /**
+   * @api {post} /comments.post Post comment for the page
+   * @apiName PostComment
+   * @apiGroup Comment
+   *
+   * @apiParam {String} page_id Page Id.
+   * @apiParam {String} revision_id Revision Id.
+   * @apiParam {String} comment Comment body
+   * @apiParam {Number} comment_position=-1 Line number of the comment
+   */
+  api.post = function(req, res){
+    var form = req.form.commentForm;
+
+    if (!req.form.isValid) {
+      return res.json(ApiResponse.error('Invalid comment.'));
+    }
+
+    var pageId = form.page_id;
+    var revisionId = form.revision_id;
+    var comment = form.comment;
+    var position = form.comment_position || -1;
+
+    return Comment.create(pageId, req.user._id, revisionId, comment, position)
+      .then(function(createdComment) {
+        createdComment.creator = req.user;
+        return res.json(ApiResponse.success({comment: createdComment}));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
+  return actions;
+};

+ 3 - 0
lib/routes/index.js

@@ -9,6 +9,7 @@ module.exports = function(crowi, app) {
     , installer = require('./installer')(crowi, app)
     , user      = require('./user')(crowi, app)
     , attachment= require('./attachment')(crowi, app)
+    , comment= require('./comment')(crowi, app)
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser
     ;
@@ -77,6 +78,8 @@ module.exports = function(crowi, app) {
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
   app.get('/_api/pages.get'           , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.get);
+  app.get('/_api/comments.get'        , accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.get);
+  app.post('/_api/comments.post'      , form.comment, accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.post);
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 

+ 3 - 8
lib/routes/page.js

@@ -6,6 +6,7 @@ module.exports = function(crowi, app) {
     , User = crowi.model('User')
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
+    , ApiResponse = require('../util/apiResponse')
     , actions = {};
 
   function getPathFromRequest(req) {
@@ -217,16 +218,10 @@ module.exports = function(crowi, app) {
     Page.findPage(pagePath, req.user, revision, options, function(err, pageData) {
       var result = {};
       if (err) {
-        result = {
-          ok: false,
-          message: err.toString()
-        };
+        result = ApiResponse.error(err);
       }
       if (pageData) {
-        result = {
-          ok: true,
-          page: pageData
-        };
+        result = ApiResponse.success(pageData);
       }
 
       return res.json(result);

+ 29 - 0
lib/util/apiResponse.js

@@ -0,0 +1,29 @@
+'use strict';
+
+function ApiResponse () {
+};
+
+ApiResponse.error = function (err) {
+  var result = {};
+
+  result = {
+    ok: false
+  };
+
+  if (typeof err == Error) {
+    result.error = err.toString();
+  } else {
+    result.error = err;
+  }
+
+  return result;
+};
+
+ApiResponse.success = function (data) {
+  var result = data;
+
+  result.ok = true;
+  return result;
+};
+
+module.exports = ApiResponse;

+ 1 - 1
lib/views/layout/layout.html

@@ -13,7 +13,7 @@
 
   <link rel="stylesheet" href="/css/crowi{% if env  == 'production' %}.min{% endif %}.css">
   <script src="/js/crowi{% if env  == 'production' %}.min{% endif %}.js"></script>
-  <link href='//fonts.googleapis.com/css?family=Maven+Pro:400,700' rel='stylesheet' type='text/css'>
+  <link href='//fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
 </head>
 {% endblock %}
 

+ 83 - 61
lib/views/page.html

@@ -13,7 +13,12 @@
 {% endblock %}
 
 {% block content_main %}
-<div id="content-main" class="content-main {% if not page %}on-edit{% endif %}" data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}">
+<div id="content-main" class="content-main {% if not page %}on-edit{% endif %}"
+  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
+  data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  >
 
   {% if not page %}
   <ul class="nav nav-tabs hidden-print">
@@ -44,6 +49,7 @@
 
     <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> 編集</a></li>
 
+
     <li class="dropdown pull-right">
       <a class="dropdown-toggle" data-toggle="dropdown" href="#">
         <i class="fa fa-wrench"></i> <span class="caret"></span>
@@ -53,7 +59,9 @@
        <li><a href="?presentation=1" class="toggle-presentation"><i class="fa fa-arrows-alt"></i> プレゼンモード (beta)</a></li>
       </ul>
     </li>
-
+    {% if page %}
+    <li class="pull-right"><a href="#revision-history" data-toggle="tab"><i class="fa fa-history"></i> History</a></li>
+    {% endif %}
   </ul>
 
   {% include 'modal/widget_rename.html' %}
@@ -98,6 +106,35 @@
     <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' %}
     </div>
+
+    {# raw revision history #}
+    <div class="tab-pane revision-history" id="revision-history">
+      <h1><i class="fa fa-history"></i> History</h1>
+      {% if not page %}
+      {% else %}
+      <div class="revision-history-list">
+        {% for t in tree %}
+        <div class="revision-hisory-outer">
+          <img src="{{ t.author|picture }}" class="picture picture-rounded">
+          <div class="revision-history-main">
+            <div class="revision-history-author">
+              <strong>{% if t.author %}{{ t.author.username }}{% else %}-{% endif %}</strong>
+            </div>
+            <div class="revision-history-comment">
+            </div>
+            <div class="revision-history-meta">
+              {{ t.createdAt|datetz('Y-m-d H:i:s') }}
+              <br>
+              <a href="?revision={{ t._id.toString() }}"><i class="fa fa-history"></i> このバージョンを見る</a>
+            </div>
+          </div>
+        </div>
+        {% endfor %}
+      </div>
+      {% endif %}
+
+    </div>
+
   </div>
   <script type="text/javascript">
     $(function(){
@@ -144,46 +181,17 @@
 
 {% block content_footer %}
 
-<div class="page-attachments">
+
+<div class="page-attachments meta">
   <p>Attachments</p>
   <ul>
   </ul>
 </div>
-<script>
-  (function() {
-    var pageId = $('#content-main').data('page-id');
-    var $pageAttachmentList = $('.page-attachments ul');
-    if (pageId) {
-      $.get('/_api/attachment/page/' + pageId, function(res) {
-        var attachments = res.data.attachments;
-        var urlBase = res.data.fileBaseUrl;
-        if (attachments.length > 0) {
-          $.each(attachments, function(i, file) {
-            $pageAttachmentList.append(
-            '<li><a href="' + urlBase + file.filePath + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
-            );
-          })
-        } else {
-          $('.page-attachments').remove();
-        }
-      });
-    }
-  })();
-</script>
-<footer>
-  {% if not page %}
-  {% else %}
-  <p class="meta">
+
+<p class="meta">
   Path: <span id="pagePath">{{ page.path }}</span><br />
-  Revision: {{ revision._id.toString() }}<br />
-  {% if author %}
-  Last Updated User: <a href="/user/{{ author.username }}">{{ author.name }}</a><br />
-  {% endif %}
-  Created: {{ page.createdAt|datetz('Y-m-d H:i:s') }}<br />
-  Updated: {{ page.updatedAt|datetz('Y-m-d H:i:s') }}<br />
-  </p>
-  {% endif %}
-</footer>
+  Last updated at {{ page.updatedAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(author)|picture }}" class="picture picture-rounded"> {{ page.creator.name|default(author.name) }}
+</p>
 
 {% endblock %}
 
@@ -222,6 +230,11 @@
       <dd>
         <p class="liker-count">
         {{ page.liker.length }}
+          {% if page.isLiked(user) %}
+            <button data-liked="1" class="btn btn-default btn-sm active" id="pageLikeButton"><i class="fa fa-thumbs-up"></i> いいね!</button>
+          {% else %}
+            <button data-liked="0" class="btn btn-default btn-sm" id="pageLikeButton"><i class="fa fa-thumbs-o-up"></i> いいね!</button>
+          {% endif %}
         </p>
         <p class="liker-list">
           {% for liker in page.liker %}
@@ -231,11 +244,6 @@
             (...)
           {% endif %}
         </p>
-        {% if page.isLiked(user) %}
-          <button data-liked="1" class="btn btn-default btn-sm active" id="pageLikeButton"><i class="fa fa-thumbs-up"></i> いいね!!!</button>
-        {% else %}
-          <button data-liked="0" class="btn btn-default btn-sm" id="pageLikeButton"><i class="fa fa-thumbs-o-up"></i> いいね!!!</button>
-        {% endif %}
       </dd>
 
       <dt><i class="fa fa-eye"></i> 見た人</dt>
@@ -285,36 +293,50 @@ $(function() {
 
 {% block side_content %}
 
-  <h3><i class="fa fa-link"></i> 共有</h3>
+  <h3><i class="fa fa-link"></i> Share</h3>
   <ul class="fitted-list">
     <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
       <span class="input-group-addon">共有用</span>
       <input class="copy-link form-control" type="text" value="{{ config.crowi['app:title'] }} {{ path }}  {{ baseUrl }}/_r/{{ page._id.toString() }}">
     </li>
-    <li data-toggle="tooltip" data-placement="bottom" title="Wiki記法" class="input-group">
-      <span class="input-group-addon">Wikiタグ</span>
-      <input class="copy-link form-control" type="text" value="&lt;{{ path }}&gt;">
-    </li>
     <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
       <span class="input-group-addon">Markdown</span>
       <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/_r/{{ revision._id.toString() }})">
     </li>
   </ul>
 
-  <h3><i class="fa fa-history"></i> History</h3>
-  {% if not page %}
-  {% else %}
-  <ul class="revision-history">
-    {% for t in tree %}
-    <li>
-      <a href="?revision={{ t._id.toString() }}">
-        <img src="{{ t.author|picture }}" class="picture picture-rounded">
-        {% if t.author %}{{ t.author.username }}{% else %}-{% endif %}<br>{{ t.createdAt|datetz('Y-m-d H:i:s') }}
-      </a>
-    </li>
-    {% endfor %}
-  </ul>
-  {% endif %}
+  <h3><i class="fa fa-comment"></i> Comments</h3>
+  <div class="page-comments">
+    <form class="form page-comment-form" id="page-comment-form">
+      <div class="comment-form">
+        <div class="comment-form-main">
+          <div class="comment-write" id="comment-write">
+            <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"></textarea>
+          </div>
+          <div class="comment-submit">
+            <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
+            <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
+            <span class="text-danger" id="comment-form-message"></span>
+            <input type="submit" id="commenf-form-button" value="Comment" class="btn btn-primary btn-sm form-inline">
+          </div>
+        </div>
+      </div>
+    </form>
+
+    <div class="page-comments-list" id="page-comments-list">
+      <div class="page-comments-list-newer collapse" id="page-comments-list-newer"></div>
+
+      <a class="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer"><i class="fa fa-angle-double-up"></i> Comments for Newer Revision <i class="fa fa-angle-double-up"></i></a>
+
+      <div class="page-comments-list-current" id="page-comments-list-current"></div>
+
+      <a class="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older"><i class="fa fa-angle-double-down"></i> Comments for Older Revision <i class="fa fa-angle-double-down"></i></a>
+
+      <div class="page-comments-list-older collapse in" id="page-comments-list-older"></div>
+    </div>
+  </div>
+
+
 {% endblock %}
 
 {% block footer %}

+ 12 - 5
lib/views/page_list.html

@@ -17,13 +17,20 @@
 <h2>ページ一覧</h2>
   <div class="tab-content">
     {# list view #}
-    <div class="active wiki tab-pane fade in" id="view-list">
+    <div class="active page-list wiki tab-pane fade in" id="view-list">
       {% for page in pages %}
-        <a href="{{ page.path }}">{{ page.path }}</a>
+        <a class="page-list-link" href="{{ page.path }}">{{ page.path }}</a>
+
+        <span class="page-list-meta">
+          {% if page.commentCount > 0 %}
+            <i class="fa fa-comment"></i>{{ page.commentCount }}
+          {% endif  %}
+
+          {% if !page.isPublic() %}
+            <i class="fa fa-lock"></i>
+          {% endif %}
+        </span>
 
-        {% if !page.isPublic() %}
-          <i class="fa fa-lock"></i>
-        {% endif %}
         <br />
       {% endfor %}
 

+ 0 - 1
public/bower_components

@@ -1 +0,0 @@
-../bower_components

+ 1 - 0
public/js/reveal.js

@@ -0,0 +1 @@
+../../node_modules/reveal.js/

+ 90 - 0
resource/css/_comment.scss

@@ -0,0 +1,90 @@
+.crowi.main-container aside.sidebar .side-content {
+
+.page-comments {
+  margin: 8px 0 0 0;
+
+  .page-comment-form {
+    margin-top: 16px;
+
+    .comment-form {
+    }
+
+    .comment-form-main {
+
+      .comment-form-comment {
+        height: 60px;
+      }
+
+      .comment-submit {
+        margin-top: 8px;
+        text-align: right;
+      }
+    }
+  }
+
+  .page-comments-list {
+    .page-comments-list-toggle-newer,
+    .page-comments-list-toggle-older {
+      text-align: center;
+      display: block;
+      margin: 8px;
+      font-size: .9em;
+      color: #999;
+    }
+    .page-comment {
+      margin-top: 8px;
+      padding-top: 8px;
+      border-top: solid 1px #ccc;
+
+      .picture {
+        float: left;
+        width: 24px;
+        height: 24px;
+      }
+
+
+      .page-comment-main {
+        margin-left: 40px;
+
+        .page-comment-creator {
+          font-weight: bold;
+        }
+        .page-comment-meta {
+          color: #aaa;
+          font-size: .9em;
+        }
+        .page-comment-body {
+          padding: 8px 0;
+        }
+      }
+    }
+
+    .page-comment.page-comment-me {
+      //color: lighten($crowiHeaderBackground, 65%);
+      color: $crowiHeaderBackground;
+      .page-comment-main {
+
+        .page-comment-body,
+        .page-comment-creator,
+        .page-comment-meta {
+        }
+
+        .page-comment-meta {
+          //background: lighten($crowiHeaderBackground, 65%);
+        }
+      }
+    }
+
+    .page-comment.page-comment-old {
+      opacity: .7;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+  }
+}
+
+
+
+} // .crowi.main-container aside.sidebar .side-content

+ 79 - 50
resource/css/_layout.scss

@@ -1,5 +1,5 @@
 .crowi { // {{{
-  font-family: 'Maven Pro', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Meiryo', sans-serif;
+  font-family: 'Open Sans', 'Helvetica Neue', 'Hiragino Kaku Gothic Pro', 'Meiryo', sans-serif;
   h1, h2, h3, h4, h5, h6 {
     font-weight: 500;
   }
@@ -168,6 +168,7 @@
 
       .side-content {
         margin-bottom: $crowiFooterHeight + $crowiHeaderHeight;
+        color: #666;
         padding: 15px;
 
         h3 {
@@ -179,33 +180,6 @@
           &:hover { color: #aaa;}
         }
 
-        ul.revision-history {
-          padding: 0;
-          li {
-            position: relative;
-            list-style: none;
-
-            a {
-              color: #666;
-              padding: 3px 5px 3px 40px;
-              display: block;
-
-              &:hover {
-                background: darken($crowiAsideBackground, 10%);
-                text-decoration: none;
-                color: darken($link-color, 35%);
-              }
-            }
-
-          }
-
-          .picture {
-            position: absolute;
-            left: 5px;
-            top: 12px;
-          }
-        }
-
         ul.fitted-list {
           padding-left: 0;
           li {
@@ -289,6 +263,15 @@
 
     } // }}}
 
+    .page-list {
+      .page-list-link {
+      }
+      .page-list-meta {
+        font-size: .9em;
+        color: #999;
+      }
+    }
+
     .main.grant-restricted,
     .main.grant-specified,
     .main.grant-owner {
@@ -306,12 +289,6 @@
     }
 
     .page-attachments {
-      background: #f0f0f0;
-      padding: 10px;
-      font-size: 0.9em;
-      color: #888;
-      margin: 10px 0;
-      border-radius: 5px;
       p {
         font-weight: bold;
       }
@@ -374,24 +351,76 @@
   .content-main {
     .tab-content {
       margin-top: 30px;
+
+      .revision-history {
+        h1 {
+          padding-bottom: 0.3em;
+          font-size: 2.3em;
+          font-weight: bold;
+          border-bottom: solid 1px #ccc;
+        }
+
+
+        .revision-history-list {
+          .revision-hisory-outer {
+            margin-top: 8px;
+
+            .picture {
+              float: left;
+              width: 32px;
+              height: 32px;
+            }
+
+            .revision-history-main {
+              margin-left: 40px;
+
+              .revision-history-author {
+              }
+              .revision-history-comment {
+              }
+              .revision-history-meta {
+              }
+            }
+          }
+
+          li {
+            position: relative;
+            list-style: none;
+
+            a {
+              color: #666;
+              padding: 3px 5px 3px 40px;
+              display: block;
+
+              &:hover {
+                background: darken($crowiAsideBackground, 10%);
+                text-decoration: none;
+                color: darken($link-color, 35%);
+              }
+            }
+
+          }
+        }
+
+      }
     }
   }
 
   .content-main .timeline-body { // {{{ timeline
-     .revision-path {
-       margin-top: 1.6em;
-       margin-bottom: 0;
-       border: solid 2px #ddd;
-       border-bottom: none;
-       padding: 16px;
-       background: #ddd;
-     }
-     .revision-body {
-       font-size: 14px;
-       border: solid 2px #ddd;
-       padding: 16px;
-       background: #fdfdfd;
-     }
+    .revision-path {
+      margin-top: 1.6em;
+      margin-bottom: 0;
+      border: solid 2px #ddd;
+      border-bottom: none;
+      padding: 16px;
+      background: #ddd;
+    }
+    .revision-body {
+      font-size: 14px;
+      border: solid 2px #ddd;
+      padding: 16px;
+      background: #fdfdfd;
+    }
   } // }}}
 
   // on-edit
@@ -672,8 +701,8 @@
       }
 
       .meta {
-        border-top: solid 1px #999;
-        margin-top: 20px;
+        border-top: solid 1px #ccc;
+        margin-top: 32px;
         color: #666;
       }
 

+ 12 - 4
resource/css/crowi.scss

@@ -11,6 +11,7 @@
 @import 'form';
 @import 'wiki';
 @import 'admin';
+@import 'comment';
 
 
 ul {
@@ -19,12 +20,19 @@ ul {
 
 
 .meta {
+
+  margin-top: 32px;
+  padding: 16px;
+  color: #666;
+  border-top: solid 1px #ccc;
   background: #f0f0f0;
-  padding: 10px;
-  font-size: 0.9em;
+  font-size: 0.95em;
   color: #888;
-  margin-top: 10px;
-  border-radius: 5px;
+
+  .picture {
+    width: 16px;
+    height: 16px;
+  }
 }
 
 .help-block {

+ 144 - 0
resource/js/crowi.js

@@ -195,7 +195,28 @@ Crowi.renderer.prototype = {
   }
 };
 
+// original: middleware.swigFilter
+Crowi.userPicture = function (user) {
+  if (!user) {
+    return '/images/userpicture.png';
+  }
+
+  if (user.image && user.image != '/images/userpicture.png') {
+    return user.image;
+  } else if (user.fbId) {
+    return '//graph.facebook.com/' + user.fbId + '/picture?size=square';
+  } else {
+    return '/images/userpicture.png';
+  }
+};
+
+
 $(function() {
+  var pageId = $('#content-main').data('page-id');
+  var revisionId = $('#content-main').data('page-revision-id');
+  var revisionCreatedAt = $('#content-main').data('page-revision-created');
+  var currentUser = $('#content-main').data('current-user');
+
   Crowi.linkPath();
 
   $('[data-toggle="tooltip"]').tooltip();
@@ -248,5 +269,128 @@ $(function() {
     return false;
   });
 
+
+  if (pageId) {
+
+    // omg
+    function createCommentHTML(revision, creator, comment, commentedAt) {
+      var $comment = $('<div>');
+      var $commentImage = $('<img class="picture picture-rounded">')
+        .attr('src', Crowi.userPicture(creator));
+      var $commentCreator = $('<div class="page-comment-creator">')
+        .text(creator.username);
+
+      var $commentRevision = $('<a class="page-comment-revision label">')
+        .attr('href', '?revision=' + revision)
+        .text(revision.substr(0,8));
+      if (revision !== revisionId) {
+        $commentRevision.addClass('label-default');
+      } else {
+        $commentRevision.addClass('label-primary');
+      }
+
+      var $commentMeta = $('<div class="page-comment-meta">')
+        .text(commentedAt + ' ')
+        .append($commentRevision);
+
+      var $commentBody = $('<div class="page-comment-body">')
+        .html(comment.replace(/(\r\n|\r|\n)/g, '<br>'));
+
+      var $commentMain = $('<div class="page-comment-main">')
+        .append($commentCreator)
+        .append($commentBody)
+        .append($commentMeta)
+
+      $comment.addClass('page-comment');
+      if (creator._id === currentUser) {
+        $comment.addClass('page-comment-me');
+      }
+      if (revision !== revisionId) {
+        $comment.addClass('page-comment-old');
+      }
+      $comment
+        .append($commentImage)
+        .append($commentMain);
+
+      return $comment;
+    }
+
+    // get comments
+    var $pageCommentList = $('.page-comments-list');
+    var $pageCommentListNewer =   $('#page-comments-list-newer');
+    var $pageCommentListCurrent = $('#page-comments-list-current');
+    var $pageCommentListOlder =   $('#page-comments-list-older');
+    var hasNewer = false;
+    var hasOlder = false;
+    $.get('/_api/comments.get', {page_id: pageId}, function(res) {
+      if (res.ok) {
+        var comments = res.comments;
+        $.each(comments, function(i, comment) {
+          var commentContent = createCommentHTML(comment.revision, comment.creator, comment.comment, comment.createdAt);
+          if (comment.revision == revisionId) {
+            $pageCommentListCurrent.append(commentContent);
+          } else {
+            if (Date.parse(comment.createdAt)/1000 > revisionCreatedAt) {
+              $pageCommentListNewer.append(commentContent);
+              hasNewer = true;
+            } else {
+              $pageCommentListOlder.append(commentContent);
+              hasOlder = true;
+            }
+          }
+        });
+      }
+    }).fail(function(data) {
+
+    }).always(function() {
+      if (!hasNewer) {
+        $('.page-comments-list-toggle-newer').hide();
+      }
+      if (!hasOlder) {
+        $pageCommentListOlder.addClass('collapse');
+        $('.page-comments-list-toggle-older').hide();
+      }
+    });
+
+    // post comment event
+    $('#page-comment-form').on('submit', function() {
+      $button = $('#commenf-form-button');
+      $button.attr('disabled', 'disabled');
+      $.post('/_api/comments.post', $(this).serialize(), function(data) {
+        $button.removeAttr('disabled');
+        if (data.ok) {
+          var comment = data.comment;
+
+          $pageCommentList.prepend(createCommentHTML(comment.revision, comment.creator, comment.comment, comment.createdAt));
+          $('#comment-form-comment').val('');
+          $('#comment-form-message').text('');
+        } else {
+          $('#comment-form-message').text(data.error);
+        }
+      }).fail(function(data) {
+        if (data.status !== 200) {
+          $('#comment-form-message').text(data.statusText);
+        }
+      });
+
+      return false;
+    });
+
+    // attachment
+    var $pageAttachmentList = $('.page-attachments ul');
+    $.get('/_api/attachment/page/' + pageId, function(res) {
+      var attachments = res.data.attachments;
+      var urlBase = res.data.fileBaseUrl;
+      if (attachments.length > 0) {
+        $.each(attachments, function(i, file) {
+          $pageAttachmentList.append(
+          '<li><a href="' + urlBase + file.filePath + '">' + (file.originalName || file.fileName) + '</a> <span class="label label-default">' + file.fileFormat + '</span></li>'
+          );
+        })
+      } else {
+        $('.page-attachments').remove();
+      }
+    });
+  }
 });