Jelajahi Sumber

Merge pull request #176 from crowi/feature-gh-diff

GitHub Like Diff
Sotaro KARASAWA 9 tahun lalu
induk
melakukan
b6345a478c

+ 5 - 1
gulpfile.js

@@ -129,7 +129,11 @@ gulp.task('css:sass', function() {
 });
 });
 
 
 gulp.task('css:concat', ['css:sass'], function() {
 gulp.task('css:concat', ['css:sass'], function() {
-  return gulp.src([css.main, 'node_modules/highlight.js/styles/tomorrow-night.css'])
+  return gulp.src([
+      css.main,
+      'node_modules/highlight.js/styles/tomorrow-night.css',
+      'node_modules/diff2html/dist/diff2html.css',
+    ])
     .pipe(concat('crowi.css'))
     .pipe(concat('crowi.css'))
     .pipe(gulp.dest(dirs.cssDist))
     .pipe(gulp.dest(dirs.cssDist))
 });
 });

+ 12 - 0
lib/crowi/index.js

@@ -96,6 +96,18 @@ Crowi.prototype.init = function() {
   });
   });
 }
 }
 
 
+Crowi.prototype.isPageId = function(pageId) {
+  if (!pageId) {
+    return false;
+  }
+
+  if (typeof pageId === 'string' && pageId.match(/^[\da-f]{24}$/)) {
+    return true;
+  }
+
+  return false;
+};
+
 Crowi.prototype.setConfig = function(config) {
 Crowi.prototype.setConfig = function(config) {
   this.config = config;
   this.config = config;
 };
 };

+ 7 - 0
lib/models/revision.js

@@ -60,6 +60,13 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
+  revisionSchema.statics.findRevisionIdList = function(path) {
+    return this.find({path: path})
+      .select('_id author createdAt')
+      .sort({createdAt: -1})
+      .exec();
+  };
+
   revisionSchema.statics.findRevisionList = function(path, options) {
   revisionSchema.statics.findRevisionList = function(path, options) {
     var Revision = this,
     var Revision = this,
         User = crowi.model('User');
         User = crowi.model('User');

+ 7 - 2
lib/models/user.js

@@ -315,13 +315,18 @@ module.exports = function(crowi) {
     var User = this;
     var User = this;
     var option = option || {}
     var option = option || {}
       , sort = option.sort || {createdAt: -1}
       , sort = option.sort || {createdAt: -1}
-      , status = option.status || STATUS_ACTIVE
+      , status = option.status || [STATUS_ACTIVE, STATUS_SUSPENDED]
       , fields = option.fields || USER_PUBLIC_FIELDS
       , fields = option.fields || USER_PUBLIC_FIELDS
       ;
       ;
 
 
+    if (!Array.isArray(status)) {
+      status = [status];
+    }
+
     return new Promise(function(resolve, reject) {
     return new Promise(function(resolve, reject) {
       User
       User
-        .find({status: status })
+        .find()
+        .or(status.map(s => { return {status: s}; }))
         .select(fields)
         .select(fields)
         .sort(sort)
         .sort(sort)
         .exec(function (err, userData) {
         .exec(function (err, userData) {

+ 2 - 1
lib/routes/index.js

@@ -115,7 +115,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
   app.post('/_api/attachments.remove' , accessTokenParser , loginRequired(crowi, app) , csrf, attachment.api.remove);
 
 
   app.get( '/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app) , revision.api.get);
   app.get( '/_api/revisions.get'      , accessTokenParser , loginRequired(crowi, app) , revision.api.get);
-  app.get( '/_api/revisions.list'     , accessTokenParser , loginRequired(crowi, app) ,revision.api.list);
+  app.get( '/_api/revisions.ids'      , accessTokenParser , loginRequired(crowi, app) , revision.api.ids);
+  app.get( '/_api/revisions.list'     , accessTokenParser , loginRequired(crowi, app) , revision.api.list);
 
 
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);

+ 1 - 1
lib/routes/installer.js

@@ -39,7 +39,7 @@ module.exports = function(crowi, app) {
             // login処理
             // login処理
             req.user = req.session.user = userData;
             req.user = req.session.user = userData;
             req.flash('successMessage', 'Crowi のインストールが完了しました!はじめに、このページでこの Wiki の各種設定を確認してください。');
             req.flash('successMessage', 'Crowi のインストールが完了しました!はじめに、このページでこの Wiki の各種設定を確認してください。');
-            return res.redirect('admin/app');
+            return res.redirect('/admin/app');
           });
           });
         });
         });
       });
       });

+ 30 - 1
lib/routes/revision.js

@@ -22,13 +22,42 @@ module.exports = function(crowi, app) {
     Revision
     Revision
       .findRevision(revisionId)
       .findRevision(revisionId)
       .then(function(revisionData) {
       .then(function(revisionData) {
-        return res.json(ApiResponse.success(revisionData));
+        var result = {
+          revision: revisionData,
+        }
+        return res.json(ApiResponse.success(result));
       })
       })
       .catch(function(err) {
       .catch(function(err) {
+        debug('Error revisios.get', err);
         return res.json(ApiResponse.error(err));
         return res.json(ApiResponse.error(err));
       });
       });
   };
   };
 
 
+  /**
+   * @api {get} /revisions.ids Get revision id list of the page
+   * @apiName ids
+   * @apiGroup Revision
+   *
+   * @apiParam {String} page_id      Page Id.
+   */
+  actions.api.ids = function(req, res) {
+    var pageId = req.query.page_id || null;
+
+    if (pageId && crowi.isPageId(pageId)) {
+      Page.findPageByIdAndGrantedUser(pageId, req.user)
+      .then(function(pageData) {
+        debug('Page found', pageData._id, pageData.path);
+        return Revision.findRevisionIdList(pageData.path);
+      }).then(function(revisions) {
+        return res.json(ApiResponse.success({revisions}));
+      }).catch(function(err) {
+        return res.json(ApiResponse.error(err));
+      });
+    } else {
+      return res.json(ApiResponse.error('Parameter error.'));
+    }
+  };
+
   /**
   /**
    * @api {get} /revisions.list Get revisions
    * @api {get} /revisions.list Get revisions
    * @apiName ListRevision
    * @apiName ListRevision

+ 3 - 28
lib/views/page.html

@@ -168,36 +168,11 @@
     {% endif %}
     {% endif %}
 
 
     {# raw revision history #}
     {# raw revision history #}
+    {% if not page %}
+    {% else %}
     <div class="tab-pane revision-history" id="revision-history">
     <div class="tab-pane revision-history" id="revision-history">
-      <h1><i class="fa fa-history"></i> History</h1>
-      {% if not page %}
-      {% else %}
-      <div class="revision-history-list">
-        {% for tt in tree %}
-        <div class="revision-hisory-outer">
-          <img src="{{ tt.author|picture }}" class="picture picture-rounded">
-          <div class="revision-history-main">
-            <div class="revision-history-author">
-              <strong>{% if tt.author %}{{ tt.author.username }}{% else %}-{% endif %}</strong>
-            </div>
-            <div class="revision-history-comment">
-            </div>
-            <div class="revision-history-meta">
-              {{ tt.createdAt|datetz('Y-m-d H:i:s') }}
-              <br>
-              <a href="?revision={{ tt._id.toString() }}"><i class="fa fa-history"></i> {{ t('View this version') }}</a>
-              <a class="diff-view" data-revision-id="{{ tt._id.toString() }}">
-                <i id="diff-icon-{{ tt._id.toString() }}" class="fa fa-arrow-circle-right"></i> {{ t('View diff') }}
-              </a>
-              <pre class="" id="diff-display-{{ tt._id.toString()}}" style="display: none"></pre>
-            </div>
-          </div>
-        </div>
-        {% endfor %}
-      </div>
-      {% endif %}
-
     </div>
     </div>
+    {% endif %}
 
 
   </div>
   </div>
   {% endif %}
   {% endif %}

+ 1 - 1
lib/views/page_list.html

@@ -148,7 +148,7 @@
               <a class="diff-view" data-revision-id="{{ tr._id.toString() }}">
               <a class="diff-view" data-revision-id="{{ tr._id.toString() }}">
                 <i id="diff-icon-{{ tr._id.toString() }}" class="fa fa-arrow-circle-right"></i> {{ t('View diff') }}
                 <i id="diff-icon-{{ tr._id.toString() }}" class="fa fa-arrow-circle-right"></i> {{ t('View diff') }}
               </a>
               </a>
-              <pre class="" id="diff-display-{{ tr._id.toString()}}" style="display: none"></pre>
+              <div class="" id="diff-display-{{ tr._id.toString()}}" style="display: none"></div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 34 - 7
npm-shrinkwrap.json

@@ -1461,9 +1461,14 @@
       "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz"
       "resolved": "https://registry.npmjs.org/dicer/-/dicer-0.2.5.tgz"
     },
     },
     "diff": {
     "diff": {
-      "version": "2.2.3",
-      "from": "diff@>=2.2.2 <2.3.0",
-      "resolved": "https://registry.npmjs.org/diff/-/diff-2.2.3.tgz"
+      "version": "3.2.0",
+      "from": "diff@>=3.2.0 <3.3.0",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-3.2.0.tgz"
+    },
+    "diff2html": {
+      "version": "2.0.12",
+      "from": "diff2html@>=2.0.12 <2.1.0",
+      "resolved": "https://registry.npmjs.org/diff2html/-/diff2html-2.0.12.tgz"
     },
     },
     "dom-serializer": {
     "dom-serializer": {
       "version": "0.1.0",
       "version": "0.1.0",
@@ -2752,6 +2757,18 @@
       "from": "hoek@>=2.0.0 <3.0.0",
       "from": "hoek@>=2.0.0 <3.0.0",
       "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz"
       "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz"
     },
     },
+    "hogan.js": {
+      "version": "3.0.2",
+      "from": "hogan.js@>=3.0.2 <4.0.0",
+      "resolved": "https://registry.npmjs.org/hogan.js/-/hogan.js-3.0.2.tgz",
+      "dependencies": {
+        "mkdirp": {
+          "version": "0.3.0",
+          "from": "mkdirp@0.3.0",
+          "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.3.0.tgz"
+        }
+      }
+    },
     "home-or-tmp": {
     "home-or-tmp": {
       "version": "2.0.0",
       "version": "2.0.0",
       "from": "home-or-tmp@>=2.0.0 <3.0.0",
       "from": "home-or-tmp@>=2.0.0 <3.0.0",
@@ -3103,6 +3120,11 @@
       "from": "istanbul@>=0.3.5 <0.4.0",
       "from": "istanbul@>=0.3.5 <0.4.0",
       "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.3.22.tgz",
       "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.3.22.tgz",
       "dependencies": {
       "dependencies": {
+        "nopt": {
+          "version": "3.0.6",
+          "from": "nopt@>=3.0.0 <4.0.0",
+          "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz"
+        },
         "resolve": {
         "resolve": {
           "version": "1.1.7",
           "version": "1.1.7",
           "from": "resolve@>=1.1.0 <1.2.0",
           "from": "resolve@>=1.1.0 <1.2.0",
@@ -4048,6 +4070,11 @@
           "version": "3.0.3",
           "version": "3.0.3",
           "from": "minimatch@>=3.0.2 <4.0.0",
           "from": "minimatch@>=3.0.2 <4.0.0",
           "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz"
           "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz"
+        },
+        "nopt": {
+          "version": "3.0.6",
+          "from": "nopt@>=2.0.0 <3.0.0||>=3.0.0 <4.0.0",
+          "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz"
         }
         }
       }
       }
     },
     },
@@ -4173,9 +4200,9 @@
       "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.10.tgz"
       "resolved": "https://registry.npmjs.org/nodemailer-wellknown/-/nodemailer-wellknown-0.1.10.tgz"
     },
     },
     "nopt": {
     "nopt": {
-      "version": "3.0.6",
-      "from": "nopt@>=2.0.0 <3.0.0||>=3.0.0 <4.0.0",
-      "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz"
+      "version": "1.0.10",
+      "from": "nopt@1.0.10",
+      "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz"
     },
     },
     "normalize-package-data": {
     "normalize-package-data": {
       "version": "2.3.5",
       "version": "2.3.5",
@@ -5838,7 +5865,7 @@
     },
     },
     "whatwg-fetch": {
     "whatwg-fetch": {
       "version": "2.0.2",
       "version": "2.0.2",
-      "from": "whatwg-fetch@>=0.10.0",
+      "from": "whatwg-fetch@>=2.0.1 <3.0.0",
       "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz"
       "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz"
     },
     },
     "which": {
     "which": {

+ 2 - 1
package.json

@@ -49,7 +49,8 @@
     "csrf": "~3.0.3",
     "csrf": "~3.0.3",
     "debug": "~2.2.0",
     "debug": "~2.2.0",
     "del": "~2.2.0",
     "del": "~2.2.0",
-    "diff": "~2.2.2",
+    "diff": "~3.2.0",
+    "diff2html": "~2.0.12",
     "elasticsearch": "~11.0.1",
     "elasticsearch": "~11.0.1",
     "emojify.js": "^1.1.0",
     "emojify.js": "^1.1.0",
     "errorhandler": "~1.3.4",
     "errorhandler": "~1.3.4",

+ 1 - 0
public/css/diff2html

@@ -0,0 +1 @@
+../../node_modules/diff2html/dist

+ 24 - 7
resource/css/_page.scss

@@ -277,22 +277,39 @@
     .revision-hisory-outer {
     .revision-hisory-outer {
       margin-top: 8px;
       margin-top: 8px;
 
 
-      .picture {
-        float: left;
-        width: 32px;
-        height: 32px;
-      }
-
       .revision-history-main {
       .revision-history-main {
-        margin-left: 40px;
+
+        .picture {
+          float: left;
+          width: 32px;
+          height: 32px;
+        }
 
 
         .revision-history-author {
         .revision-history-author {
+          margin-left: 40px;
           color: #666;
           color: #666;
         }
         }
         .revision-history-comment {
         .revision-history-comment {
+          margin-left: 40px;
         }
         }
         .revision-history-meta {
         .revision-history-meta {
+          margin-left: 40px;
+
+          p {
+            margin-bottom: 8px;
+          }
+
+          a {
+            margin-right: 8px;
+          }
+          a:hover {
+            cursor: pointer;
+          }
         }
         }
+
+      }
+      .revision-history-diff {
+        margin-left: 40px;
       }
       }
     }
     }
 
 

+ 6 - 0
resource/css/crowi.scss

@@ -251,6 +251,12 @@ footer {
   border-radius: 3px;
   border-radius: 3px;
 }
 }
 
 
+// adjust
+// this is for diff2html. hide page name from diff view
+.d2h-file-header {
+  display: none;
+}
+
 // components
 // components
 .flip-container { // {{{
 .flip-container { // {{{
   perspective: 1000;
   perspective: 1000;

+ 14 - 3
resource/js/app.js

@@ -5,14 +5,19 @@ import Crowi from './util/Crowi';
 import CrowiRenderer from './util/CrowiRenderer';
 import CrowiRenderer from './util/CrowiRenderer';
 
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import HeaderSearchBox  from './components/HeaderSearchBox';
-import SearchPage  from './components/SearchPage';
-import PageListSearch  from './components/PageListSearch';
+import SearchPage       from './components/SearchPage';
+import PageListSearch   from './components/PageListSearch';
+import PageHistory      from './components/PageHistory';
+import SeenUserList     from './components/SeenUserList';
 //import PageComment  from './components/PageComment';
 //import PageComment  from './components/PageComment';
-import SeenUserList from './components/SeenUserList';
 
 
 if (!window) {
 if (!window) {
   window = {};
   window = {};
 }
 }
+
+const mainContent = document.querySelector('#content-main');
+const pageId = mainContent.attributes['data-page-id'].value || null;
+
 // FIXME
 // FIXME
 const crowi = new Crowi({me: $('#content-main').data('current-username')}, window);
 const crowi = new Crowi({me: $('#content-main').data('current-username')}, window);
 window.crowi = crowi;
 window.crowi = crowi;
@@ -25,6 +30,7 @@ const componentMappings = {
   'search-top': <HeaderSearchBox />,
   'search-top': <HeaderSearchBox />,
   'search-page': <SearchPage />,
   'search-page': <SearchPage />,
   'page-list-search': <PageListSearch />,
   'page-list-search': <PageListSearch />,
+  //'revision-history': <PageHistory pageId={pageId} />,
   //'page-comment': <PageComment />,
   //'page-comment': <PageComment />,
   'seen-user-list': <SeenUserList />,
   'seen-user-list': <SeenUserList />,
 };
 };
@@ -35,3 +41,8 @@ Object.keys(componentMappings).forEach((key) => {
     ReactDOM.render(componentMappings[key], elem);
     ReactDOM.render(componentMappings[key], elem);
   }
   }
 });
 });
+
+// うわーもうー
+$('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
+  ReactDOM.render(<PageHistory pageId={pageId} crowi={crowi} />, document.getElementById('revision-history'));
+});

+ 22 - 0
resource/js/components/Common/Icon.js

@@ -0,0 +1,22 @@
+import React from 'react';
+
+export default class Icon extends React.Component {
+
+  render() {
+    const name = this.props.name || null;
+
+    if (!name) {
+      return '';
+    }
+
+    return (
+      <i className={"fa fa-" + name} />
+    );
+  }
+}
+
+// TODO: support spin, size and so far
+Icon.propTypes = {
+  name: React.PropTypes.string.isRequired,
+};
+

+ 34 - 0
resource/js/components/Common/UserDate.js

@@ -0,0 +1,34 @@
+import React from 'react';
+
+import moment from 'moment';
+
+/**
+ * UserDate
+ *
+ * display date depends on user timezone of user settings
+ */
+export default class UserDate extends React.Component {
+
+  render() {
+    const dt = moment(this.props.dateTime).format(this.props.format);
+
+    return (
+      <span className={this.props.className}>
+        {dt}
+      </span>
+    );
+  }
+}
+
+UserDate.propTypes = {
+  dateTime: React.PropTypes.string.isRequired,
+  format: React.PropTypes.string,
+  className: React.PropTypes.string,
+};
+
+UserDate.defaultProps = {
+  dateTime: 'now',
+  format: 'YYYY/MM/DD HH:mm:ss',
+  className: '',
+};
+

+ 139 - 0
resource/js/components/PageHistory.js

@@ -0,0 +1,139 @@
+import React from 'react';
+
+import Icon from './Common/Icon';
+import PageRevisionList from './PageHistory/PageRevisionList';
+
+export default class PageHistory extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      revisions: [],
+      diffOpened: {},
+    };
+
+    this.getPreviousRevision = this.getPreviousRevision.bind(this);
+    this.onDiffOpenClicked = this.onDiffOpenClicked.bind(this);
+  }
+
+  componentDidMount() {
+    const pageId = this.props.pageId;
+
+    if (!pageId) {
+      return ;
+    }
+
+    this.props.crowi.apiGet('/revisions.ids', {page_id: pageId})
+    .then(res => {
+
+      const rev = res.revisions;
+      let diffOpened = {};
+      const lastId = rev.length - 1;
+      res.revisions.map((revision, i) => {
+        const user = this.props.crowi.findUserById(revision.author);
+        if (user) {
+          rev[i].author = user;
+        }
+
+        if (i === 0 || i === lastId) {
+          diffOpened[revision._id] = true;
+        } else {
+          diffOpened[revision._id] = false;
+        }
+      });
+
+      this.setState({
+        revisions: rev,
+        diffOpened: diffOpened,
+      });
+
+      // load 0, and last default
+      if (rev[0]) {
+        this.fetchPageRevisionBody(rev[0]);
+      }
+      if (rev[1]) {
+        this.fetchPageRevisionBody(rev[1]);
+      }
+      if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
+        this.fetchPageRevisionBody(rev[lastId]);
+      }
+    }).catch(err => {
+      // do nothing
+    });
+  }
+
+  getPreviousRevision(currentRevision) {
+    let cursor = null;
+    for (let revision of this.state.revisions) {
+      if (cursor && cursor._id == currentRevision._id) {
+        cursor = revision;
+        break;
+      }
+
+      cursor = revision;
+    }
+
+    return cursor;
+  }
+
+  onDiffOpenClicked(revision) {
+    const diffOpened = this.state.diffOpened,
+      revisionId = revision._id;
+
+    if (diffOpened[revisionId]) {
+      return ;
+    }
+
+    diffOpened[revisionId] = true;
+    this.setState({
+      diffOpened
+    });
+
+    this.fetchPageRevisionBody(revision);
+    this.fetchPageRevisionBody(this.getPreviousRevision(revision));
+  }
+
+  fetchPageRevisionBody(revision) {
+    if (revision.body) {
+      return ;
+    }
+
+    this.props.crowi.apiGet('/revisions.get', {revision_id: revision._id})
+    .then(res => {
+      if (res.ok) {
+        this.setState({
+          revisions: this.state.revisions.map((rev) => {
+            if (rev._id == res.revision._id) {
+              return res.revision;
+            }
+
+            return rev;
+          })
+        })
+      }
+    }).catch(err => {
+
+    });
+
+  }
+
+  render() {
+    return (
+      <div>
+        <h1><Icon name="history" /> History</h1>
+        <PageRevisionList
+          revisions={this.state.revisions}
+          diffOpened={this.state.diffOpened}
+          getPreviousRevision={this.getPreviousRevision}
+          onDiffOpenClicked={this.onDiffOpenClicked}
+        />
+      </div>
+    );
+  }
+}
+
+PageHistory.propTypes = {
+  pageId: React.PropTypes.string,
+  crowi: React.PropTypes.object.isRequired,
+};

+ 54 - 0
resource/js/components/PageHistory/PageRevisionList.js

@@ -0,0 +1,54 @@
+import React from 'react';
+
+import Revision     from './Revision';
+import RevisionDiff from './RevisionDiff';
+
+export default class PageRevisionList extends React.Component {
+
+  render() {
+    const revisions = this.props.revisions,
+      revisionCount = this.props.revisions.length;
+
+    const revisionList = this.props.revisions.map((revision, idx) => {
+      const revisionId = revision._id
+        , revisionDiffOpened = this.props.diffOpened[revisionId] || false
+
+
+      let previousRevision;
+      if (idx+1 < revisionCount) {
+        previousRevision = revisions[idx + 1];
+      } else {
+        previousRevision = revision; // if it is the first revision, show full text as diff text
+      }
+
+      return (
+        <div className="revision-hisory-outer" key={"revision-history-" + revisionId}>
+          <Revision
+            revision={revision}
+            onDiffOpenClicked={this.props.onDiffOpenClicked}
+            key={"revision-history-rev-" + revisionId}
+            />
+          <RevisionDiff
+            revisionDiffOpened={revisionDiffOpened}
+            currentRevision={revision}
+            previousRevision={previousRevision}
+            key={"revision-diff-" + revisionId}
+          />
+        </div>
+      );
+    });
+
+    return (
+      <div className="revision-history-list">
+        {revisionList}
+      </div>
+    );
+  }
+}
+
+PageRevisionList.propTypes = {
+  revisions: React.PropTypes.array,
+  diffOpened: React.PropTypes.object,
+  onDiffOpenClicked: React.PropTypes.func.isRequired,
+}
+

+ 59 - 0
resource/js/components/PageHistory/Revision.js

@@ -0,0 +1,59 @@
+import React from 'react';
+
+import UserDate     from '../Common/UserDate';
+import Icon         from '../Common/Icon';
+import UserPicture  from '../User/UserPicture';
+
+export default class Revision extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this._onDiffOpenClicked = this._onDiffOpenClicked.bind(this);
+  }
+
+  componentDidMount() {
+  }
+
+  _onDiffOpenClicked() {
+    this.props.onDiffOpenClicked(this.props.revision);
+  }
+
+  render() {
+    const revision = this.props.revision;
+    const author = revision.author;
+
+    let pic = '';
+    if (typeof author === 'object') {
+      pic = <UserPicture user={author} />;
+    }
+
+    return (
+      <div className="revision-history-main">
+        {pic}
+        <div className="revision-history-author">
+          <strong>{author.username}</strong>
+        </div>
+        <div className="revision-history-meta">
+          <p>
+            <UserDate dateTime={revision.createdAt} />
+          </p>
+          <p>
+            <a href={"?revision=" + revision._id }>
+              <Icon name="history" /> View this version
+            </a>
+            <a className="diff-view" onClick={this._onDiffOpenClicked}>
+              <Icon name="level-down" /> View diff
+            </a>
+          </p>
+        </div>
+      </div>
+    );
+  }
+}
+
+Revision.propTypes = {
+  revision: React.PropTypes.object,
+  onDiffOpenClicked: React.PropTypes.func.isRequired,
+}
+

+ 42 - 0
resource/js/components/PageHistory/RevisionDiff.js

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { createPatch } from 'diff';
+import { Diff2Html } from 'diff2html';
+
+export default class RevisionDiff extends React.Component {
+
+  render() {
+    const currentRevision = this.props.currentRevision,
+      previousRevision = this.props.previousRevision,
+      revisionDiffOpened = this.props.revisionDiffOpened;
+
+
+    let diffViewHTML = '';
+    if (currentRevision.body
+      && previousRevision.body
+      && revisionDiffOpened) {
+
+      let previousText = previousRevision.body;
+      if (currentRevision._id == previousRevision._id) {
+        previousText = '';
+      }
+
+      const patch = createPatch(
+        currentRevision.path,
+        previousText,
+        currentRevision.body
+      );
+
+      diffViewHTML = Diff2Html.getPrettyHtml(patch);
+    }
+
+    const diffView = {__html: diffViewHTML};
+    return <div className="revision-history-diff" dangerouslySetInnerHTML={diffView} />;
+  }
+}
+
+RevisionDiff.propTypes = {
+  currentRevision: React.PropTypes.object.isRequired,
+  previousRevision: React.PropTypes.object.isRequired,
+  revisionDiffOpened: React.PropTypes.bool.isRequired,
+}

+ 0 - 82
resource/js/crowi.js

@@ -2,7 +2,6 @@
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 */
 
 
-var jsdiff = require('diff');
 var io = require('socket.io-client');
 var io = require('socket.io-client');
 
 
 //require('bootstrap-sass');
 //require('bootstrap-sass');
@@ -701,87 +700,6 @@ $(function() {
       return $userHtml;
       return $userHtml;
     }
     }
 
 
-    // History Diff
-    var allRevisionIds = [];
-    $.each($('.diff-view'), function() {
-      allRevisionIds.push($(this).data('revisionId'));
-    });
-
-    $('.diff-view').on('click', function(e) {
-      e.preventDefault();
-
-      var getBeforeRevisionId = function(revisionId) {
-        var currentPos = $.inArray(revisionId, allRevisionIds);
-        if (currentPos < 0) {
-          return false;
-        }
-
-        var beforeRevisionId = allRevisionIds[currentPos + 1];
-        if (typeof beforeRevisionId === 'undefined') {
-          return false;
-        }
-
-        return beforeRevisionId;
-      };
-
-      var revisionId = $(this).data('revisionId');
-      var beforeRevisionId = getBeforeRevisionId(revisionId);
-      var $diffDisplay = $('#diff-display-' + revisionId);
-      var $diffIcon = $('#diff-icon-' + revisionId);
-
-      if ($diffIcon.hasClass('fa-arrow-circle-right')) {
-        $diffIcon.removeClass('fa-arrow-circle-right');
-        $diffIcon.addClass('fa-arrow-circle-down');
-      } else {
-        $diffIcon.removeClass('fa-arrow-circle-down');
-        $diffIcon.addClass('fa-arrow-circle-right');
-      }
-
-      if (beforeRevisionId === false) {
-        $diffDisplay.text('差分はありません');
-        $diffDisplay.slideToggle();
-      } else {
-        var revisionIds = revisionId + ',' + beforeRevisionId;
-
-        if ($diffDisplay.data('loaded')) {
-          $diffDisplay.slideToggle();
-          return true;
-        }
-
-        $.ajax({
-          type: 'GET',
-          url: '/_api/revisions.list?revision_ids=' + revisionIds,
-          dataType: 'json'
-        }).done(function(res) {
-          var currentText = res[0].body;
-          var previousText = res[1].body;
-
-          $diffDisplay.text('');
-
-          var diff = jsdiff.diffLines(previousText, currentText);
-          diff.forEach(function(part) {
-            var color = part.added ? 'green' : part.removed ? 'red' : 'grey';
-            var $span = $('<span>');
-            $span.css('color', color);
-            $span.text(part.value);
-            $diffDisplay.append($span);
-          });
-
-          $diffDisplay.data('loaded', 1);
-          $diffDisplay.slideToggle();
-        });
-      }
-    });
-
-    // default open
-    $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', function() {
-      $('.diff-view').each(function(i, diffView) {
-        if (i < 2) {
-          $(diffView).click();
-        }
-      });
-    });
-
     // presentation
     // presentation
     var presentaionInitialized = false
     var presentaionInitialized = false
       , $b = $('body');
       , $b = $('body');

+ 16 - 1
resource/js/util/Crowi.js

@@ -22,6 +22,7 @@ export default class Crowi {
 
 
     this.users = [];
     this.users = [];
     this.userByName = {};
     this.userByName = {};
+    this.userById   = {};
     this.draft = {};
     this.draft = {};
 
 
     this.recoverData();
     this.recoverData();
@@ -34,6 +35,7 @@ export default class Crowi {
   recoverData() {
   recoverData() {
     const keys = [
     const keys = [
       'userByName',
       'userByName',
+      'userById',
       'users',
       'users',
       'draft',
       'draft',
     ];
     ];
@@ -50,7 +52,7 @@ export default class Crowi {
   }
   }
 
 
   fetchUsers () {
   fetchUsers () {
-    const interval = 1000*60*10; // 5min
+    const interval = 1000*60*15; // 15min
     const currentTime = new Date();
     const currentTime = new Date();
     if (!this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
     if (!this.localStorage.lastFetched && interval > currentTime - new Date(this.localStorage.lastFetched)) {
       return ;
       return ;
@@ -62,13 +64,18 @@ export default class Crowi {
       this.localStorage.users = JSON.stringify(data.users);
       this.localStorage.users = JSON.stringify(data.users);
 
 
       let userByName = {};
       let userByName = {};
+      let userById = {};
       for (let i = 0; i < data.users.length; i++) {
       for (let i = 0; i < data.users.length; i++) {
         const user = data.users[i];
         const user = data.users[i];
         userByName[user.username] = user;
         userByName[user.username] = user;
+        userById[user._id] = user;
       }
       }
       this.userByName = userByName;
       this.userByName = userByName;
       this.localStorage.userByName = JSON.stringify(userByName);
       this.localStorage.userByName = JSON.stringify(userByName);
 
 
+      this.userById = userById;
+      this.localStorage.userById = JSON.stringify(userById);
+
       this.localStorage.lastFetched = new Date();
       this.localStorage.lastFetched = new Date();
     }).catch(err => {
     }).catch(err => {
       this.localStorage.removeItem('lastFetched');
       this.localStorage.removeItem('lastFetched');
@@ -94,6 +101,14 @@ export default class Crowi {
     return null;
     return null;
   }
   }
 
 
+  findUserById(userId) {
+    if (this.userById && this.userById[userId]) {
+      return this.userById[userId];
+    }
+
+    return null;
+  }
+
   findUser(username) {
   findUser(username) {
     if (this.userByName && this.userByName[username]) {
     if (this.userByName && this.userByName[username]) {
       return this.userByName[username];
       return this.userByName[username];