فهرست منبع

Merge pull request #1154 from weseek/master

release v3.5.7
Yuki Takei 6 سال پیش
والد
کامیت
85fc502135
34فایلهای تغییر یافته به همراه797 افزوده شده و 584 حذف شده
  1. 1 0
      .prettierignore
  2. 8 1
      CHANGES.md
  3. 1 1
      package.json
  4. 1 1
      src/client/js/components/BookmarkButton.jsx
  5. 1 1
      src/client/js/components/LikeButton.jsx
  6. 1 1
      src/client/js/components/PageAttachment.jsx
  7. 104 92
      src/client/js/components/PageComment/Comment.jsx
  8. 74 95
      src/client/js/components/PageComment/CommentEditor.jsx
  9. 33 40
      src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx
  10. 61 81
      src/client/js/components/PageComments.jsx
  11. 173 96
      src/client/js/components/PageEditorByHackmd.jsx
  12. 17 6
      src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx
  13. 4 0
      src/client/js/components/TableOfContents.jsx
  14. 1 1
      src/client/js/services/AppContainer.js
  15. 3 3
      src/client/js/services/PageContainer.js
  16. 1 1
      src/client/styles/bootstrap4/_utilities.scss
  17. 2 2
      src/client/styles/bootstrap4/_variables.scss
  18. 19 22
      src/client/styles/scss/_comment.scss
  19. 5 0
      src/client/styles/scss/_comment_crowi.scss
  20. 28 11
      src/client/styles/scss/_comment_growi.scss
  21. 63 22
      src/client/styles/scss/_comment_kibela.scss
  22. 1 0
      src/client/styles/scss/_mixins.scss
  23. 11 13
      src/client/styles/scss/_on-edit.scss
  24. 2 0
      src/client/styles/scss/_page.scss
  25. 67 52
      src/client/styles/scss/_page_growi.scss
  26. 1 0
      src/client/styles/scss/style-app.scss
  27. 3 0
      src/client/styles/scss/theme/_override-agileadmin.scss
  28. 0 5
      src/server/models/page.js
  29. 67 20
      src/server/routes/hackmd.js
  30. 1 0
      src/server/routes/index.js
  31. 12 7
      src/server/routes/page.js
  32. 5 0
      src/server/util/middlewares.js
  33. 23 7
      src/server/views/layout-growi/widget/header.html
  34. 3 3
      src/server/views/layout-kibela/widget/header.html

+ 1 - 0
.prettierignore

@@ -1 +1,2 @@
+src/client/styles/bootstrap4/
 src/client/styles/scss/_override-bootstrap-variables.scss

+ 8 - 1
CHANGES.md

@@ -1,6 +1,13 @@
 # CHANGES
 
-## 3.5.6-RC
+## 3.5.7-RC
+
+* Improvement: Show commented date with date distance format
+* Improvement: GROWI server obtains HackMD/CodiMD page id from the 302 response header
+* Improvement: Controls when HackMD/CodiMD has unsaved draft
+* Improvement: Show hints if HackMD/CodiMD integration is not working
+
+## 3.5.6
 
 * Fix: Saving new page is failed when empty string tag is set
 * Fix: Link of Create template page button in New Page Modal is broken

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.5.6-RC",
+  "version": "3.5.7-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",

+ 1 - 1
src/client/js/components/BookmarkButton.jsx

@@ -56,7 +56,7 @@ export default class BookmarkButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.crowi.me !== '';
+    return this.props.crowi.me != null;
   }
 
   render() {

+ 1 - 1
src/client/js/components/LikeButton.jsx

@@ -37,7 +37,7 @@ class LikeButton extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me !== '';
+    return this.props.appContainer.me != null;
   }
 
   render() {

+ 1 - 1
src/client/js/components/PageAttachment.jsx

@@ -89,7 +89,7 @@ class PageAttachment extends React.Component {
   }
 
   isUserLoggedIn() {
-    return this.props.appContainer.me !== '';
+    return this.props.appContainer.me != null;
   }
 
   render() {

+ 104 - 92
src/client/js/components/PageComment/Comment.jsx

@@ -1,8 +1,14 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { distanceInWordsStrict } from 'date-fns';
 import dateFnsFormat from 'date-fns/format';
 
+import Button from 'react-bootstrap/es/Button';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
+import Collapse from 'react-bootstrap/es/Collapse';
+
 import AppContainer from '../../services/AppContainer';
 import PageContainer from '../../services/PageContainer';
 
@@ -26,7 +32,7 @@ class Comment extends React.Component {
 
     this.state = {
       html: '',
-      isLayoutTypeGrowi: false,
+      isOlderRepliesShown: false,
     };
 
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
@@ -40,12 +46,6 @@ class Comment extends React.Component {
 
   componentWillMount() {
     this.renderHtml(this.props.comment.comment);
-    this.init();
-  }
-
-  init() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
   }
 
   componentWillReceiveProps(nextProps) {
@@ -65,9 +65,25 @@ class Comment extends React.Component {
     return this.props.comment.revision === this.props.pageContainer.state.revisionId;
   }
 
-  getRootClassName() {
-    return `page-comment ${
-      this.isCurrentUserEqualsToAuthor() ? 'page-comment-me ' : ''}`;
+  getRootClassName(comment) {
+    let className = 'page-comment';
+
+    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
+    if (comment.revision === revisionId) {
+      className += ' page-comment-current';
+    }
+    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
+      className += ' page-comment-newer';
+    }
+    else {
+      className += ' page-comment-older';
+    }
+
+    if (this.isCurrentUserEqualsToAuthor()) {
+      className += ' page-comment-me';
+    }
+
+    return className;
   }
 
   getRevisionLabelClassName() {
@@ -135,65 +151,67 @@ class Comment extends React.Component {
 
   }
 
+  renderReply(reply) {
+    return (
+      <div key={reply._id} className="page-comment-reply">
+        <CommentWrapper
+          comment={reply}
+          deleteBtnClicked={this.props.deleteBtnClicked}
+          growiRenderer={this.props.growiRenderer}
+        />
+      </div>
+    );
+  }
+
   renderReplies() {
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
+
     let replyList = this.props.replyList;
-    if (!isLayoutTypeGrowi) {
+    if (!isBaloonStyle) {
       replyList = replyList.slice().reverse();
     }
 
     const areThereHiddenReplies = replyList.length > 2;
 
-    const iconForOlder = <i className="icon-options-vertical"></i>;
-    const toggleOlder = areThereHiddenReplies
-      ? (
-        <a className="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older">
-          {iconForOlder} Read More
-        </a>
-      )
-      : <div></div>;
+    const { isOlderRepliesShown } = this.state;
+    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
+    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
+    const toggleButton = (
+      <Button
+        bsStyle="link"
+        className="page-comments-list-toggle-older"
+        onClick={() => { this.setState({ isOlderRepliesShown: !isOlderRepliesShown }) }}
+      >
+        {toggleButtonIcon} {toggleButtonLabel}
+      </Button>
+    );
 
     const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
     const hiddenReplies = replyList.slice(0, replyList.length - 2);
 
-    const toggleElements = hiddenReplies.map((reply) => {
-      return (
-        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-          <CommentWrapper
-            comment={reply}
-            deleteBtnClicked={this.props.deleteBtnClicked}
-            growiRenderer={this.props.growiRenderer}
-            replyList={[]}
-          />
-        </div>
-      );
+    const hiddenElements = hiddenReplies.map((reply) => {
+      return this.renderReply(reply);
     });
 
-    const toggleBlock = (
-      <div className="page-comments-list-older collapse out" id="page-comments-list-older">
-        {toggleElements}
-      </div>
-    );
-
-    const shownBlock = shownReplies.map((reply) => {
-      return (
-        <div key={reply._id} className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-          <CommentWrapper
-            comment={reply}
-            deleteBtnClicked={this.props.deleteBtnClicked}
-            growiRenderer={this.props.growiRenderer}
-            replyList={[]}
-          />
-        </div>
-      );
+    const shownElements = shownReplies.map((reply) => {
+      return this.renderReply(reply);
     });
 
     return (
-      <div>
-        {toggleBlock}
-        {toggleOlder}
-        {shownBlock}
-      </div>
+      <React.Fragment>
+        { areThereHiddenReplies && (
+          <div className="page-comments-hidden-replies">
+            <Collapse in={this.state.isOlderRepliesShown}>
+              <div>{hiddenElements}</div>
+            </Collapse>
+            <div className="text-center">{toggleButton}</div>
+          </div>
+        ) }
+
+        {shownElements}
+      </React.Fragment>
     );
   }
 
@@ -202,55 +220,46 @@ class Comment extends React.Component {
     const creator = comment.creator;
     const isMarkdown = comment.isMarkdown;
 
-    const rootClassName = this.getRootClassName();
-    const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
+    const rootClassName = this.getRootClassName(comment);
+    const commentDate = distanceInWordsStrict(comment.createdAt, new Date());
     const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
     const revisionLavelClassName = this.getRevisionLabelClassName();
 
-    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
-
-    let isNewer;
-    if (comment.revision === revisionId) {
-      isNewer = 'page-comments-list-current';
-    }
-    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-      isNewer = 'page-comments-list-newer';
-    }
-    else {
-      isNewer = 'page-comments-list-older';
-    }
-
+    const commentDateTooltip = (
+      <Tooltip id={`commentDateTooltip-${comment._id}`}>
+        {dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm')}
+      </Tooltip>
+    );
 
     return (
-      <div>
-        <div className={isNewer}>
-          <div className={rootClassName}>
-            <UserPicture user={creator} />
-            <div className="page-comment-main">
-              <div className="page-comment-creator">
-                <Username user={creator} />
-              </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">
-                <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
-                  <i className="ti-close"></i>
-                </button>
-              </div>
+      <React.Fragment>
+
+        <div className={rootClassName}>
+          <UserPicture user={creator} />
+          <div className="page-comment-main">
+            <div className="page-comment-creator">
+              <Username user={creator} />
+            </div>
+            <div className="page-comment-body">{commentBody}</div>
+            <div className="page-comment-meta">
+              <OverlayTrigger overlay={commentDateTooltip} placement="bottom">
+                <span>{commentDate}</span>
+              </OverlayTrigger>
+              <span className="ml-2"><a className={revisionLavelClassName} href={revHref}>{revFirst8Letters}</a></span>
+            </div>
+            <div className="page-comment-control">
+              <button type="button" className="btn btn-link" onClick={this.deleteBtnClickedHandler}>
+                <i className="ti-close"></i>
+              </button>
             </div>
           </div>
         </div>
-        <div className="container-fluid">
-          <div className="row">
-            {this.renderReplies()}
-          </div>
-        </div>
-      </div>
+
+        {this.renderReplies()}
+
+      </React.Fragment>
     );
   }
 
@@ -272,5 +281,8 @@ Comment.propTypes = {
   deleteBtnClicked: PropTypes.func.isRequired,
   replyList: PropTypes.array,
 };
+Comment.defaultProps = {
+  replyList: [],
+};
 
 export default CommentWrapper;

+ 74 - 95
src/client/js/components/PageComment/CommentEditor.jsx

@@ -36,7 +36,6 @@ class CommentEditor extends React.Component {
     const isUploadableFile = config.upload.file;
 
     this.state = {
-      isLayoutTypeGrowi: false,
       comment: '',
       isMarkdown: true,
       html: '',
@@ -60,15 +59,6 @@ class CommentEditor extends React.Component {
     this.toggleEditor = this.toggleEditor.bind(this);
   }
 
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
   updateState(value) {
     this.setState({ comment: value });
   }
@@ -214,7 +204,8 @@ class CommentEditor extends React.Component {
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const emojiStrategy = appContainer.getEmojiStrategy();
 
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
 
     const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
     const submitButton = (
@@ -229,98 +220,86 @@ class CommentEditor extends React.Component {
 
     return (
       <div className="form page-comment-form">
-
-        { username
-          && (
-          <div className="comment-form">
-            { isLayoutTypeGrowi
-              && (
-              <div className="comment-form-user">
-                <UserPicture user={user} />
-              </div>
-              )
-            }
-            <div className="comment-form-main">
-              <div className="comment-write">
-                <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
-                  <Tab eventKey={1} title="Write">
-                    <Editor
-                      ref={(c) => { this.editor = c }}
-                      value={this.state.comment}
-                      isGfmMode={this.state.isMarkdown}
-                      lineNumbers={false}
-                      isMobile={appContainer.isMobile}
-                      isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
-                      isUploadableFile={this.state.isUploadableFile}
-                      emojiStrategy={emojiStrategy}
-                      onChange={this.updateState}
-                      onUpload={this.uploadHandler}
-                      onCtrlEnter={this.postHandler}
-                    />
+        <div className="comment-form">
+          { isBaloonStyle && (
+            <div className="comment-form-user">
+              <UserPicture user={user} />
+            </div>
+          ) }
+          <div className="comment-form-main">
+            <div className="comment-write">
+              <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                <Tab eventKey={1} title="Write">
+                  <Editor
+                    ref={(c) => { this.editor = c }}
+                    value={this.state.comment}
+                    isGfmMode={this.state.isMarkdown}
+                    lineNumbers={false}
+                    isMobile={appContainer.isMobile}
+                    isUploadable={this.state.isUploadable && this.state.isLayoutTypeGrowi} // enable only when GROWI layout
+                    isUploadableFile={this.state.isUploadableFile}
+                    emojiStrategy={emojiStrategy}
+                    onChange={this.updateState}
+                    onUpload={this.uploadHandler}
+                    onCtrlEnter={this.postHandler}
+                  />
+                </Tab>
+                { this.state.isMarkdown && (
+                  <Tab eventKey={2} title="Preview">
+                    <div className="comment-form-preview">
+                      {commentPreview}
+                    </div>
                   </Tab>
-                  { this.state.isMarkdown
-                    && (
-                    <Tab eventKey={2} title="Preview">
-                      <div className="comment-form-preview">
-                        {commentPreview}
-                      </div>
-                    </Tab>
-                    )
-                  }
-                </Tabs>
-              </div>
-              <div className="comment-submit">
-                <div className="d-flex">
-                  <label style={{ flex: 1 }}>
-                    { isLayoutTypeGrowi && this.state.key === 1
-                      && (
-                      <span>
-                        <input
-                          type="checkbox"
-                          id="comment-form-is-markdown"
-                          name="isMarkdown"
-                          checked={this.state.isMarkdown}
-                          value="1"
-                          onChange={this.updateStateCheckbox}
-                        />
-                        <span className="ml-2">Markdown</span>
-                      </span>
-                      )
-                  }
-                  </label>
-                  <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
-                  { this.state.hasSlackConfig
-                    && (
-                    <div className="form-inline align-self-center mr-md-2">
-                      <SlackNotification
-                        isSlackEnabled={commentContainer.state.isSlackEnabled}
-                        slackChannels={commentContainer.state.slackChannels}
-                        onEnabledFlagChange={this.onSlackEnabledFlagChange}
-                        onChannelChange={this.onSlackChannelsChange}
+                ) }
+              </Tabs>
+            </div>
+            <div className="comment-submit">
+              <div className="d-flex">
+                <label style={{ flex: 1 }}>
+                  { isBaloonStyle && this.state.key === 1 && (
+                    <span>
+                      <input
+                        type="checkbox"
+                        id="comment-form-is-markdown"
+                        name="isMarkdown"
+                        checked={this.state.isMarkdown}
+                        value="1"
+                        onChange={this.updateStateCheckbox}
                       />
-                    </div>
-                    )
-                  }
-                  <div>
-                    <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.toggleEditor}>
-                      Cancel
-                    </Button>
+                      <span className="ml-2">Markdown</span>
+                    </span>
+                  ) }
+                </label>
+                <span className="hidden-xs">{ this.state.errorMessage && errorMessage }</span>
+                { this.state.hasSlackConfig
+                  && (
+                  <div className="form-inline align-self-center mr-md-2">
+                    <SlackNotification
+                      isSlackEnabled={commentContainer.state.isSlackEnabled}
+                      slackChannels={commentContainer.state.slackChannels}
+                      onEnabledFlagChange={this.onSlackEnabledFlagChange}
+                      onChannelChange={this.onSlackChannelsChange}
+                    />
                   </div>
-                  &nbsp;&nbsp;&nbsp;&nbsp;
-                  <div className="hidden-xs">{submitButton}</div>
+                  )
+                }
+                <div>
+                  <Button bsStyle="danger" className="fcbtn btn btn-xs btn-danger btn-outline btn-rounded" onClick={this.toggleEditor}>
+                    Cancel
+                  </Button>
                 </div>
-                <div className="visible-xs mt-2">
-                  <div className="d-flex justify-content-end">
-                    { this.state.errorMessage && errorMessage }
-                    <div>{submitButton}</div>
-                  </div>
+                &nbsp;&nbsp;&nbsp;&nbsp;
+                <div className="hidden-xs">{submitButton}</div>
+              </div>
+              <div className="visible-xs mt-2">
+                <div className="d-flex justify-content-end">
+                  { this.state.errorMessage && errorMessage }
+                  <div>{submitButton}</div>
                 </div>
               </div>
             </div>
           </div>
-          )
-        }
-
+        </div>
       </div>
     );
   }

+ 33 - 40
src/client/js/components/PageComment/CommentEditorLazyRenderer.jsx

@@ -14,7 +14,6 @@ class CommentEditorLazyRenderer extends React.Component {
 
     this.state = {
       isEditorShown: false,
-      isLayoutTypeGrowi: false,
     };
 
     this.growiRenderer = this.props.appContainer.getRenderer('comment');
@@ -22,15 +21,6 @@ class CommentEditorLazyRenderer extends React.Component {
     this.showCommentFormBtnClickHandler = this.showCommentFormBtnClickHandler.bind(this);
   }
 
-  componentWillMount() {
-    this.init();
-  }
-
-  init() {
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-  }
-
   showCommentFormBtnClickHandler() {
     this.setState({ isEditorShown: !this.state.isEditorShown });
   }
@@ -38,48 +28,51 @@ class CommentEditorLazyRenderer extends React.Component {
   render() {
     const { appContainer } = this.props;
     const username = appContainer.me;
+    const isLoggedIn = username != null;
     const user = appContainer.findUser(username);
-    const isLayoutTypeGrowi = this.state.isLayoutTypeGrowi;
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
+
+    if (!isLoggedIn) {
+      return <React.Fragment></React.Fragment>;
+    }
+
     return (
       <React.Fragment>
-        { !this.state.isEditorShown
-          && (
+
+        { !this.state.isEditorShown && (
           <div className="form page-comment-form">
-            { username
-              && (
-                <div className="comment-form">
-                  { isLayoutTypeGrowi
-                  && (
-                    <div className="comment-form-user">
-                      <UserPicture user={user} />
-                    </div>
-                  )
-                  }
-                  <div className="comment-form-main">
-                    <button
-                      type="button"
-                      className={`btn btn-lg ${this.state.isLayoutTypeGrowi ? 'btn-link' : 'btn-primary'} center-block`}
-                      onClick={this.showCommentFormBtnClickHandler}
-                    >
-                      <i className="icon-bubble"></i> Add Comment
-                    </button>
-                  </div>
+            <div className="comment-form">
+              { isBaloonStyle && (
+                <div className="comment-form-user">
+                  <UserPicture user={user} />
                 </div>
-              )
-            }
+              ) }
+              <div className="comment-form-main">
+                { !this.state.isEditorShown && (
+                  <button
+                    type="button"
+                    className={`btn btn-lg ${isBaloonStyle ? 'btn-link' : 'btn-primary'} center-block`}
+                    onClick={this.showCommentFormBtnClickHandler}
+                  >
+                    <i className="icon-bubble"></i> Add Comment
+                  </button>
+                ) }
+              </div>
+            </div>
           </div>
-          )
-        }
-        { this.state.isEditorShown
-          && (
+        ) }
+
+        { this.state.isEditorShown && (
           <CommentEditor
             growiRenderer={this.growiRenderer}
             replyTo={undefined}
             commentButtonClickedHandler={this.showCommentFormBtnClickHandler}
           >
           </CommentEditor>
-)
-        }
+        ) }
+
       </React.Fragment>
     );
   }

+ 61 - 81
src/client/js/components/PageComments.jsx

@@ -31,8 +31,6 @@ class PageComments extends React.Component {
     super(props);
 
     this.state = {
-      isLayoutTypeGrowi: false,
-
       // for deleting comment
       commentToDelete: undefined,
       isDeleteConfirmModalShown: false,
@@ -61,9 +59,6 @@ class PageComments extends React.Component {
       return;
     }
 
-    const layoutType = this.props.appContainer.getConfig().layoutType;
-    this.setState({ isLayoutTypeGrowi: layoutType === 'crowi-plus' || layoutType === 'growi' });
-
     this.props.commentContainer.retrieveComments();
   }
 
@@ -110,10 +105,10 @@ class PageComments extends React.Component {
     });
   }
 
-  // adds replies to specific comment object
-  addRepliesToComments(comment, replies) {
+  // get replies to specific comment object
+  getRepliesFor(comment, allReplies) {
     const replyList = [];
-    replies.forEach((reply) => {
+    allReplies.forEach((reply) => {
       if (reply.replyTo === comment._id) {
         replyList.push(reply);
       }
@@ -122,102 +117,87 @@ class PageComments extends React.Component {
   }
 
   /**
-   * generate Elements of Comment
+   * render Elements of Comment Thread
    *
-   * @param {any} comments Array of Comment Model Obj
+   * @param {any} comment Comment Model Obj
+   * @param {any} replies List of Reply Comment Model Obj
    *
    * @memberOf PageComments
    */
-  generateCommentElements(comments, replies) {
-    return comments.map((comment) => {
-
-      const commentId = comment._id;
-      const showEditor = this.state.showEditorIds.has(commentId);
-      const username = this.props.appContainer.me;
-
-      const replyList = this.addRepliesToComments(comment, replies);
-
-      return (
-        <div key={commentId}>
-          <Comment
-            comment={comment}
-            deleteBtnClicked={this.confirmToDeleteComment}
-            growiRenderer={this.growiRenderer}
-            replyList={replyList}
-          />
-          <div className="container-fluid">
-            <div className="row">
-              <div className="col-xs-offset-1 col-xs-11 col-sm-offset-1 col-sm-11 col-md-offset-1 col-md-11 col-lg-offset-1 col-lg-11">
-                { !showEditor && (
-                  <div>
-                    { username
-                    && (
-                      <div className="col-xs-offset-6 col-sm-offset-6 col-md-offset-6 col-lg-offset-6">
-                        <Button
-                          bsStyle="primary"
-                          className="fcbtn btn btn-outline btn-rounded btn-xxs"
-                          onClick={() => { return this.replyButtonClickedHandler(commentId) }}
-                        >
-                          Reply <i className="fa fa-mail-reply"></i>
-                        </Button>
-                      </div>
-                    )
-                  }
-                  </div>
-                )}
-                { showEditor && (
-                  <CommentEditor
-                    growiRenderer={this.growiRenderer}
-                    replyTo={commentId}
-                    commentButtonClickedHandler={this.commentButtonClickedHandler}
-                  />
-                )}
-              </div>
-            </div>
+  renderThread(comment, replies) {
+    const commentId = comment._id;
+    const showEditor = this.state.showEditorIds.has(commentId);
+    const isLoggedIn = this.props.appContainer.me != null;
+
+    let rootClassNames = 'page-comment-thread';
+    if (replies.length === 0) {
+      rootClassNames += ' page-comment-thread-no-replies';
+    }
+
+    return (
+      <div key={commentId} className={`mb-5 ${rootClassNames}`}>
+        <Comment
+          comment={comment}
+          deleteBtnClicked={this.confirmToDeleteComment}
+          growiRenderer={this.growiRenderer}
+          replyList={replies}
+        />
+        { !showEditor && isLoggedIn && (
+          <div className="text-right">
+            <Button
+              bsStyle="default"
+              className="btn btn-outline btn-default btn-sm btn-comment-reply"
+              onClick={() => { return this.replyButtonClickedHandler(commentId) }}
+            >
+              <i className="icon-fw icon-action-redo"></i> Reply
+            </Button>
           </div>
-          <br />
-        </div>
-      );
-    });
+        )}
+        { showEditor && isLoggedIn && (
+          <div className="page-comment-reply-form">
+            <CommentEditor
+              growiRenderer={this.growiRenderer}
+              replyTo={commentId}
+              commentButtonClickedHandler={this.commentButtonClickedHandler}
+            />
+          </div>
+        )}
+      </div>
+    );
   }
 
   render() {
-    const currentComments = [];
-    const currentReplies = [];
+    const topLevelComments = [];
+    const allReplies = [];
+
+    const layoutType = this.props.appContainer.getConfig().layoutType;
+    const isBaloonStyle = layoutType.match(/crowi-plus|growi|kibela/);
 
     let comments = this.props.commentContainer.state.comments;
-    if (this.state.isLayoutTypeGrowi) {
+    if (isBaloonStyle) {
       // replace with asc order array
       comments = comments.slice().reverse(); // non-destructive reverse
     }
 
     comments.forEach((comment) => {
       if (comment.replyTo === undefined) {
-      // comment is not a reply
-        currentComments.push(comment);
+        // comment is not a reply
+        topLevelComments.push(comment);
       }
       else {
-      // comment is a reply
-        currentReplies.push(comment);
+        // comment is a reply
+        allReplies.push(comment);
       }
     });
 
-    // generate elements
-    const currentElements = this.generateCommentElements(currentComments, currentReplies);
-
-    // generate blocks
-    const currentBlock = (
-      <div className="page-comments-list-current" id="page-comments-list-current">
-        {currentElements}
-      </div>
-    );
-
-    // layout blocks
-    const commentsElements = (<div>{currentBlock}</div>);
-
     return (
       <div>
-        {commentsElements}
+        { topLevelComments.map((topLevelComment) => {
+          // get related replies
+          const replies = this.getRepliesFor(topLevelComment, allReplies);
+
+          return this.renderThread(topLevelComment, replies);
+        }) }
 
         <DeleteCommentModal
           isShown={this.state.isDeleteConfirmModalShown}

+ 173 - 96
src/client/js/components/PageEditorByHackmd.jsx

@@ -2,9 +2,6 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import loggerFactory from '@alias/logger';
 
-import SplitButton from 'react-bootstrap/es/SplitButton';
-import MenuItem from 'react-bootstrap/es/MenuItem';
-
 import AppContainer from '../services/AppContainer';
 import PageContainer from '../services/PageContainer';
 import EditorContainer from '../services/EditorContainer';
@@ -20,9 +17,12 @@ class PageEditorByHackmd extends React.Component {
     super(props);
 
     this.state = {
-      markdown: this.props.pageContainer.state.markdown,
       isInitialized: false,
       isInitializing: false,
+      // for error
+      hasError: false,
+      errorMessage: '',
+      errorReason: '',
     };
 
     this.getHackmdUri = this.getHackmdUri.bind(this);
@@ -30,6 +30,7 @@ class PageEditorByHackmd extends React.Component {
     this.resumeToEdit = this.resumeToEdit.bind(this);
     this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
     this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
+    this.penpalErrorOccuredHandler = this.penpalErrorOccuredHandler.bind(this);
   }
 
   componentWillMount() {
@@ -45,11 +46,7 @@ class PageEditorByHackmd extends React.Component {
       return Promise.reject(new Error('HackmdEditor component has not initialized'));
     }
 
-    return this.hackmdEditor.getValue()
-      .then((document) => {
-        this.setState({ markdown: document });
-        return document;
-      });
+    return this.hackmdEditor.getValue();
   }
 
   /**
@@ -67,7 +64,7 @@ class PageEditorByHackmd extends React.Component {
   /**
    * Start integration with HackMD
    */
-  startToEdit() {
+  async startToEdit() {
     const { pageContainer } = this.props;
     const hackmdUri = this.getHackmdUri();
 
@@ -84,26 +81,33 @@ class PageEditorByHackmd extends React.Component {
     const params = {
       pageId: pageContainer.state.pageId,
     };
-    this.props.appContainer.apiPost('/hackmd.integrate', params)
-      .then((res) => {
-        if (!res.ok) {
-          throw new Error(res.error);
-        }
-
-        this.setState({
-          isInitialized: true,
-        });
-        pageContainer.setState({
-          pageIdOnHackmd: res.pageIdOnHackmd,
-          revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-        });
-      })
-      .catch((err) => {
-        pageContainer.showErrorToastr(err);
-      })
-      .then(() => {
-        this.setState({ isInitializing: false });
+
+    try {
+      const res = await this.props.appContainer.apiPost('/hackmd.integrate', params);
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      await pageContainer.setState({
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      pageContainer.showErrorToastr(err);
+
+      this.setState({
+        hasError: true,
+        errorMessage: 'GROWI server failed to connect to HackMD.',
+        errorReason: err.toString(),
       });
+    }
+
+    this.setState({
+      isInitialized: true,
+      isInitializing: false,
+    });
   }
 
   /**
@@ -116,8 +120,27 @@ class PageEditorByHackmd extends React.Component {
   /**
    * Reset draft
    */
-  discardChanges() {
-    this.props.pageContainer.setState({ hasDraftOnHackmd: false });
+  async discardChanges() {
+    const { pageContainer } = this.props;
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await this.props.appContainer.apiPost('/hackmd.discard', { pageId });
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      this.props.pageContainer.setState({
+        hasDraftOnHackmd: false,
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      pageContainer.showErrorToastr(err);
+    }
   }
 
   /**
@@ -160,7 +183,7 @@ class PageEditorByHackmd extends React.Component {
     }
 
     // do nothing if contents are same
-    if (this.state.markdown === body) {
+    if (pageContainer.state.markdown === body) {
       return;
     }
 
@@ -178,7 +201,19 @@ class PageEditorByHackmd extends React.Component {
     }
   }
 
-  render() {
+  penpalErrorOccuredHandler(error) {
+    const { pageContainer } = this.props;
+
+    pageContainer.showErrorToastr(error);
+
+    this.setState({
+      hasError: true,
+      errorMessage: 'GROWI client failed to connect to GROWI agent for HackMD.',
+      errorReason: error.toString(),
+    });
+  }
+
+  renderPreInitContent() {
     const hackmdUri = this.getHackmdUri();
     const { pageContainer } = this.props;
     const {
@@ -188,26 +223,8 @@ class PageEditorByHackmd extends React.Component {
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
     const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
 
-    if (this.state.isInitialized) {
-      return (
-        <HackmdEditor
-          ref={(c) => { this.hackmdEditor = c }}
-          hackmdUri={hackmdUri}
-          pageIdOnHackmd={pageIdOnHackmd}
-          initializationMarkdown={isResume ? null : this.state.markdown}
-          onChange={this.hackmdEditorChangeHandler}
-          onSaveWithShortcut={(document) => {
-            this.onSaveWithShortcut(document);
-          }}
-        >
-        </HackmdEditor>
-      );
-    }
-
-    const isRevisionOutdated = revisionId !== remoteRevisionId;
-    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
-
     let content;
+
     /*
      * HackMD is not setup
      */
@@ -222,58 +239,59 @@ class PageEditorByHackmd extends React.Component {
      * Resume to edit or discard changes
      */
     else if (isResume) {
-      const title = (
-        <React.Fragment>
-          <span className="btn-label"><i className="icon-control-end"></i></span>
-          Resume to edit with HackMD
-        </React.Fragment>
-      );
+      const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
+
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
-          <div className="text-center hackmd-resume-button-container mb-3">
-            <SplitButton
-              id="split-button-resume-hackmd"
-              title={title}
-              bsStyle="success"
-              bsSize="large"
-              className="btn-resume waves-effect waves-light"
-              onClick={() => { return this.resumeToEdit() }}
-            >
-              <MenuItem className="text-center" onClick={() => { return this.discardChanges() }}>
-                <i className="icon-control-rewind"></i> Discard changes
-              </MenuItem>
-            </SplitButton>
-          </div>
-          <p className="text-center">
-            Click to edit from the previous continuation<br />
-            or
-            <button
-              type="button"
-              className="btn btn-link text-danger p-0 hackmd-discard-button"
-              onClick={() => { return this.discardChanges() }}
-            >
-              Discard changes
-            </button>.
-          </p>
-          { isHackmdDocumentOutdated
-            && (
-            <div className="panel panel-warning mt-5">
+          <p className="text-center"><strong>HackMD has unsaved draft.</strong></p>
+
+          { isHackmdDocumentOutdated && (
+            <div className="panel panel-warning">
               <div className="panel-heading"><i className="icon-fw icon-info"></i> DRAFT MAY BE OUTDATED</div>
               <div className="panel-body text-center">
                 The current draft on HackMD is based on&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.<br />
-                <button
-                  type="button"
-                  className="btn btn-link text-danger p-0 hackmd-discard-button"
-                  onClick={() => { return this.discardChanges() }}
-                >
-                  Discard it
-                </button> to start to edit with current revision.
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="label label-default">{revisionIdHackmdSynced.substr(-8)}</span></a>.
+
+                <div className="text-center mt-3">
+                  <button
+                    className="btn btn-link btn-view-outdated-draft p-0"
+                    type="button"
+                    disabled={this.state.isInitializing}
+                    onClick={() => { return this.resumeToEdit() }}
+                  >
+                    View the outdated draft on HackMD
+                  </button>
+                </div>
               </div>
             </div>
-            )
-          }
+          ) }
+
+          { !isHackmdDocumentOutdated && (
+            <div className="text-center hackmd-resume-button-container mb-3">
+              <button
+                className="btn btn-success btn-lg waves-effect waves-light"
+                type="button"
+                disabled={this.state.isInitializing}
+                onClick={() => { return this.resumeToEdit() }}
+              >
+                <span className="btn-label"><i className="icon-control-end"></i></span>
+                <span className="btn-text">Resume to edit with HackMD</span>
+              </button>
+            </div>
+          ) }
+
+          <div className="text-center hackmd-discard-button-container mb-3">
+            <button
+              className="btn btn-default btn-lg waves-effect waves-light"
+              type="button"
+              onClick={() => { return this.discardChanges() }}
+            >
+              <span className="btn-label"><i className="icon-control-start"></i></span>
+              <span className="btn-text">Discard changes of HackMD</span>
+            </button>
+          </div>
+
         </div>
       );
     }
@@ -281,6 +299,8 @@ class PageEditorByHackmd extends React.Component {
      * Start to edit
      */
     else {
+      const isRevisionOutdated = revisionId !== remoteRevisionId;
+
       content = (
         <div>
           <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
@@ -307,6 +327,63 @@ class PageEditorByHackmd extends React.Component {
     );
   }
 
+  render() {
+    const hackmdUri = this.getHackmdUri();
+    const { pageContainer } = this.props;
+    const {
+      markdown, pageIdOnHackmd, hasDraftOnHackmd,
+    } = pageContainer.state;
+
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    const isResume = isPageExistsOnHackmd && hasDraftOnHackmd;
+
+    let content;
+
+    if (this.state.isInitialized) {
+      content = (
+        <HackmdEditor
+          ref={(c) => { this.hackmdEditor = c }}
+          hackmdUri={hackmdUri}
+          pageIdOnHackmd={pageIdOnHackmd}
+          initializationMarkdown={isResume ? null : markdown}
+          onChange={this.hackmdEditorChangeHandler}
+          onSaveWithShortcut={(document) => {
+            this.onSaveWithShortcut(document);
+          }}
+          onPenpalErrorOccured={this.penpalErrorOccuredHandler}
+        >
+        </HackmdEditor>
+      );
+    }
+    else {
+      content = this.renderPreInitContent();
+    }
+
+
+    return (
+      <div className="position-relative">
+
+        {content}
+
+        { this.state.hasError && (
+          <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
+            <div className="white-box text-center">
+              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> HackMD Integration failed</h2>
+              <h4>{this.state.errorMessage}</h4>
+              <p className="well well-sm text-danger">
+                {this.state.errorReason}
+              </p>
+              <p>
+                Check your configuration following <a href="https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html">the manual</a>.
+              </p>
+            </div>
+          </div>
+        ) }
+
+      </div>
+    );
+  }
+
 }
 
 /**

+ 17 - 6
src/client/js/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -1,18 +1,18 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
 
 import connectToChild from 'penpal/lib/connectToChild';
 
 const DEBUG_PENPAL = false;
 
+const logger = loggerFactory('growi:HackmdEditor');
+
 export default class HackmdEditor extends React.PureComponent {
 
   constructor(props) {
     super(props);
 
-    this.state = {
-    };
-
     this.hackmd = null;
 
     this.initHackmdWithPenpal = this.initHackmdWithPenpal.bind(this);
@@ -26,7 +26,7 @@ export default class HackmdEditor extends React.PureComponent {
     this.initHackmdWithPenpal();
   }
 
-  initHackmdWithPenpal() {
+  async initHackmdWithPenpal() {
     const _this = this; // for in methods scope
 
     const iframe = document.createElement('iframe');
@@ -43,14 +43,24 @@ export default class HackmdEditor extends React.PureComponent {
           _this.saveWithShortcutHandler(document);
         },
       },
+      timeout: 15000,
       debug: DEBUG_PENPAL,
     });
-    connection.promise.then((child) => {
+
+    try {
+      const child = await connection.promise;
       this.hackmd = child;
       if (this.props.initializationMarkdown != null) {
         child.setValueOnInit(this.props.initializationMarkdown);
       }
-    });
+    }
+    catch (err) {
+      logger.error(err);
+
+      if (this.props.onPenpalErrorOccured != null) {
+        this.props.onPenpalErrorOccured(err);
+      }
+    }
   }
 
   /**
@@ -93,4 +103,5 @@ HackmdEditor.propTypes = {
   initializationMarkdown: PropTypes.string,
   onChange: PropTypes.func,
   onSaveWithShortcut: PropTypes.func,
+  onPenpalErrorOccured: PropTypes.func,
 };

+ 4 - 0
src/client/js/components/TableOfContents.jsx

@@ -70,6 +70,10 @@ class TableOfContents extends React.Component {
   resetScrollbar(revisionTocTop) {
     const tocContentElem = document.querySelector('.revision-toc .markdownIt-TOC');
 
+    if (tocContentElem == null) {
+      return;
+    }
+
     // window height - revisionTocTop - .system-version height
     const viewHeight = window.innerHeight - revisionTocTop - 20;
 

+ 1 - 1
src/client/js/services/AppContainer.js

@@ -29,7 +29,7 @@ export default class AppContainer extends Container {
 
     const body = document.querySelector('body');
 
-    this.me = body.dataset.currentUsername;
+    this.me = body.dataset.currentUsername || null; // will be initialized with null when data is empty string
     this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
     this.isPluginEnabled = body.dataset.pluginEnabled === 'true';

+ 3 - 3
src/client/js/services/PageContainer.js

@@ -42,13 +42,13 @@ export default class PageContainer extends Container {
       likerUserIds: [],
 
       tags: [],
-      templateTagData: mainContent.getAttribute('data-template-tags'),
+      templateTagData: mainContent.getAttribute('data-template-tags') || null,
 
       // latest(on remote) information
       remoteRevisionId: revisionId,
-      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced'),
+      revisionIdHackmdSynced: mainContent.getAttribute('data-page-revision-id-hackmd-synced') || null,
       lastUpdateUsername: undefined,
-      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd'),
+      pageIdOnHackmd: mainContent.getAttribute('data-page-id-on-hackmd') || null,
       hasDraftOnHackmd: !!mainContent.getAttribute('data-page-has-draft-on-hackmd'),
       isHackmdDraftUpdatingInRealtime: false,
     };

+ 1 - 1
src/client/styles/bootstrap4/_utilities.scss

@@ -6,7 +6,7 @@
 // @import "utilities/embed";
 @import 'utilities/flex';
 // @import "utilities/float";
-// @import "utilities/position";
+@import 'utilities/position';
 // @import "utilities/screenreaders";
 // @import "utilities/shadows";
 // @import "utilities/sizing";

+ 2 - 2
src/client/styles/bootstrap4/_variables.scss

@@ -606,8 +606,8 @@ $font-weight-bold: 700 !default;
 // // of components dependent on the z-axis and are designed to all work together.
 
 // $zindex-dropdown:                   1000 !default;
-// $zindex-sticky:                     1020 !default;
-// $zindex-fixed:                      1030 !default;
+$zindex-sticky:                     1020 !default;
+$zindex-fixed:                      1030 !default;
 // $zindex-modal-backdrop:             1040 !default;
 // $zindex-modal:                      1050 !default;
 // $zindex-popover:                    1060 !default;

+ 19 - 22
src/client/styles/scss/_comment.scss

@@ -23,34 +23,31 @@
 
 .main-container {
   .page-comments {
-    .page-comments-list-toggle-newer,
     .page-comments-list-toggle-older {
-      display: block;
-      margin: 8px;
+      display: inline-block;
       font-size: 0.9em;
-      text-align: center;
     }
 
-    // older comments
-    .page-comments-list-older .page-comment {
-    }
-    // newer comments
-    .page-comments-list-newer .page-comment {
-      opacity: 0.7;
+    .page-comment {
+      // older comments
+      &.page-comment-older {
+      }
+      // newer comments
+      &.page-comment-newer {
+        opacity: 0.7;
 
-      &:hover {
-        opacity: 1;
+        &:hover {
+          opacity: 1;
+        }
+      }
+
+      .page-comment-meta {
+        display: flex;
+        justify-content: flex-end;
+
+        font-size: 0.9em;
+        color: #999;
       }
     }
   }
 }
-
-.btn-xxs {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  width: 50px;
-  height: 10px;
-  font-size: 11px;
-  border-radius: 1px;
-}

+ 5 - 0
src/client/styles/scss/_comment_crowi.scss

@@ -0,0 +1,5 @@
+.crowi.main-container {
+  .page-comment-main {
+    margin-bottom: 0.5em;
+  }
+}

+ 28 - 11
src/client/styles/scss/_comment_growi.scss

@@ -2,8 +2,8 @@
   %comment-section {
     position: relative;
     padding: 1em;
-    margin-bottom: 1em;
     margin-left: 4.5em;
+
     // screen-xs
     @media (max-width: $screen-xs) {
       margin-left: 3.5em;
@@ -75,16 +75,32 @@
       margin-bottom: 0.5em;
       word-wrap: break-word;
     }
+  }
 
-    .page-comment-meta {
-      font-size: 0.9em;
-      color: #999;
-      text-align: right;
-
-      * {
-        vertical-align: 25%;
-      }
-    }
+  /*
+   * reply
+   */
+  .page-comment-reply {
+    margin-top: 1em;
+  }
+  // remove margin after hidden replies
+  .page-comments-hidden-replies + .page-comment-reply {
+    margin-top: 0;
+  }
+  .page-comment-reply,
+  .page-comment-reply-form {
+    margin-right: 15px;
+    margin-left: 6em;
+  }
+  // reply button
+  .btn.btn-comment-reply {
+    width: 120px;
+    margin-top: 0.5em;
+    margin-right: 15px;
+
+    border-top: none;
+    border-right: none;
+    border-left: none;
   }
 
   // show when hover
@@ -99,7 +115,8 @@
     }
 
     position: relative;
-    margin-top: 2em;
+    margin-top: 1em;
+
     // user icon
     .picture {
       @extend %picture;

+ 63 - 22
src/client/styles/scss/_comment_kibela.scss

@@ -3,18 +3,19 @@
   %comment-section {
     position: relative;
     padding: 1em;
-    margin-bottom: 1em; // screen-xs
     margin-left: 4.5em;
+
     @media (max-width: $screen-xs) {
       margin-left: 3.5em;
-    } // speech balloon
+    }
+
+    // speech balloon
     &:before {
       position: absolute;
       top: 1.5em;
       left: -1em;
       display: block;
       width: 0;
-      width: 0; // screen-xs
       height: 0;
       content: '';
       border-top: 20px solid transparent;
@@ -22,11 +23,13 @@
       border-bottom: 20px solid transparent;
       border-left: 20px solid transparent;
       border-left-width: 0;
+
       @media (max-width: $screen-xs) {
         top: 1em;
       }
     }
   }
+
   %picture {
     float: left;
     width: 3em;
@@ -37,55 +40,91 @@
       height: 2em;
     }
   }
+
   .page-comments-row {
     margin: 10px 0px;
   }
+
   .page-comments {
     h4 {
       margin-bottom: 1em;
     }
   }
   .page-comment {
-    position: relative; // ユーザー名
+    position: relative;
+
+    // ユーザー名
     .page-comment-creator {
       margin-top: -0.5em;
       margin-bottom: 0.5em;
       font-weight: bold;
-    } // ユーザーアイコン
+    }
+
+    // ユーザーアイコン
     .picture {
       @extend %picture;
-    } // コメントセクション
+    }
+
+    // コメントセクション
     .page-comment-main {
       @extend %comment-section;
       background: #e6e9ec;
       border-radius: 0.35em;
-    } // コメント本文
+    }
+
+    // コメント本文
     .page-comment-body {
       margin-bottom: 0.5em;
       word-wrap: break-word;
     }
-    .page-comment-meta {
-      font-size: 0.9em;
-      color: #e5ecf1;
-      text-align: right;
-      * {
-        vertical-align: 25%;
-      }
-    }
-  } // show when hover
+  }
+
+  /*
+   * reply
+   */
+  .page-comment-reply {
+    margin-top: 1em;
+  }
+  // remove margin after hidden replies
+  .page-comments-hidden-replies + .page-comment-reply {
+    margin-top: 0;
+  }
+  .page-comment-reply,
+  .page-comment-reply-form {
+    margin-right: 15px;
+    margin-left: 6em;
+  }
+  // reply button
+  .btn.btn-comment-reply {
+    width: 120px;
+    margin-top: 0.5em;
+    margin-right: 15px;
+
+    border-top: none;
+    border-right: none;
+    border-left: none;
+  }
+
+  // show when hover
   .page-comment-main:hover > .page-comment-control {
     display: block;
-  } // display cheatsheet for comment form only
+  }
+
+  // display cheatsheet for comment form only
   .comment-form {
-    position: relative;
-    margin-top: 2em; // user icon
-    border: none;
     .editor-cheatsheet {
       display: none;
     }
+
+    position: relative;
+    margin-top: 1em;
+
+    // user icon
     .picture {
       @extend %picture;
-    } // seciton
+    }
+
+    // seciton
     .comment-form-main {
       @extend %comment-section;
       background: #e6e9ec;
@@ -93,7 +132,9 @@
       .CodeMirror {
         border: 0px;
       }
-    } // textarea
+    }
+
+    // textarea
     .comment-write {
       margin-bottom: 0.5em;
     }

+ 1 - 0
src/client/styles/scss/_mixins.scss

@@ -73,6 +73,7 @@
       #page-editor-with-hackmd {
         &,
         .hackmd-preinit,
+        .hackmd-error,
         #iframe-hackmd-container > iframe {
           width: 100vw;
           height: calc(100vh - #{$header-plus-footer});

+ 11 - 13
src/client/styles/scss/_on-edit.scss

@@ -267,27 +267,25 @@ body.on-edit {
       border: none;
     }
 
+    .hackmd-error {
+      top: 0;
+      background-color: rgba($gray-dark, 0.8);
+    }
+
     .hackmd-status-label {
       font-size: 3em;
       color: $muted;
     }
 
-    .hackmd-start-button-container,
-    .hackmd-resume-button-container {
-      .btn-lg .btn-label {
-        padding-top: 6px; // for SplitButton
-        padding-bottom: 6px; // for SplitButton
-      }
-    }
-
-    .hackmd-resume-button-container {
-      .dropdown-menu {
-        right: 0;
-        left: unset;
+    .hackmd-resume-button-container,
+    .hackmd-discard-button-container {
+      .btn-text {
+        display: inline-block;
+        min-width: 230px;
       }
     }
 
-    .hackmd-discard-button {
+    .btn-view-outdated-draft {
       text-decoration: underline;
       vertical-align: unset;
     }

+ 2 - 0
src/client/styles/scss/_page.scss

@@ -10,6 +10,8 @@
   header {
     // the container of h1
     div.title-container {
+      padding-right: 5px;
+      padding-left: 5px;
       margin-right: auto;
     }
 

+ 67 - 52
src/client/styles/scss/_page_growi.scss

@@ -1,52 +1,67 @@
-.growi.main-container {
-  header {
-    div.title-logo-container {
-      display: none; // hide in default
-
-      a {
-        // centering
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 32px;
-        height: 32px;
-
-        img {
-          width: 16px;
-          height: 16px;
-        }
-      }
-    }
-
-    ul.authors {
-      margin: 0;
-
-      li {
-        font-size: 12px;
-        list-style: none;
-      }
-
-      .picture {
-        width: 22px;
-        height: 22px;
-        border: 1px solid #ccc;
-      }
-    }
-  }
-
-  /*
-   * affix header
-   */
-  header.affix {
-    // show logo link
-    div.title-logo-container {
-      display: unset;
-      margin-right: 6px;
-      margin-left: -12px;
-    }
-    // hide authors in affix
-    .authors {
-      display: none !important;
-    }
-  }
-}
+.growi.main-container {
+  header {
+    div.title-logo-container {
+      display: none; // hide in default
+
+      a {
+        // centering
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 32px;
+        height: 32px;
+
+        img {
+          width: 16px;
+          height: 16px;
+        }
+      }
+    }
+
+    ul.authors {
+      padding-left: 1.5em;
+      margin: 0;
+
+      li {
+        font-size: 12px;
+        list-style: none;
+      }
+
+      .picture {
+        width: 22px;
+        height: 22px;
+        border: 1px solid #ccc;
+
+        &.picture-xs {
+          width: 14px;
+          height: 14px;
+        }
+      }
+    }
+  }
+
+  /*
+   * affix header
+   */
+  header:not(.affix) {
+    .only-affix {
+      display: none !important;
+    }
+  }
+  header.affix {
+    .not-affix {
+      display: none !important;
+    }
+
+    // show logo link
+    div.title-logo-container {
+      display: unset;
+      margin-right: 6px;
+      margin-left: -12px;
+    }
+    // hide authors in affix
+    .authors {
+      padding-left: 0.5em;
+    }
+  }
+}

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -15,6 +15,7 @@
 @import 'admin';
 @import 'attachments';
 @import 'comment';
+@import 'comment_crowi';
 @import 'comment_growi';
 @import 'comment_kibela';
 @import 'navbar_kibela';

+ 3 - 0
src/client/styles/scss/theme/_override-agileadmin.scss

@@ -1,4 +1,7 @@
 .bg-title {
+  padding: 6px 0;
+  margin-right: -15px;
+  margin-left: -15px;
   overflow: unset;
 }
 

+ 0 - 5
src/server/models/page.js

@@ -1333,12 +1333,7 @@ module.exports = function(crowi) {
    * @param {string} pageIdOnHackmd
    */
   pageSchema.statics.registerHackmdPage = function(pageData, pageIdOnHackmd) {
-    if (pageData.pageIdOnHackmd != null) {
-      throw new Error(`'pageIdOnHackmd' of the page '${pageData.path}' is not empty`);
-    }
-
     pageData.pageIdOnHackmd = pageIdOnHackmd;
-
     return this.syncRevisionToHackmd(pageData);
   };
 

+ 67 - 20
src/server/routes/hackmd.js

@@ -116,21 +116,53 @@ module.exports = function(crowi, app) {
     const hackmdUri = process.env.HACKMD_URI_FOR_SERVER || process.env.HACKMD_URI;
     let page = req.page;
 
-    if (page.pageIdOnHackmd != null) {
-      try {
-        // check if page exists in HackMD
-        await axios.get(`${hackmdUri}/${page.pageIdOnHackmd}`);
-      }
-      catch (err) {
-        // reset if pages doesn't exist
-        page.pageIdOnHackmd = undefined;
-      }
+    const hackmdPageUri = (page.pageIdOnHackmd != null)
+      ? `${hackmdUri}/${page.pageIdOnHackmd}`
+      : `${hackmdUri}/new`;
+
+    let hackmdResponse;
+    try {
+      // check if page is found or created in HackMD
+      hackmdResponse = await axios.get(hackmdPageUri, {
+        maxRedirects: 0,
+        // validate HTTP status is 200 or 302 or 404
+        validateStatus: (status) => {
+          return status === 200 || status === 302 || status === 404;
+        },
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error(err));
+    }
+
+    const { status, headers } = hackmdResponse;
+
+    // validate HackMD/CodiMD specific header
+    if (headers['codimd-version'] == null && headers['hackmd-version'] == null) {
+      const message = 'Connecting to a non-HackMD server.';
+      logger.error(message);
+      return res.json(ApiResponse.error(message));
     }
 
     try {
-      if (page.pageIdOnHackmd == null) {
-        page = await createNewPageOnHackmdAndRegister(hackmdUri, page);
+      // when page is not found
+      if (status === 404) {
+        // reset registered data
+        page = await Page.registerHackmdPage(page, undefined);
+        // re-invoke
+        return integrate(req, res);
+      }
+
+      // when redirect
+      if (status === 302) {
+        // extract page id on HackMD
+        const pagePathOnHackmd = headers.location; // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
+        const pageIdOnHackmd = pagePathOnHackmd.substr(1); //        strip the head '/'
+
+        page = await Page.registerHackmdPage(page, pageIdOnHackmd);
       }
+      // when page is found
       else {
         page = await Page.syncRevisionToHackmd(page);
       }
@@ -148,17 +180,31 @@ module.exports = function(crowi, app) {
     }
   };
 
-  async function createNewPageOnHackmdAndRegister(hackmdUri, page) {
-    // access to HackMD and create page
-    const response = await axios.get(`${hackmdUri}/new`);
-    logger.debug('HackMD responds', response);
+  /**
+   * POST /_api/hackmd.discard
+   *
+   * Create page on HackMD and start to integrate
+   * @param {object} req
+   * @param {object} res
+   */
+  const discard = async function(req, res) {
+    let page = req.page;
 
-    // extract page id on HackMD
-    const pagePathOnHackmd = response.request.path; // e.g. '/NC7bSRraT1CQO1TO7wjCPw'
-    const pageIdOnHackmd = pagePathOnHackmd.substr(1); // strip the head '/'
+    try {
+      page = await Page.syncRevisionToHackmd(page);
 
-    return Page.registerHackmdPage(page, pageIdOnHackmd);
-  }
+      const data = {
+        pageIdOnHackmd: page.pageIdOnHackmd,
+        revisionIdHackmdSynced: page.revisionHackmdSynced,
+        hasDraftOnHackmd: page.hasDraftOnHackmd,
+      };
+      return res.json(ApiResponse.success(data));
+    }
+    catch (err) {
+      logger.error(err);
+      return res.json(ApiResponse.error('discard process failed'));
+    }
+  };
 
   /**
    * POST /_api/hackmd.saveOnHackmd
@@ -188,6 +234,7 @@ module.exports = function(crowi, app) {
     loadStyles,
     validateForApi,
     integrate,
+    discard,
     saveOnHackmd,
   };
 };

+ 1 - 0
src/server/routes/index.js

@@ -226,6 +226,7 @@ module.exports = function(crowi, app) {
   app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
   app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
   app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.integrate);
+  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.discard);
   app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequired() , csrf, hackmd.validateForApi, hackmd.saveOnHackmd);
 
   // API v3

+ 12 - 7
src/server/routes/page.js

@@ -169,6 +169,10 @@ module.exports = function(crowi, app) {
   }
 
   function replacePlaceholdersOfTemplate(template, req) {
+    if (req.user == null) {
+      return '';
+    }
+
     const definitions = {
       pagepath: getPathFromRequest(req),
       username: req.user.name,
@@ -427,13 +431,14 @@ module.exports = function(crowi, app) {
       view = 'customlayout-selector/not_found';
 
       // retrieve templates
-      const template = await Page.findTemplate(path);
-
-      if (template.templateBody) {
-        const body = replacePlaceholdersOfTemplate(template.templateBody, req);
-        const tags = template.templateTags;
-        renderVars.template = body;
-        renderVars.templateTags = tags;
+      if (req.user != null) {
+        const template = await Page.findTemplate(path);
+        if (template.templateBody) {
+          const body = replacePlaceholdersOfTemplate(template.templateBody, req);
+          const tags = template.templateTags;
+          renderVars.template = body;
+          renderVars.templateTags = tags;
+        }
       }
 
       // add scope variables by ancestor page

+ 5 - 0
src/server/util/middlewares.js

@@ -1,5 +1,6 @@
 const debug = require('debug')('growi:lib:middlewares');
 const logger = require('@alias/logger')('growi:lib:middlewares');
+const { distanceInWordsStrict } = require('date-fns');
 const pathUtils = require('growi-commons').pathUtils;
 const md5 = require('md5');
 const entities = require('entities');
@@ -119,6 +120,10 @@ module.exports = (crowi, app) => {
         return swigFilters.date(input, format, app.get('tzoffset'));
       });
 
+      swig.setFilter('dateDistance', (input) => {
+        return distanceInWordsStrict(input, new Date());
+      });
+
       swig.setFilter('nl2br', (string) => {
         return string
           .replace(/\n/g, '<br>');

+ 23 - 7
src/server/views/layout-growi/widget/header.html

@@ -17,8 +17,8 @@
 
       <ul class="authors hidden-sm hidden-xs text-nowrap">
         <li>
-          <div class="d-flex align-items-center">
-            <a class="m-r-5" href="{{ userPageRoot(page.creator) }}">
+          <div class="d-flex align-items-center not-affix">
+            <a class="m-r-5" href="{{ userPageRoot(page.creator) }}" data-toggle="tooltip" data-placement="bottom" title="{{ page.creator.name|default(author.name) }}">
               <img src="{{ page.creator|default(author)|picture }}" class="picture img-circle">
             </a>
             <div>
@@ -26,15 +26,31 @@
               <div class="text-muted">{{ page.createdAt|datetz('Y/m/d H:i:s') }}</div>
             </div>
           </div>
+          <div class="d-flex align-items-center only-affix">
+            <a class="m-r-5" href="{{ userPageRoot(page.creator) }}" data-toggle="tooltip" data-placement="bottom" title="{{ page.creator.name|default(author.name) }}">
+              <img src="{{ page.creator|default(author)|picture }}" class="picture picture-xs img-circle">
+            </a>
+            <div class="ml-auto">
+              <div>Created in <span class="text-muted">{{ page.createdAt|datetz('Y/m/d H:i:s') }}</span></div>
+            </div>
+          </div>
         </li>
         <li class="m-t-5">
-          <div class="d-flex align-items-center">
-            <a class="m-r-5" href="{{ userPageRoot(page.revision.author) }}">
-              <img src="{{ page.revision.author|default(author)|picture }}" class="picture img-circle">
+          <div class="d-flex align-items-center not-affix">
+            <a class="m-r-5" href="{{ userPageRoot(author) }}" data-toggle="tooltip" data-placement="bottom" title="{{ author.name }}">
+              <img src="{{ author|picture }}" class="picture img-circle">
             </a>
             <div>
-              <div>Updated by <a href="{{ userPageRoot(page.revision.author) }}">{{ page.revision.author.name|default(author.name) }}</a></div>
-              <div class="text-muted">{{ page.updatedAt|datetz('Y/m/d H:i:s') }}</div>
+              <div>Updated by <a href="{{ userPageRoot(page.revision.author) }}">{{ author.name }}</a></div>
+              <div class="text-muted"">{{ page.updatedAt|datetz('Y/m/d H:i:s') }}</div>
+            </div>
+          </div>
+          <div class="d-flex align-items-center only-affix">
+            <a class="m-r-5" href="{{ userPageRoot(author) }}" data-toggle="tooltip" data-placement="bottom" title="{{ author.name }}">
+              <img src="{{ author|picture }}" class="picture picture-xs img-circle">
+            </a>
+            <div class="ml-auto">
+              <div>Updated in <span class="text-muted"">{{ page.updatedAt|datetz('Y/m/d H:i:s') }}</span></div>
             </div>
           </div>
         </li>

+ 3 - 3
src/server/views/layout-kibela/widget/header.html

@@ -29,12 +29,12 @@
       </li>
       <li class="m-t-5">
         <div class="d-flex align-items-center">
-          <a class="m-r-5" href="{{ userPageRoot(page.revision.author) }}">
-            <img src="{{ page.revision.author|default(author)|picture }}" class="picture img-circle">
+          <a class="m-r-5" href="{{ userPageRoot(author) }}">
+            <img src="{{ author|picture }}" class="picture img-circle">
           </a>
           <div>
             <div>Updated by
-              <a href="{{ userPageRoot(page.revision.author) }}">{{ page.revision.author.name|default(author.name) }}</a>
+              <a href="{{ userPageRoot(author) }}">{{ author.name }}</a>
             </div>
             <div class="text-muted">{{ page.updatedAt|datetz('Y/m/d H:i:s') }}</div>
           </div>