فهرست منبع

Merge pull request #90 from weseek/master

release v1.1.12
Yuki Takei 9 سال پیش
والد
کامیت
7bcb0d63ea

+ 5 - 1
CHANGES.md

@@ -1,9 +1,13 @@
 CHANGES
 ========
 
+## 1.1.12
+
+* Feature: Remove Comment Button
+
 ## 1.1.11
 
-* Fix: omit comment form from page_list (crowi-plus Enhanced Layout)
+* Fix: Omit Comment form from page_list (crowi-plus Enhanced Layout)
 * Fix: .search-box is broken on sm/xs screen
 
 ## 1.1.10

+ 1 - 1
lib/models/comment.js

@@ -2,7 +2,7 @@ module.exports = function(crowi) {
   var debug = require('debug')('crowi:models:comment')
     , mongoose = require('mongoose')
     , ObjectId = mongoose.Schema.Types.ObjectId
-    , USER_PUBLIC_FIELDS = '_id image googleId name username email status createdAt' // TODO: どこか別の場所へ...
+    , USER_PUBLIC_FIELDS = '_id image isGravatarEnabled googleId name username email status createdAt' // TODO: どこか別の場所へ...
     , commentSchema
   ;
 

+ 21 - 0
lib/routes/comment.js

@@ -72,5 +72,26 @@ module.exports = function(crowi, app) {
       });
   };
 
+  /**
+   * @api {post} /comments.remove Remove specified comment
+   * @apiName RemoveComment
+   * @apiGroup Comment
+   *
+   * @apiParam {String} comment_id Comment Id.
+   */
+  api.remove = function(req, res){
+    var commentId = req.body.comment_id;
+    if (!commentId) {
+      return res.json(ApiResponse.error(`'comment_id' is undefined`));
+    }
+
+    return Comment.remove({_id: commentId})
+      .then(function() {
+        return res.json(ApiResponse.success({}));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+  };
+
   return actions;
 };

+ 1 - 0
lib/routes/index.js

@@ -119,6 +119,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
+  app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);
   app.get( '/_api/bookmarks.get'      , accessTokenParser , loginRequired(crowi, app, false) , bookmark.api.get);
   app.post('/_api/bookmarks.add'      , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.add);
   app.post('/_api/bookmarks.remove'   , accessTokenParser , loginRequired(crowi, app) , csrf, bookmark.api.remove);

+ 3 - 0
lib/views/crowi-plus/widget/comments.html

@@ -5,6 +5,7 @@
     <h4><i class="fa fa-comments"></i> Comments</h4>
 
     <div class="page-comments-list" id="page-comments-list">
+      {# transplanted to PageComments React component -- 2017.06.02 Yuki Takei
       <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>
@@ -14,6 +15,7 @@
       <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>
 
     {% if page and not page.isDeleted() %}
@@ -42,6 +44,7 @@
         </div>
       </div>
     </form>
+    <div id="page-comment-form-behavior"></div>
     {% endif %}
 
   </div>

+ 1 - 0
lib/views/widget/page_side_content.html

@@ -28,6 +28,7 @@
       </div>
     </div>
   </form>
+  <div id="page-comment-form-behavior"></div>
 
   <div class="page-comments-list" id="page-comments-list">
     <div class="page-comments-list-newer collapse" id="page-comments-list-newer"></div>

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "crowi-plus",
-  "version": "1.1.11-RC",
+  "version": "1.1.12-RC",
   "description": "Enhanced Crowi",
   "tags": [
     "wiki",

+ 37 - 5
resource/css/_comment.scss

@@ -1,3 +1,20 @@
+.crowi.main-container {
+  .page-comment-delete-modal .modal-content {
+    .modal-body {
+      .comment-body {
+        background-color: #eee;
+        padding: .5em;
+        margin-top: .5em;
+        border-radius: 4px;
+
+        // scrollable
+        overflow-y: auto;
+        max-height: 13em;
+      }
+    }
+  }
+}
+
 .crowi.main-container aside.sidebar .side-content {
 
 .page-comments {
@@ -42,13 +59,14 @@
         height: 24px;
       }
 
+      .page-comment-creator {
+        font-weight: bold;
+      }
 
       .page-comment-main {
+        position: relative;
         margin-left: 40px;
 
-        .page-comment-creator {
-          font-weight: bold;
-        }
         .page-comment-meta {
           color: #aaa;
           font-size: .9em;
@@ -57,6 +75,21 @@
           padding: 8px 0;
           word-wrap: break-word;
         }
+        .page-comment-control {
+          position: absolute;
+          display: none;    // default hidden
+          top: 0;
+          right: 0;
+
+          a {
+            padding: 3px;
+          }
+        }
+      }
+
+      // show controls when hover
+      .page-comment-main:hover > .page-comment-control {
+        display: block;
       }
     }
 
@@ -74,6 +107,7 @@
           //background: lighten($crowiHeaderBackground, 65%);
         }
       }
+
     }
 
     .page-comment.page-comment-old {
@@ -86,6 +120,4 @@
   }
 }
 
-
-
 } // .crowi.main-container aside.sidebar .side-content

+ 54 - 21
resource/css/_comment_crowi-plus.scss

@@ -1,28 +1,28 @@
 .crowi-plus.main-container  {
 
   %comment-section {
+    position: relative;
     background: #f5f5f5;
-      padding: 1em;
-      position: relative;
-      margin-left: 4.5em;
-      margin-bottom: 1em;
-      border-radius: 3px;
-      &:before {
-        border: 1em solid transparent;
-        border-right-color:#f5f5f5;
-        border-left-width: 0;
-        left: -1em;
-        content: "";
-        display: block;
-        top: 1.5em;
-        position: absolute;
-        width: 0;
-      }
+    padding: 1em;
+    margin-left: 4.5em;
+    margin-bottom: 1em;
+    border-radius: 3px;
+    &:before {
+      border: 1em solid transparent;
+      border-right-color:#f5f5f5;
+      border-left-width: 0;
+      left: -1em;
+      content: "";
+      display: block;
+      top: 1.5em;
+      position: absolute;
+      width: 0;
+    }
   }
 
   %picture-rounded {
-    position: absolute;
-    top: 1em;
+    float: left;
+    margin-top: 0.8em;
     width: 3em;
     height: 3em;
   }
@@ -41,6 +41,13 @@
   .page-comment {
     position: relative;
 
+    // ユーザー名
+    .page-comment-creator {
+      margin-top: -0.5em;
+      margin-bottom: 0.5em;
+      font-weight: bold;
+    }
+
     // ユーザーアイコン
     .picture.picture-rounded {
       @extend %picture-rounded;
@@ -56,9 +63,35 @@
       margin-bottom: 0.5em;
     }
 
-    // ユーザー名
-    .page-comment-creator {
-      margin-bottom: 0.5em;
+    .page-comment-meta {
+      color: #999;
+      font-size: .9em;
+      text-align: right;
+
+      * {
+        vertical-align: 25%;
+      }
+    }
+
+    .page-comment-control {
+      position: absolute;
+      display: none;    // default hidden
+      top: 0;
+      right: 0;
+    }
+
+  }
+
+  // show when hover
+  .page-comment-main:hover > .page-comment-control {
+    display: block;
+  }
+
+  .page-comment-old {
+    opacity: .7;
+
+    &:hover {
+      opacity: 1;
     }
   }
 

+ 5 - 4
resource/css/_page.scss

@@ -238,10 +238,11 @@
         font-size: 1.1em;
       }
 
-      a {
-        color: #ccc;
-        &:hover { color: #aaa;}
-      }
+      // disabled in crowi-plus -- 2017.06.03 Yuki Takei
+      // a {
+      //   color: #ccc;
+      //   &:hover { color: #aaa;}
+      // }
 
       ul.fitted-list {
         padding-left: 0;

+ 21 - 3
resource/js/app.js

@@ -8,12 +8,13 @@ import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
+import PageComments     from './components/PageComments';
+import PageCommentFormBehavior from './components/PageCommentFormBehavior';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
 import RevisionUrl      from './components/Page/RevisionUrl';
 import BookmarkButton   from './components/BookmarkButton';
-//import PageComment  from './components/PageComment';
 
 if (!window) {
   window = {};
@@ -21,10 +22,14 @@ if (!window) {
 
 const mainContent = document.querySelector('#content-main');
 let pageId = null;
+let pageRevisionId = null;
+let pageRevisionCreatedAt = null;
 let pagePath;
 let pageContent = null;
 if (mainContent !== null) {
   pageId = mainContent.attributes['data-page-id'].value;
+  pageRevisionId = mainContent.attributes['data-page-revision-id'].value;
+  pageRevisionCreatedAt = +mainContent.attributes['data-page-revision-created'].value;
   pagePath = mainContent.attributes['data-path'].value;
   const rawText = document.getElementById('raw-text-original');
   if (rawText) {
@@ -51,29 +56,42 @@ if (isEnabledPlugins) {
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
+/**
+ * define components
+ *  key: id of element
+ *  value: React Element
+ */
 const componentMappings = {
   'search-top': <HeaderSearchBox crowi={crowi} />,
   'search-page': <SearchPage crowi={crowi} />,
   'page-list-search': <PageListSearch crowi={crowi} />,
+  'page-comments-list': <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt= {pageRevisionCreatedAt} crowi={crowi} />,
   'page-attachment': <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />,
 
   //'revision-history': <PageHistory pageId={pageId} />,
-  //'page-comment': <PageComment />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
 };
+// additional definitions if pagePath exists
 if (pagePath) {
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} />;
   componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
 }
 
+let componentInstances = {};
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   if (elem) {
-    ReactDOM.render(componentMappings[key], elem);
+    componentInstances[key] = ReactDOM.render(componentMappings[key], elem);
   }
 });
 
+// render components with refs to another component
+const elem = document.getElementById('page-comment-form-behavior');
+if (elem) {
+  ReactDOM.render(<PageCommentFormBehavior crowi={crowi} pageComments={componentInstances['page-comments-list']} />, elem);
+}
+
 // うわーもうー
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
   ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));

+ 94 - 0
resource/js/components/PageComment/Comment.js

@@ -0,0 +1,94 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import moment from 'moment/src/moment';
+
+import ReactUtils from '../ReactUtils';
+import UserPicture from '../User/UserPicture';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class Comment
+ * @extends {React.Component}
+ */
+export default class Comment extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
+    this.isCurrentRevision = this.isCurrentRevision.bind(this);
+    this.getRootClassName = this.getRootClassName.bind(this);
+    this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
+    this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
+  }
+
+  isCurrentUserEqualsToAuthor() {
+    return this.props.comment.creator.username === this.props.currentUserId;
+  }
+
+  isCurrentRevision() {
+    return this.props.comment.revision === this.props.currentRevisionId;
+  }
+
+  getRootClassName() {
+    return "page-comment "
+        + (this.isCurrentUserEqualsToAuthor() ? 'page-comment-me' : '')
+        + (this.isCurrentRevision() ? '': 'page-comment-old');
+  }
+
+  getRevisionLabelClassName() {
+    return 'page-comment-revision label '
+        + (this.isCurrentRevision() ? 'label-primary' : 'label-default');
+  }
+
+  deleteBtnClickedHandler() {
+    this.props.deleteBtnClicked(this.props.comment);
+  }
+
+  render() {
+    const comment = this.props.comment;
+    const creator = comment.creator;
+
+    const rootClassName = this.getRootClassName();
+    const commentDate = moment(comment.createdAt).format('YYYY/MM/DD HH:mm');
+    const commentBody = ReactUtils.nl2br(comment.comment);
+    const creatorsPage = `/user/${creator.username}`;
+    const revHref = `?revision=${comment.revision}`;
+    const revFirst8Letters = comment.revision.substr(0,8);
+    const revisionLavelClassName = this.getRevisionLabelClassName();
+
+    return (
+      <div className={rootClassName}>
+        <a href={creatorsPage}>
+          <UserPicture user={creator} />
+        </a>
+        <div className="page-comment-main">
+          <div className="page-comment-creator">
+            <a href={creatorsPage}>{creator.username}</a>
+          </div>
+          <div className="page-comment-body">{commentBody}</div>
+          <div className="page-comment-meta">
+            {commentDate}&nbsp;
+            <a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a>
+          </div>
+          <div className="page-comment-control">
+            <a className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+              <i className="fa fa-trash-o"></i>
+            </a>
+          </div>
+        </div>
+      </div>
+    );
+  }
+}
+
+Comment.propTypes = {
+  comment: PropTypes.object.isRequired,
+  currentRevisionId: PropTypes.string.isRequired,
+  currentUserId: PropTypes.string.isRequired,
+  deleteBtnClicked: PropTypes.func.isRequired,
+};

+ 60 - 0
resource/js/components/PageComment/DeleteCommentModal.js

@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { Button, Modal } from 'react-bootstrap';
+import moment from 'moment/src/moment';
+
+import ReactUtils from '../ReactUtils';
+import UserPicture from '../User/UserPicture';
+
+export default class DeleteCommentModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentWillMount() {
+  }
+
+  render() {
+    if (this.props.comment === undefined) {
+      return <div></div>
+    }
+
+    const comment = this.props.comment;
+    const commentDate = moment(comment.createdAt).format('YYYY/MM/DD HH:mm');
+
+    // generate body
+    let commentBody = comment.comment;
+    if (commentBody.length > 200) { // omit
+      commentBody = commentBody.substr(0,200) + '...';
+    }
+    commentBody = ReactUtils.nl2br(commentBody);
+
+    return (
+      <Modal show={this.props.isShown} onHide={this.props.cancel} className="page-comment-delete-modal">
+        <Modal.Header closeButton>
+          <Modal.Title>Delete comment?</Modal.Title>
+        </Modal.Header>
+        <Modal.Body>
+          <UserPicture user={comment.creator} size="xs" /> <strong>{comment.creator.username}</strong> wrote on {commentDate}:
+          <p className="comment-body">{commentBody}</p>
+        </Modal.Body>
+        <Modal.Footer>
+          <span className="text-danger">{this.props.errorMessage}</span>&nbsp;
+          <Button onClick={this.props.cancel}>Cancel</Button>
+          <Button onClick={this.props.confirmedToDelete} className="btn-danger">Delete</Button>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+DeleteCommentModal.propTypes = {
+  isShown: PropTypes.bool.isRequired,
+  comment: PropTypes.object,
+  errorMessage: PropTypes.string,
+  cancel: PropTypes.func.isRequired,            // for cancel evnet handling
+  confirmedToDelete: PropTypes.func.isRequired, // for confirmed event handling
+};

+ 64 - 0
resource/js/components/PageCommentFormBehavior.js

@@ -0,0 +1,64 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import PageComments from './PageComments';
+
+/**
+ * Set the behavior that post comments to #page-comment-form
+ *
+ * This is transplanted from legacy/crowi.js -- 2017.06.03 Yuki Takei
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageCommentFormBehavior
+ * @extends {React.Component}
+ */
+export default class PageCommentFormBehavior extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentWillMount() {
+    const pageComments = this.props.pageComments;
+
+    if (pageComments === undefined) {
+      return;
+    }
+
+    $('#page-comment-form').on('submit', function() {
+      var $button = $('#comment-form-button');
+      $button.attr('disabled', 'disabled');
+      $.post('/_api/comments.add', $(this).serialize(), function(data) {
+        $button.prop('disabled', false);
+        if (data.ok) {
+
+          // reload comments
+          pageComments.init();
+
+          $('#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;
+    });
+  }
+
+  render() {
+    // render nothing
+    return <div></div>
+  }
+}
+
+PageCommentFormBehavior.propTypes = {
+  pageComments: React.PropTypes.instanceOf(PageComments),
+  crowi: PropTypes.object.isRequired,
+};

+ 195 - 0
resource/js/components/PageComments.js

@@ -0,0 +1,195 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Comment from './PageComment/Comment';
+import DeleteCommentModal from './PageComment/DeleteCommentModal';
+
+/**
+ * Load data of comments and render the list of <Comment />
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class PageComments
+ * @extends {React.Component}
+ */
+export default class PageComments extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      comments: [],
+
+      // for deleting comment
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    };
+
+    this.init = this.init.bind(this);
+    this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
+    this.deleteComment = this.deleteComment.bind(this);
+    this.showDeleteConfirmModal = this.showDeleteConfirmModal.bind(this);
+    this.closeDeleteConfirmModal = this.closeDeleteConfirmModal.bind(this);
+  }
+
+  componentWillMount() {
+    const pageId = this.props.pageId;
+
+    if (pageId) {
+      this.init();
+    }
+  }
+
+  init() {
+    if (!this.props.pageId) {
+      return ;
+    }
+
+    const pageId = this.props.pageId;
+
+    this.props.crowi.apiGet('/comments.get', {page_id: pageId})
+    .then(res => {
+      if (res.ok) {
+        this.setState({comments: res.comments});
+      }
+    }).catch(err => {
+
+    });
+
+  }
+
+  confirmToDeleteComment(comment) {
+    this.setState({commentToDelete: comment});
+    this.showDeleteConfirmModal();
+  }
+
+  deleteComment() {
+    const comment = this.state.commentToDelete;
+
+    this.props.crowi.apiPost('/comments.remove', {comment_id: comment._id})
+    .then(res => {
+      if (res.ok) {
+        this.findAndSplice(comment);
+      }
+      this.closeDeleteConfirmModal();
+    }).catch(err => {
+      this.setState({errorMessageForDeleting: err.message});
+    });
+  }
+
+  findAndSplice(comment) {
+    let comments = this.state.comments;
+
+    const index = comments.indexOf(comment);
+    if (index < 0) {
+      return;
+    }
+    comments.splice(index, 1);
+
+    this.setState({comments});
+  }
+
+  showDeleteConfirmModal() {
+    this.setState({isDeleteConfirmModalShown: true});
+  }
+
+  closeDeleteConfirmModal() {
+    this.setState({
+      commentToDelete: undefined,
+      isDeleteConfirmModalShown: false,
+      errorMessageForDeleting: undefined,
+    });
+  }
+
+  /**
+   * generate Elements of Comment
+   *
+   * @param {any} comments Array of Comment Model Obj
+   *
+   * @memberOf PageComments
+   */
+  generateCommentElements(comments) {
+    return comments.map((comment) => {
+      return (
+        <Comment key={comment._id} comment={comment}
+          currentUserId={this.props.crowi.me}
+          currentRevisionId={this.props.revisionId}
+          deleteBtnClicked={this.confirmToDeleteComment} />
+      );
+    });
+  }
+
+  render() {
+    let currentComments = [];
+    let newerComments = [];
+    let olderComments = [];
+
+    // divide by revisionId and createdAt
+    const revisionId = this.props.revisionId;
+    const revisionCreatedAt = this.props.revisionCreatedAt;
+    this.state.comments.forEach((comment) => {
+      if (comment.revision == revisionId) {
+        currentComments.push(comment);
+      }
+      else if (Date.parse(comment.createdAt)/1000 > revisionCreatedAt) {
+        newerComments.push(comment);
+      }
+      else {
+        olderComments.push(comment);
+      }
+    });
+
+    // generate elements
+    let currentElements = this.generateCommentElements(currentComments);
+    let newerElements = this.generateCommentElements(newerComments);
+    let olderElements = this.generateCommentElements(olderComments);
+
+    let toggleNewer = (newerElements.length === 0)
+      ? <div></div>
+      : (
+        <a className="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer">
+          <i className="fa fa-angle-double-up"></i> Comments for Newer Revision <i className="fa fa-angle-double-up"></i>
+        </a>
+      )
+    let toggleOlder = (olderElements.length === 0)
+      ? <div></div>
+      : (
+        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
+          <i className="fa fa-angle-double-down"></i> Comments for Older Revision <i className="fa fa-angle-double-down"></i>
+        </a>
+      )
+
+    return (
+      <div>
+        <div className="page-comments-list-newer collapse" id="page-comments-list-newer">
+          {newerElements}
+        </div>
+        {toggleNewer}
+        <div className="page-comments-list-current" id="page-comments-list-current">
+          {currentElements}
+        </div>
+        {toggleOlder}
+        <div className="page-comments-list-older collapse in" id="page-comments-list-older">
+          {olderElements}
+        </div>
+
+        <DeleteCommentModal
+          isShown={this.state.isDeleteConfirmModalShown}
+          comment={this.state.commentToDelete}
+          errorMessage={this.state.errorMessageForDeleting}
+          cancel={this.closeDeleteConfirmModal}
+          confirmedToDelete={this.deleteComment}
+        />
+      </div>
+    );
+  }
+}
+
+PageComments.propTypes = {
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  revisionCreatedAt: PropTypes.number,
+  crowi: PropTypes.object.isRequired,
+};

+ 28 - 0
resource/js/components/ReactUtils.js

@@ -0,0 +1,28 @@
+import React from 'react';
+
+export default class ReactUtils {
+
+  /**
+   * show '\n' as '<br>'
+   *
+   * @see http://qiita.com/kouheiszk/items/e7c74ab5eab901f89a7f
+   *
+   * @static
+   * @param {any} text
+   * @returns
+   *
+   * @memberOf ReactUtils
+   */
+  static nl2br(text) {
+    var regex = /(\n)/g
+    return text.split(regex).map(function (line) {
+      if (line.match(regex)) {
+        return React.createElement('br', {key: Math.random().toString(10).substr(2, 10)})
+      }
+      else {
+        return line;
+      }
+    });
+  }
+
+}

+ 6 - 0
resource/js/legacy/crowi.js

@@ -482,6 +482,10 @@ $(function() {
       });
     }
 
+    /*
+     * transplanted to React components -- 2017.06.02 Yuki Takei
+     *
+
     // omg
     function createCommentHTML(revision, creator, comment, commentedAt) {
       var $comment = $('<div>');
@@ -587,6 +591,8 @@ $(function() {
       return false;
     });
 
+    */
+
     // Like
     var $likeButton = $('.like-button');
     var $likeCount = $('#like-count');