Parcourir la source

Merge pull request #48 from crowi/improve-bookmark

Improve bookmark
Sotaro KARASAWA il y a 10 ans
Parent
commit
1750259582

+ 16 - 0
lib/crowi/index.js

@@ -25,18 +25,24 @@ function Crowi (rootdir, env)
   this.pluginDir = path.join(this.rootDir, 'node_modules') + sep;
   this.publicDir = path.join(this.rootDir, 'public') + sep;
   this.libDir    = path.join(this.rootDir, 'lib') + sep;
+  this.eventsDir = path.join(this.libDir, 'events') + sep;
   this.viewsDir  = path.join(this.libDir, 'views') + sep;
   this.mailDir   = path.join(this.viewsDir, 'mail') + sep;
 
   this.config = {};
   this.mailer = {};
 
+
   this.models = {};
 
   this.env = env;
   this.node_env = this.env.NODE_ENV || 'development';
   this.port = this.env.PORT || 3000;
 
+  this.events = {
+    user: new (require(self.eventsDir + 'user'))(this),
+  };
+
   if (this.node_env == 'development') {
     Promise.longStackTraces();
   }
@@ -94,6 +100,15 @@ Crowi.prototype.model = function(name, model) {
   return this.models[name];
 };
 
+// getter/setter of event instance
+Crowi.prototype.event = function(name, event) {
+  if (event) {
+    return this.events[name] = event;
+  }
+
+  return this.events[name];
+};
+
 Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   var mongoUri = this.env.MONGOLAB_URI ||
@@ -105,6 +120,7 @@ Crowi.prototype.setupDatabase = function() {
   return new Promise(function(resolve, reject) {
     mongoose.connect(mongoUri, function(e) {
       if (e) {
+        debug('DB Connect Error: ', e);
         debug('DB Connect Error: ', mongoUri);
         return reject(new Error('Cann\'t connect to Database Server.'));
       }

+ 34 - 0
lib/events/user.js

@@ -0,0 +1,34 @@
+var debug = require('debug')('crowi:events:user');
+var util = require('util');
+var events = require('events');
+var sprintf = require('sprintf');
+
+function UserEvent(crowi) {
+  this.crowi = crowi;
+
+  events.EventEmitter.call(this);
+}
+util.inherits(UserEvent, events.EventEmitter);
+
+UserEvent.prototype.onActivated = function(user) {
+  var User = this.crowi.model('User');
+  var Page = this.crowi.model('Page');
+
+  var userPagePath = Page.getUserPagePath(user);
+  Page.findPage(userPagePath, user, {}, false)
+  .then(function(page) {
+    // do nothing because user page is already exists.
+  }).catch(function(err) {
+    var body = sprintf('# %s\nThis is %s\'s page', user.username, user.username)
+    // create user page
+    Page.create(userPagePath, body, user, {})
+    .then(function(page) {
+      // page created
+      debug('User page created', page);
+    }).catch(function(err) {
+      debug('Failed to create user page', err);
+    });
+  });
+};
+
+module.exports = UserEvent;

+ 4 - 2
lib/form/revision.js

@@ -4,6 +4,8 @@ var form = require('express-form')
   , field = form.field;
 
 module.exports = form(
-  field('pageForm.body').required()
-  //field('pageForm.hoge').required()
+  field('pageForm.path').required(),
+  field('pageForm.body').required().custom(function(value) { return value.replace(/\r/g, '\n'); }),
+  field('pageForm.currentRevision'),
+  field('pageForm.grant').toInt().required()
 );

+ 93 - 24
lib/models/bookmark.js

@@ -10,46 +10,115 @@ module.exports = function(crowi) {
     user: { type: ObjectId, ref: 'User', index: true },
     createdAt: { type: Date, default: Date.now() }
   });
+  bookmarkSchema.index({page: 1, user: 1}, {unique: true});
+
+  bookmarkSchema.statics.populatePage = function(bookmarks) {
+    var Bookmark = this;
+    var User = crowi.model('User');
+    var Page = crowi.model('Page');
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.populate(bookmarks, {path: 'page'}, function(err, bookmarks) {
+        if (err) {
+          return reject(err);
+        }
+
+        Bookmark.populate(bookmarks, {path: 'page.revision', model: 'Revision'}, function(err, bookmarks) {
+          if (err) {
+            return reject(err);
+          }
+
+          Bookmark.populate(bookmarks, {path: 'page.revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, bookmarks) {
+            if (err) {
+              return reject(err);
+            }
+
+            return resolve(bookmarks);
+          });
+        });
+      });
+    });
+  };
 
   // bookmark チェック用
-  bookmarkSchema.statics.findByPageIdAndUser = function(pageId, user, callback) {
+  bookmarkSchema.statics.findByPageIdAndUserId = function(pageId, userId) {
     var Bookmark = this;
 
-    Bookmark.findOne({ page: pageId, user: user._id }, callback);
+    return new Promise(function(resolve, reject) {
+      return Bookmark.findOne({ page: pageId, user: userId }, function(err, doc) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(doc);
+      });
+    });
   };
 
-  bookmarkSchema.statics.findByUser = function(user, option, callback) {
+  bookmarkSchema.statics.findByUser = function(user, option) {
+    var User = crowi.model('User');
     var Bookmark = this;
 
     var limit = option.limit || 50;
-    var offset = option.skip || 0;
-
-    Bookmark
-      .find({ user: user._id })
-      //.sort('createdAt', -1)
-      .skip(offset)
-      .limit(limit)
-      .exec(function(err, bookmarks) {
-        debug ('bookmarks', bookmarks);
-        callback(err, bookmarks);
-      });
+    var offset = option.offset || 0;
+    var populatePage = option.populatePage || false;
+
+    return new Promise(function(resolve, reject) {
+      Bookmark
+        .find({ user: user._id })
+        .sort({createdAt: -1})
+        .skip(offset)
+        .limit(limit)
+        .exec(function(err, bookmarks) {
+          if (err) {
+            return reject(err);
+          }
+
+          if (!populatePage) {
+            return resolve(bookmarks);
+          }
+
+          return Bookmark.populatePage(bookmarks).then(resolve).catch(reject);
+        });
+    });
   };
 
-  bookmarkSchema.statics.add = function(page, user, callback) {
+  bookmarkSchema.statics.add = function(page, user) {
     var Bookmark = this;
 
-    Bookmark.findOneAndUpdate(
-      { page: page._id, user: user._id },
-      { page: page._id, user: user._id, createdAt: Date.now() },
-      { upsert: true, },
-      function (err, bookmark) {
-        debug('Bookmark.findOneAndUpdate', err, bookmark);
-        callback(err, bookmark);
+    return new Promise(function(resolve, reject) {
+      var newBookmark = new Bookmark;
+
+      newBookmark.page = page;
+      newBookmark.user = user;
+      newBookmark.createdAt = Date.now();
+      newBookmark.save(function(err, bookmark) {
+        debug('Bookmark.save', err, bookmark);
+        if (err) {
+          if (err.code === 11000) { // duplicate key (dummy reesponse of new object)
+            return resolve(newBookmark);
+          }
+          return reject(err);
+        }
+
+        resolve(bookmark);
+      });
     });
   };
 
-  bookmarkSchema.statics.remove = function(page, user, callback) {
-    // To be implemented ...
+  bookmarkSchema.statics.remove = function(page, user) {
+    var Bookmark = this;
+
+    return new Promise(function(resolve, reject) {
+      Bookmark.findOneAndRemove({page: page, user: user}, function(err, data) {
+        if (err) {
+          debug('Bookmark.findOneAndRemove failed', err);
+          return reject(err);
+        }
+
+        return resolve(data);
+      });
+    });
   };
 
   return mongoose.model('Bookmark', bookmarkSchema);

+ 1 - 1
lib/models/comment.js

@@ -57,7 +57,7 @@ module.exports = function(crowi) {
             return resolve([]);
           }
 
-          debug('Comment loaded', data);
+          //debug('Comment loaded', data);
           return resolve(data);
         });
     });

+ 360 - 165
lib/models/page.js

@@ -7,27 +7,14 @@ module.exports = function(crowi) {
     , GRANT_SPECIFIED = 3
     , GRANT_OWNER = 4
     , PAGE_GRANT_ERROR = 1
-    , USER_PUBLIC_FIELDS = '_id fbId image googleId name username email status createdAt' // TODO: どこか別の場所へ...
     , pageSchema;
 
-  function populatePageData(pageData, revisionId, callback) {
-    var Page = crowi.model('Page');
-
-    pageData.latestRevision = pageData.revision;
-    if (revisionId) {
-      pageData.revision = revisionId;
+  function isPortalPath(path) {
+    if (path.match(/.*\/$/)) {
+      return true;
     }
-    pageData.likerCount = pageData.liker.length || 0;
-    pageData.seenUsersCount = pageData.seenUsers.length || 0;
 
-    pageData.populate([
-      {path: 'creator', model: 'User', select: USER_PUBLIC_FIELDS},
-      {path: 'revision', model: 'Revision'},
-      {path: 'liker', options: { limit: 11 }},
-      {path: 'seenUsers', options: { limit: 11 }},
-    ], function (err, pageData) {
-      Page.populate(pageData, {path: 'revision.author', model: 'User'}, callback);
-    });
+    return false;
   }
 
   pageSchema = new mongoose.Schema({
@@ -52,6 +39,10 @@ module.exports = function(crowi) {
     return false;
   };
 
+  pageSchema.methods.isPortal = function() {
+    return isPortalPath(this.path);
+  };
+
   pageSchema.methods.isCreator = function(userData) {
     if (this.populated('creator') && this.creator._id.toString() === userData._id.toString()) {
       return true;
@@ -92,48 +83,53 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.isLiked = function(userData) {
-    if (undefined === this.populated('liker')) {
-      if (this.liker.indexOf(userData._id.toString()) != -1) {
-        return true;
-      }
-      return true;
-    } else {
-      return this.liker.some(function(likedUser) {
-        return likedUser._id.equals(userData._id);
-      });
-    }
+    return this.liker.some(function(likedUser) {
+      return likedUser == userData._id.toString();
+    });
   };
 
-  pageSchema.methods.like = function(userData, callback) {
+  pageSchema.methods.like = function(userData) {
     var self = this,
       Page = self;
 
-    var added = this.liker.addToSet(userData._id);
-    if (added.length > 0) {
-      this.save(function(err, data) {
-        debug('liker updated!', added);
-        return callback(err, data);
-      });
-    } else {
-      debug('liker not updated');
-      return callback(null, this);
-    }
+    return new Promise(function(resolve, reject) {
+      var added = self.liker.addToSet(userData._id);
+      if (added.length > 0) {
+        self.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+          debug('liker updated!', added);
+          return resolve(data);
+        });
+      } else {
+        debug('liker not updated');
+        return reject(self);
+      }
+    });
+
   };
 
   pageSchema.methods.unlike = function(userData, callback) {
     var self = this,
       Page = self;
 
-    var removed = this.liker.pull(userData._id);
-    if (removed.length > 0) {
-      this.save(function(err, data) {
-        debug('unlike updated!', removed);
-        return callback(err, data);
-      });
-    } else {
-      debug('unlike not updated');
-      callback(null, this);
-    }
+    return new Promise(function(resolve, reject) {
+      var beforeCount = self.liker.length;
+      self.liker.pull(userData._id);
+      if (self.liker.length != beforeCount) {
+        self.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+          return resolve(data);
+        });
+      } else {
+        debug('liker not updated');
+        return reject(self);
+      }
+    });
+
   };
 
   pageSchema.methods.isSeenUser = function(userData) {
@@ -141,30 +137,94 @@ module.exports = function(crowi) {
       Page = self;
 
     return this.seenUsers.some(function(seenUser) {
-      return seenUser._id.equals(userData._id);
+      return seenUser.equals(userData._id);
     });
   };
 
-  pageSchema.methods.seen = function(userData, callback) {
+  pageSchema.methods.seen = function(userData) {
     var self = this,
       Page = self;
 
-    if (!userData || !userData._id) {
-      callback(new Error('User data is not valid'), null);
-    }
-
     if (this.isSeenUser(userData)) {
       debug('seenUsers not updated');
-      return callback(null, self);
+      return Promise.resolve(this);
+    }
+
+    return new Promise(function(resolve, reject) {
+      if (!userData || !userData._id) {
+        reject(new Error('User data is not valid'));
+      }
+
+      var added = self.seenUsers.addToSet(userData);
+      self.save(function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        debug('seenUsers updated!', added);
+        return resolve(self);
+      });
+    });
+  };
+
+  pageSchema.statics.populatePageData = function(pageData, revisionId) {
+    var Page = crowi.model('Page');
+    var User = crowi.model('User');
+
+    pageData.latestRevision = pageData.revision;
+    if (revisionId) {
+      pageData.revision = revisionId;
     }
+    pageData.likerCount = pageData.liker.length || 0;
+    pageData.seenUsersCount = pageData.seenUsers.length || 0;
+
+    return new Promise(function(resolve, reject) {
+      pageData.populate([
+        {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
+        {path: 'revision', model: 'Revision'},
+        //{path: 'liker', options: { limit: 11 }},
+        //{path: 'seenUsers', options: { limit: 11 }},
+      ], function (err, pageData) {
+        Page.populate(pageData, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
+      });
+    });
+  };
+
+  pageSchema.statics.populatePageList = function(pageList) {
+    var Page = self;
+    var User = crowi.model('User');
+
+    return new Promise(function(resolve, reject) {
+      Page.populate(
+        pageList,
+        [
+          {path: 'creator', model: 'User', select: User.USER_PUBLIC_FIELDS},
+          {path: 'revision', model: 'Revision'}
+        ],
+        function(err, pageList) {
+          if (err) {
+            return reject(err);
+          }
+
+          Page.populate(pageList, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+            if (err) {
+              return reject(err);
+            }
 
-    var added = self.seenUsers.addToSet(userData);
-    this.save(function(err, data) {
-      debug('seenUsers updated!', added);
-      return callback(err, self);
+            resolve(data);
+          });
+        }
+      );
     });
   };
 
+
   pageSchema.statics.updateCommentCount = function (page, num)
   {
     var self = this;
@@ -181,6 +241,18 @@ module.exports = function(crowi) {
     });
   };
 
+  pageSchema.statics.hasPortalPage = function (path, user) {
+    var self = this;
+    return new Promise(function(resolve, reject) {
+      self.findPage(path, user)
+      .then(function(page) {
+        resolve(page);
+      }).catch(function(err) {
+        resolve(null); // check only has portal page, through error
+      });
+    });
+  };
+
   pageSchema.statics.getGrantLabels = function() {
     var grantLabels = {};
     grantLabels[GRANT_PUBLIC]     = '公開';
@@ -199,14 +271,19 @@ module.exports = function(crowi) {
     return path;
   };
 
+  pageSchema.statics.getUserPagePath = function(user) {
+    return '/user/' + user.username;
+  };
+
   pageSchema.statics.isCreatableName = function(name) {
     var forbiddenPages = [
       /\^|\$|\*|\+|\#/,
       /^\/_api\/.*/,
       /^\/\-\/.*/,
       /^\/_r\/.*/,
+      /^\/user\/[^\/]+\/(bookmarks|comments|activities|pages|recent-create|recent-edit)/, // reserved
       /.+\/edit$/,
-      /\/$/,
+      /.+\.md$/,
       /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments).+/,
     ];
 
@@ -231,7 +308,7 @@ module.exports = function(crowi) {
   pageSchema.statics.findUpdatedList = function(offset, limit, cb) {
     this
       .find({})
-      .sort('updatedAt', -1)
+      .sort({updatedAt: -1})
       .skip(offset)
       .limit(limit)
       .exec(function(err, data) {
@@ -239,86 +316,173 @@ module.exports = function(crowi) {
       });
   };
 
-  pageSchema.statics.findPageById = function(id, cb) {
+  pageSchema.statics.findPageById = function(id) {
     var Page = this;
 
-    Page.findOne({_id: id}, function(err, pageData) {
-      if (pageData === null) {
-        return cb(new Error('Page Not Found'), null);
-      }
+    return new Promise(function(resolve, reject) {
+      Page.findOne({_id: id}, function(err, pageData) {
+        if (err) {
+          return reject(err);
+        }
 
-      return populatePageData(pageData, null, cb);
+        return Page.populatePageData(pageData, null).then(resolve);
+      });
     });
   };
 
-  pageSchema.statics.findPageByIdAndGrantedUser = function(id, userData, cb) {
+  pageSchema.statics.findPageByIdAndGrantedUser = function(id, userData) {
     var Page = this;
 
-    Page.findPageById(id, function(err, pageData) {
-      if (pageData === null) {
-        return cb(new Error('Page Not Found'), null);
-      }
+    return new Promise(function(resolve, reject) {
+      Page.findPageById(id)
+      .then(function(pageData) {
+        if (userData && !pageData.isGrantedFor(userData)) {
+          return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
+        }
 
-      if (userData && !pageData.isGrantedFor(userData)) {
-        return cb(PAGE_GRANT_ERROR, null);
-      }
+        return resolve(pageData);
+      }).catch(function(err) {
+        return reject(err);
+      });
+    });
+  };
+
+  // find page and check if granted user
+  pageSchema.statics.findPage = function(path, userData, revisionId, ignoreNotFound) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.findOne({path: path}, function(err, pageData) {
+        if (err) {
+          return reject(err);
+        }
+
+        if (pageData === null) {
+          if (ignoreNotFound) {
+            return resolve(null);
+          }
 
-      return cb(null,pageData);
+          var pageNotFoundError = new Error('Page Not Found')
+          pageNotFoundError.name = 'Crowi:Page:NotFound';
+          return reject(pageNotFoundError);
+        }
+
+        if (!pageData.isGrantedFor(userData)) {
+          return reject(new Error('Page is not granted for the user')); //PAGE_GRANT_ERROR, null);
+        }
+
+        self.populatePageData(pageData, revisionId || null).then(resolve).catch(reject);
+      });
     });
   };
 
-  pageSchema.statics.findPage = function(path, userData, revisionId, options, cb) {
+  pageSchema.statics.findListByPageIds = function(ids, option) {
     var Page = this;
+    var User = crowi.model('User');
+    var limit = option.limit || 50;
+    var offset = option.skip || 0;
 
-    this.findOne({path: path}, function(err, pageData) {
-      if (pageData === null) {
-        return cb(new Error('Page Not Found'), null);
-      }
+    return new Promise(function(resolve, reject) {
+      Page
+        .find({ _id: { $in: ids }, grant: GRANT_PUBLIC })
+        //.sort({createdAt: -1}) // TODO optionize
+        .skip(offset)
+        .limit(limit)
+        .populate('revision')
+        .exec(function(err, pages) {
+          if (err) {
+            return reject(err);
+          }
 
-      if (!pageData.isGrantedFor(userData)) {
-        return cb(PAGE_GRANT_ERROR, null);
-      }
+          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+            if (err) {
+              return reject(err);
+            }
 
-      return populatePageData(pageData, revisionId, cb);
+            return resolve(data);
+          });
+        });
     });
   };
 
-  pageSchema.statics.findListByPageIds = function(ids, options, cb) {
+  pageSchema.statics.findListByCreator = function(user, option) {
+    var Page = this;
+    var User = crowi.model('User');
+    var limit = option.limit || 50;
+    var offset = option.offset || 0;
+
+    return new Promise(function(resolve, reject) {
+      Page
+        .find({ creator: user._id, grant: GRANT_PUBLIC })
+        .sort({createdAt: -1})
+        .skip(offset)
+        .limit(limit)
+        .populate('revision')
+        .exec(function(err, pages) {
+          if (err) {
+            return reject(err);
+          }
+
+          Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+            if (err) {
+              return reject(err);
+            }
+
+            return resolve(data);
+          });
+        });
+    });
   };
 
-  pageSchema.statics.findListByStartWith = function(path, userData, options, cb) {
-    if (!options) {
-      options = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
+  pageSchema.statics.findListByStartWith = function(path, userData, option) {
+    var Page = this;
+    var User = crowi.model('User');
+
+    if (!option) {
+      option = {sort: 'updatedAt', desc: -1, offset: 0, limit: 50};
     }
     var opt = {
-      sort: options.sort || 'updatedAt',
-      desc: options.desc || -1,
-      offset: options.offset || 0,
-      limit: options.limit || 50
+      sort: option.sort || 'updatedAt',
+      desc: option.desc || -1,
+      offset: option.offset || 0,
+      limit: option.limit || 50
     };
     var sortOpt = {};
     sortOpt[opt.sort] = opt.desc;
     var queryReg = new RegExp('^' + path);
-    var sliceOption = options.revisionSlice || {$slice: 1};
-
-    var q = this.find({
-        path: queryReg,
-        redirectTo: null,
-        $or: [
-          {grant: null},
-          {grant: GRANT_PUBLIC},
-          {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
-          {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
-          {grant: GRANT_OWNER, grantedUsers: userData._id},
-        ],
-      })
-      .populate('revision')
-      .sort(sortOpt)
-      .skip(opt.offset)
-      .limit(opt.limit);
+    var sliceOption = option.revisionSlice || {$slice: 1};
 
-    q.exec(function(err, data) {
-      cb(err, data);
+    return new Promise(function(resolve, reject) {
+      // FIXME: might be heavy
+      var q = Page.find({
+          path: queryReg,
+          redirectTo: null,
+          $or: [
+            {grant: null},
+            {grant: GRANT_PUBLIC},
+            {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
+            {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
+            {grant: GRANT_OWNER, grantedUsers: userData._id},
+          ],
+        })
+        .populate('revision')
+        .sort(sortOpt)
+        .skip(opt.offset)
+        .limit(opt.limit);
+
+      q.exec(function(err, pages) {
+        if (err) {
+          return reject(err);
+        }
+
+        Page.populate(pages, {path: 'revision.author', model: 'User', select: User.USER_PUBLIC_FIELDS}, function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
+      });
     });
   };
 
@@ -329,83 +493,114 @@ module.exports = function(crowi) {
     });
   };
 
-  pageSchema.statics.updateGrant = function(page, grant, userData, cb) {
-    this.update({_id: page._id}, {$set: {grant: grant}}, function(err, data) {
-      if (grant == GRANT_PUBLIC) {
-        page.grantedUsers = [];
-      } else {
-        page.grantedUsers = [];
-        page.grantedUsers.push(userData._id);
-      }
-      page.save(function(err, data) {
-        return cb(err, data);
+  pageSchema.statics.updateGrant = function(page, grant, userData) {
+    var self = this;
+
+    return new Promise(function(resolve, reject) {
+      self.update({_id: page._id}, {$set: {grant: grant}}, function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+
+        if (grant == GRANT_PUBLIC) {
+          page.grantedUsers = [];
+        } else {
+          page.grantedUsers = [];
+          page.grantedUsers.push(userData._id);
+        }
+        page.save(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
       });
     });
   };
 
   // Instance method でいいのでは
-  pageSchema.statics.pushToGrantedUsers = function(page, userData, cb) {
-    if (!page.grantedUsers || !Array.isArray(page.grantedUsers)) {
-      page.grantedUsers = [];
-    }
-    page.grantedUsers.push(userData._id);
-    page.save(function(err, data) {
-      return cb(err, data);
+  pageSchema.statics.pushToGrantedUsers = function(page, userData) {
+
+    return new Promise(function(resolve, reject) {
+      if (!page.grantedUsers || !Array.isArray(page.grantedUsers)) {
+        page.grantedUsers = [];
+      }
+      page.grantedUsers.push(userData);
+      page.save(function(err, data) {
+        if (err) {
+          return reject(err);
+        }
+        return resolve(data);
+      });
     });
   };
 
-  pageSchema.statics.pushRevision = function(pageData, newRevision, user, cb) {
-    newRevision.save(function(err, newRevision) {
-      if (err) {
-        debug('Error on saving revision', err);
-        return cb(err, null);
-      }
+  pageSchema.statics.pushRevision = function(pageData, newRevision, user) {
 
-      debug('Successfully saved new revision', newRevision);
-      pageData.revision = newRevision._id;
-      pageData.updatedAt = Date.now();
-      pageData.save(function(err, data) {
+    return new Promise(function(resolve, reject) {
+      newRevision.save(function(err, newRevision) {
         if (err) {
-          debug('Error on save page data (after push revision)', err);
-          cb(err, null);
-          return;
+          debug('Error on saving revision', err);
+          return reject(err);
         }
-        cb(err, data);
+
+        debug('Successfully saved new revision', newRevision);
+        pageData.revision = newRevision;
+        pageData.updatedAt = Date.now();
+        pageData.save(function(err, data) {
+          if (err) {
+            // todo: remove new revision?
+            debug('Error on save page data (after push revision)', err);
+            return reject(err);
+          }
+
+          resolve(data);
+        });
       });
     });
   };
 
-  pageSchema.statics.create = function(path, body, user, options, cb) {
+  pageSchema.statics.create = function(path, body, user, options) {
     var Page = this
       , Revision = crowi.model('Revision')
       , format = options.format || 'markdown'
       , grant = options.grant || GRANT_PUBLIC
       , redirectTo = options.redirectTo || null;
 
-    this.findOne({path: path}, function(err, pageData) {
-      if (pageData) {
-        cb(new Error('Cannot create new page to existed path'), null);
-        return;
-      }
+    // force public
+    if (isPortalPath(path)) {
+      grant = GRANT_PUBLIC;
+    }
+
+    return new Promise(function(resolve, reject) {
+      Page.findOne({path: path}, function(err, pageData) {
+        if (pageData) {
+          return reject(new Error('Cannot create new page to existed path'));
+        }
 
-      var newPage = new Page();
-      newPage.path = path;
-      newPage.creator = user;
-      newPage.createdAt = Date.now();
-      newPage.updatedAt = Date.now();
-      newPage.redirectTo = redirectTo;
-      newPage.grant = grant;
-      newPage.grantedUsers = [];
-      newPage.grantedUsers.push(user);
-      newPage.save(function (err, newPage) {
-
-        var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
-        Page.pushRevision(newPage, newRevision, user, function(err, data) {
+        var newPage = new Page();
+        newPage.path = path;
+        newPage.creator = user;
+        newPage.createdAt = Date.now();
+        newPage.updatedAt = Date.now();
+        newPage.redirectTo = redirectTo;
+        newPage.grant = grant;
+        newPage.grantedUsers = [];
+        newPage.grantedUsers.push(user);
+
+        newPage.save(function (err, newPage) {
           if (err) {
-            console.log('Push Revision Error on create page', err);
+            return reject(err);
           }
-          cb(err, data);
-          return;
+
+          var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
+          Page.pushRevision(newPage, newRevision, user).then(function(data) {
+            resolve(data);
+          }).catch(function(err) {
+            debug('Push Revision Error on create page', err);
+            return reject(err);
+          });
         });
       });
     });

+ 16 - 8
lib/models/revision.js

@@ -14,20 +14,28 @@ module.exports = function(crowi) {
 
   revisionSchema.statics.findLatestRevision = function(path, cb) {
     this.find({path: path})
-      .sort({'createdAt': -1})
+      .sort({createdAt: -1})
       .limit(1)
       .exec(function(err, data) {
         cb(err, data.shift());
       });
   };
 
-  revisionSchema.statics.findRevisionList = function(path, options, cb) {
-    this.find({path: path})
-      .sort({'createdAt': -1})
-      .populate('author')
-      .exec(function(err, data) {
-        cb(err, data);
-      });
+  revisionSchema.statics.findRevisionList = function(path, options) {
+    var Revision = this;
+
+    return new Promise(function(resolve, reject) {
+      Revision.find({path: path})
+        .sort({createdAt: -1})
+        .populate('author')
+        .exec(function(err, data) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(data);
+        });
+    });
   };
 
   revisionSchema.statics.updateRevisionListByPath = function(path, updateData, options, cb) {

+ 62 - 7
lib/models/user.js

@@ -15,6 +15,8 @@ module.exports = function(crowi) {
 
     , PAGE_ITEMS        = 20
 
+    , userEvent = crowi.event('user')
+
     , userSchema;
 
   userSchema = new mongoose.Schema({
@@ -23,16 +25,19 @@ module.exports = function(crowi) {
     image: String,
     googleId: String,
     name: { type: String },
-    username: { type: String },
-    email: { type: String, required: true },
+    username: { type: String, index: true },
+    email: { type: String, required: true, index: true  },
+    introduction: { type: String },
     password: String,
     apiToken: String,
-    status: { type: Number, required: true, default: STATUS_ACTIVE },
+    status: { type: Number, required: true, default: STATUS_ACTIVE, index: true  },
     createdAt: { type: Date, default: Date.now },
-    admin: { type: Boolean, default: 0 }
+    admin: { type: Boolean, default: 0, index: true  }
   });
   userSchema.plugin(mongoosePaginate);
 
+  userEvent.on('activated', userEvent.onActivated);
+
   function decideUserStatusOnRegistration () {
     var Config = crowi.model('Config'),
       config = crowi.getConfig();
@@ -160,6 +165,7 @@ module.exports = function(crowi) {
     this.username = username;
     this.status = STATUS_ACTIVE;
     this.save(function(err, userData) {
+      userEvent.emit('activated', userData);
       return callback(err, userData);
     });
   };
@@ -184,6 +190,7 @@ module.exports = function(crowi) {
     debug('Activate User', this);
     this.status = STATUS_ACTIVE;
     this.save(function(err, userData) {
+      userEvent.emit('activated', userData);
       return callback(err, userData);
     });
   };
@@ -253,6 +260,7 @@ module.exports = function(crowi) {
 
   userSchema.statics.findUsers = function(options, callback) {
     var sort = options.sort || {status: 1, createdAt: 1};
+
     this.find()
       .sort(sort)
       .skip(options.skip || 0)
@@ -263,6 +271,30 @@ module.exports = function(crowi) {
 
   };
 
+  userSchema.statics.findUsersByIds = function(ids, option) {
+    var User = this;
+    var option = option || {}
+      , sort = option.sort || {createdAt: -1}
+      , status = option.status || STATUS_ACTIVE
+      , fields = option.fields || USER_PUBLIC_FIELDS
+      ;
+
+
+    return new Promise(function(resolve, reject) {
+      User
+        .find({ _id: { $in: ids }, status: status })
+        .select(fields)
+        .sort(sort)
+        .exec(function (err, userData) {
+          if (err) {
+            return reject(err);
+          }
+
+          return resolve(userData);
+        });
+    });
+  };
+
   userSchema.statics.findAdmins = function(callback) {
     var User = this;
     this.find({admin: true})
@@ -285,9 +317,16 @@ module.exports = function(crowi) {
     }, { sortBy : sort });
   };
 
-  userSchema.statics.findUserByUsername = function(username, callback) {
-    this.findOne({username: username}, function (err, userData) {
-      callback(err, userData);
+  userSchema.statics.findUserByUsername = function(username) {
+    var User = this;
+    return new Promise(function(resolve, reject) {
+      User.findOne({username: username}, function (err, userData) {
+        if (err) {
+          return reject(err);
+        }
+
+        return resolve(userData);
+      });
     });
   };
 
@@ -502,6 +541,9 @@ module.exports = function(crowi) {
     newUser.status = decideUserStatusOnRegistration();
 
     newUser.save(function(err, userData) {
+      if (userData.status == STATUS_ACTIVE) {
+        userEvent.emit('activated', userData);
+      }
       return callback(err, userData);
     });
   };
@@ -519,16 +561,29 @@ module.exports = function(crowi) {
     newUser.status = decideUserStatusOnRegistration();
 
     newUser.save(function(err, userData) {
+      if (userData.status == STATUS_ACTIVE) {
+        userEvent.emit('activated', userData);
+      }
       return callback(err, userData);
     });
   };
 
+
   userSchema.statics.createUserPictureFilePath = function(user, name) {
     var ext = '.' + name.match(/(.*)(?:\.([^.]+$))/)[2];
 
     return 'user/' + user._id + ext;
   };
 
+  userSchema.statics.getUsernameByPath = function(path) {
+    var username = null;
+    if (m = path.match(/^\/user\/([^\/]+)\/?/)) {
+      username = m[1];
+    }
+
+    return username;
+  };
+
 
   userSchema.statics.STATUS_REGISTERED = STATUS_REGISTERED;
   userSchema.statics.STATUS_ACTIVE = STATUS_ACTIVE;

+ 1 - 7
lib/routes/attachment.js

@@ -61,13 +61,7 @@ module.exports = function(crowi, app) {
           return resolve(pageData);
         });
       } else {
-        Page.findPageById(id, function(err, pageData){
-          if (err) {
-            debug('Page find error', err);
-            return reject(err);
-          }
-          return resolve(pageData);
-        });
+        Page.findPageById(id).then(resolve).catch(reject);
       }
     }).then(function(pageData) {
       page = pageData;

+ 89 - 0
lib/routes/bookmark.js

@@ -0,0 +1,89 @@
+module.exports = function(crowi, app) {
+  'use strict';
+
+  var debug = require('debug')('crowi:routes:bookmark')
+    , Bookmark = crowi.model('Bookmark')
+    , Page = crowi.model('Page')
+    , User = crowi.model('User')
+    , Revision = crowi.model('Revision')
+    , Bookmark = crowi.model('Bookmark')
+    , ApiResponse = require('../util/apiResponse')
+    , actions = {}
+  ;
+  actions.api = {};
+
+  /**
+   * @api {get} /bookmarks.get Get bookmark of the page with the user
+   * @apiName GetBookmarks
+   * @apiGroup Bookmark
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  actions.api.get = function (req, res) {
+    var pageId = req.query.page_id;
+
+    Bookmark.findByPageIdAndUserId(pageId, req.user)
+    .then(function(data) {
+      debug('bookmark found', pageId, data);
+      var result = {};
+      if (data) {
+      }
+
+      result.bookmark = data;
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
+  /**
+   * @api {post} /bookmarks.add Add bookmark of the page
+   * @apiName AddBookmark
+   * @apiGroup Bookmark
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  actions.api.add = function(req, res) {
+    var pageId = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(pageData) {
+      if (pageData) {
+        return Bookmark.add(pageData, req.user);
+      } else {
+        return res.json(ApiResponse.success({bookmark: null}));
+      }
+    }).then(function(data) {
+      var result = {};
+      data.depopulate('page');
+      data.depopulate('user');
+
+      result.bookmark = data;
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
+  /**
+   * @api {post} /bookmarks.remove Remove bookmark of the page
+   * @apiName RemoveBookmark
+   * @apiGroup Bookmark
+   *
+   * @apiParam {String} page_id Page Id.
+   */
+  actions.api.remove = function(req, res){
+    var pageId = req.body.page_id;
+
+    Bookmark.remove(pageId, req.user)
+    .then(function(data) {
+      debug('Bookmark removed.', data); // if the bookmark is not exists, this 'data' is null
+      return res.json(ApiResponse.success());
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
+
+  return actions;
+};

+ 2 - 2
lib/routes/comment.js

@@ -41,7 +41,7 @@ module.exports = function(crowi, app) {
   };
 
   /**
-   * @api {post} /comments.post Post comment for the page
+   * @api {post} /comments.add Post comment for the page
    * @apiName PostComment
    * @apiGroup Comment
    *
@@ -50,7 +50,7 @@ module.exports = function(crowi, app) {
    * @apiParam {String} comment Comment body
    * @apiParam {Number} comment_position=-1 Line number of the comment
    */
-  api.post = function(req, res){
+  api.add = function(req, res){
     var form = req.form.commentForm;
 
     if (!req.form.isValid) {

+ 15 - 7
lib/routes/index.js

@@ -9,7 +9,8 @@ module.exports = function(crowi, app) {
     , installer = require('./installer')(crowi, app)
     , user      = require('./user')(crowi, app)
     , attachment= require('./attachment')(crowi, app)
-    , comment= require('./comment')(crowi, app)
+    , comment   = require('./comment')(crowi, app)
+    , bookmark  = require('./bookmark')(crowi, app)
     , loginRequired = middleware.loginRequired
     , accessTokenParser = middleware.accessTokenParser
     ;
@@ -71,19 +72,26 @@ module.exports = function(crowi, app) {
   app.get( '/_api/attachment/page/:pageId', loginRequired(crowi, app) , attachment.api.list);
   app.post('/_api/attachment/page/:pageId', loginRequired(crowi, app) , attachment.api.add);
   app.post('/_api/attachment/:id/remove',loginRequired(crowi, app), attachment.api.remove);
-  app.post('/_api/page/:id/like'      , loginRequired(crowi, app) , page.api.like);
-  app.post('/_api/page/:id/unlike'    , loginRequired(crowi, app) , page.api.unlike);
-  app.get( '/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.isBookmarked);
-  app.post('/_api/page/:id/bookmark'  , loginRequired(crowi, app) , page.api.bookmark);
+
+  app.get( '/user/:username([^/]+)/bookmarks'      , loginRequired(crowi, app) , page.userBookmarkList);
+  app.get( '/user/:username([^/]+)/recent-create'  , loginRequired(crowi, app) , page.userRecentCreatedList);
 
   // HTTP RPC Styled API (に徐々に移行していいこうと思う)
+  app.get('/_api/users.list'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , user.api.list);
   app.get('/_api/pages.get'           , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.get);
+  app.post('/_api/pages.seen'         , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.seen);
   app.get('/_api/comments.get'        , accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.get);
-  app.post('/_api/comments.post'      , form.comment, accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.post);
+  app.post('/_api/comments.add'       , form.comment, accessTokenParser(crowi, app) , loginRequired(crowi, app) , comment.api.add);
+  app.get( '/_api/bookmarks.get'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.get);
+  app.post('/_api/bookmarks.add'      , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.add);
+  app.post('/_api/bookmarks.remove'   , accessTokenParser(crowi, app) , loginRequired(crowi, app) , bookmark.api.remove);
+  app.post('/_api/likes.add'          , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.like);
+  app.post('/_api/likes.remove'       , accessTokenParser(crowi, app) , loginRequired(crowi, app) , page.api.unlike);
+
   //app.get('/_api/revision/:id'     , user.useUserData()         , revision.api.get);
   //app.get('/_api/r/:revisionId'    , user.useUserData()         , page.api.get);
 
-  app.post('/*/edit'                 , form.revision              , loginRequired(crowi, app) , page.pageEdit);
+  app.post('/_/edit'                 , form.revision             , loginRequired(crowi, app) , page.pageEdit);
   app.get('/*/$'                     , loginRequired(crowi, app) , page.pageListShow);
   app.get('/*'                       , loginRequired(crowi, app) , page.pageShow);
   //app.get('/*/edit'                , routes.edit);

+ 306 - 143
lib/routes/page.js

@@ -11,14 +11,25 @@ module.exports = function(crowi, app) {
 
   function getPathFromRequest(req) {
     var path = '/' + (req.params[0] || '');
-    return path;
+    return path.replace(/\.md$/, '');
+  }
+
+  function isUserPage(path) {
+    if (path.match(/^\/user\/[^\/]+\/?$/)) {
+      return true;
+    }
+
+    return false;
   }
 
   // TODO: total とかでちゃんと計算する
   function generatePager(options) {
-    var next = null, prev = null,
-        offset = parseInt(options.offset, 10),
-        limit  = parseInt(options.limit, 10);
+    var next = null,
+      prev = null,
+      offset = parseInt(options.offset, 10),
+      limit  = parseInt(options.limit, 10),
+      length = options.length || 0;
+
 
     if (offset > 0) {
       prev = offset - limit;
@@ -27,35 +38,58 @@ module.exports = function(crowi, app) {
       }
     }
 
-    next = offset + limit;
+    if (length < limit) {
+      next = null;
+    } else {
+      next = offset + limit;
+    }
 
     return {
       prev: prev,
       next: next,
       offset: offset,
-      limit: limit
     };
   }
 
   // routing
   actions.pageListShow = function(req, res) {
     var path = getPathFromRequest(req);
-    var options = {};
+    var limit = 50;
+    var offset = parseInt(req.query.offset)  || 0;
+    path = path + (path == '/' ? '' : '/');
 
     // index page
-    options = {
-      offset: req.query.offset || 0,
-      limit : req.query.limit  || 50
+    var pagerOptions = {
+      offset: offset,
+      limit : limit
     };
-    var q = Page.findListByStartWith(path, req.user, options, function(err, doc) {
-      if (err) {
-        // TODO : check
+    var queryOptions = {
+      offset: offset,
+      limit : limit + 1
+    };
+
+    var renderVars = {
+      page: null,
+      path: path,
+      pages: [],
+    };
+
+    Page.hasPortalPage(path, req.user)
+    .then(function(portalPage) {
+      renderVars.page = portalPage;
+
+      return Page.findListByStartWith(path.substr(0, path.length -1), req.user, queryOptions);
+    }).then(function(pageList) {
+
+      if (pageList.length > limit) {
+        pageList.pop();
       }
-      res.render('page_list', {
-        path: path + (path == '/' ? '' : '/'),
-        pages: doc,
-        pager: generatePager(options)
-      });
+
+      pagerOptions.length = pageList.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.pages = pageList;
+      res.render('page_list', renderVars);
     });
   };
 
@@ -63,7 +97,6 @@ module.exports = function(crowi, app) {
     // create page
     if (!pageData) {
       return res.render('page', {
-        revision: {},
         author: {},
         page: false,
       });
@@ -73,15 +106,58 @@ module.exports = function(crowi, app) {
       return res.redirect(encodeURI(pageData.redirectTo + '?renamed=' + pageData.path));
     }
 
-    Revision.findRevisionList(pageData.path, {}, function(err, tree) {
-      var revision = pageData.revision || {};
-      res.render(req.query.presentation ? 'page_presentation' : 'page', {
-        path: pageData.path,
-        revision: revision,
-        author: revision.author || false,
-        page: pageData,
-        tree: tree || [],
-      });
+    var renderVars = {
+      path: pageData.path,
+      page: pageData,
+      revision: pageData.revision || {},
+      author: pageData.revision.author || false,
+    };
+    var userPage = isUserPage(pageData.path);
+    var userData = null;
+
+    Revision.findRevisionList(pageData.path, {})
+    .then(function(tree) {
+      renderVars.tree = tree;
+
+      return Promise.resolve();
+    }).then(function() {
+      if (userPage) {
+        return User.findUserByUsername(User.getUsernameByPath(pageData.path))
+        .then(function(data) {
+          if (data === null) {
+            throw new Error('The user not found.');
+          }
+          userData = data;
+          renderVars.pageUser = userData;
+
+          return Bookmark.findByUser(userData, {limit: 10, populatePage: true});
+        }).then(function(bookmarkList) {
+          debug(bookmarkList);
+          renderVars.bookmarkList = bookmarkList;
+
+          return Page.findListByCreator(userData, {limit: 10});
+        }).then(function(createdList) {
+          renderVars.createdList = createdList;
+          return Promise.resolve();
+        }).catch(function(err) {
+          debug('Error on finding user related entities', err);
+          // pass
+        });
+      } else {
+        return Promise.resolve();
+      }
+    }).then(function() {
+      var defaultPageTeamplate = 'page';
+      if (userData) {
+        defaultPageTeamplate = 'user_page';
+      }
+
+      res.render(req.query.presentation ? 'page_presentation' : defaultPageTeamplate, renderVars);
+    }).catch(function(err) {
+      debug('Error: renderPage()', err);
+      if (err) {
+        res.redirect('/');
+      }
     });
   }
 
@@ -89,6 +165,9 @@ module.exports = function(crowi, app) {
     var path = path || getPathFromRequest(req);
     var options = {};
 
+    // FIXME: せっかく getPathFromRequest になってるのにここが生 params[0] だとダサイ
+    var isMarkdown = req.params[0].match(/.+\.md$/) || false;
+
     res.locals.path = path;
 
     // pageShow は /* にマッチしてる最後の砦なので、creatableName でない routing は
@@ -99,80 +178,158 @@ module.exports = function(crowi, app) {
       return ;
     }
 
-    // single page
-    var parentPath = path.split('/').slice(0, -1).join('/'); // TODO : limitation
-    options = {
-    };
+    Page.findPage(path, req.user, req.query.revision)
+    .then(function(page) {
+      debug('Page found', page._id, page.path);
 
-    Page.findPage(path, req.user, req.query.revision, options, function(err, pageData) {
-      if (req.query.revision && err) {
-        res.redirect(encodeURI(path));
-        return ;
+      if (isMarkdown) {
+        res.set('Content-Type', 'text/plain');
+        return res.send(page.revision.body);
       }
 
-      if (err == Page.PAGE_GRANT_ERROR) {
-        debug('PAGE_GRANT_ERROR');
-        return res.redirect('/');
+      return renderPage(page, req, res);
+    }).catch(function(err) {
+      if (req.query.revision) {
+        return res.redirect(encodeURI(path));
       }
 
-      if (pageData) {
-        debug('Page found', pageData._id, pageData.path);
-        pageData.seen(req.user, function(err, data) {
-          return renderPage(data, req, res);
-        });
-      } else {
-          return renderPage(null, req, res);
+      if (isMarkdown) {
+        return res.redirect('/');
       }
+
+      debug('Catch pageShow', err);
+      return renderPage(null, req, res);
     });
   };
 
   actions.pageEdit = function(req, res) {
-    var io = module.parent.exports.io;
-    var path = getPathFromRequest(req);
 
     var pageForm = req.body.pageForm;
     var body = pageForm.body;
-    var format = pageForm.format;
     var currentRevision = pageForm.currentRevision;
     var grant = pageForm.grant;
+    var path = pageForm.path;
+
+    var redirectPath = encodeURI(path);
+
+    // set to render
+    res.locals.pageForm = pageForm;
 
     if (!Page.isCreatableName(path)) {
-      res.redirect(encodeURI(path));
+      res.redirect(redirectPath);
       return ;
     }
 
-    Page.findPage(path, req.user, null, {}, function(err, pageData){
+    var ignoreNotFound = true;
+    Page.findPage(path, req.user, null, ignoreNotFound)
+    .then(function(pageData) {
       if (!req.form.isValid) {
         renderPage(pageData, req, res);
-        return;
+        return Promise.reject(new Error('form error'));
       }
+
       if (pageData && !pageData.isUpdatable(currentRevision)) {
         req.form.errors.push('すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。');
         renderPage(pageData, req, res);
-        return;
+        return Promise.reject(new Error('form error'));
       }
 
-      var cb = function(err, data) {
-        if (err) {
-          console.log('Page save error:', err);
-        }
-        crowi.getIo().sockets.emit('page edited', {page: data, user: req.user});
-
-        var redirectPath = encodeURI(path);
-        if (grant != data.grant) {
-          Page.updateGrant(data, grant, req.user, function (err, data) {
-            return res.redirect(redirectPath);
-          });
-        } else {
-          return res.redirect(redirectPath);
-        }
-      };
       if (pageData) {
+        // update existing page
         var newRevision = Revision.prepareRevision(pageData, body, req.user);
-        Page.pushRevision(pageData, newRevision, req.user, cb);
+        return Page.pushRevision(pageData, newRevision, req.user);
+      } else {
+        // new page
+        return Page.create(path, body, req.user, {grant: grant});
+      }
+    }).then(function(data) {
+      crowi.getIo().sockets.emit('page edited', {page: data, user: req.user});
+
+      if (grant != data.grant) {
+        return Page.updateGrant(data, grant, req.user).then(function(data) {
+          return res.redirect(redirectPath);
+        });
       } else {
-        Page.create(path, body, req.user, {format: format, grant: grant}, cb);
+        return res.redirect(redirectPath);
+      }
+    }).catch(function(err) {
+      debug('Create or edit page error', err);
+      return res.redirect(redirectPath);
+    });
+  };
+
+  // app.get( '/users/:username([^/]+)/bookmarks'      , loginRequired(crowi, app) , page.userBookmarkList);
+  actions.userBookmarkList = function(req, res) {
+    var username = req.params.username;
+    var limit = 50;
+    var offset = parseInt(req.query.offset)  || 0;
+
+    var user;
+    var renderVars = {};
+
+    var pagerOptions = { offset: offset, limit : limit };
+    var queryOptions = { offset: offset, limit : limit + 1, populatePage: true};
+
+    User.findUserByUsername(username)
+    .then(function(user) {
+      if (user === null) {
+        throw new Error('The user not found.');
+      }
+      renderVars.user = user;
+
+      return Bookmark.findByUser(user, queryOptions);
+    }).then(function(bookmarks) {
+
+      if (bookmarks.length > limit) {
+        bookmarks.pop();
       }
+      pagerOptions.length = bookmarks.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.bookmarks = bookmarks;
+
+      return res.render('user/bookmarks', renderVars);
+    }).catch(function(err) {
+      debug('Error on rendereing bookmark', err);
+      res.redirect('/');
+    });
+  };
+
+  // app.get( '/users/:username([^/]+)/recent-create' , loginRequired(crowi, app) , page.userRecentCreatedList);
+  actions.userRecentCreatedList = function(req, res) {
+    var username = req.params.username;
+    var limit = 50;
+    var offset = parseInt(req.query.offset) || 0;
+
+    var user;
+    var renderVars = {};
+
+    var pagerOptions = { offset: offset, limit : limit };
+    var queryOptions = { offset: offset, limit : limit + 1};
+
+
+    User.findUserByUsername(username)
+    .then(function(user) {
+      if (user === null) {
+        throw new Error('The user not found.');
+      }
+      renderVars.user = user;
+
+      return Page.findListByCreator(user, queryOptions);
+    }).then(function(pages) {
+
+      if (pages.length > limit) {
+        pages.pop();
+      }
+      pagerOptions.length = pages.length;
+
+      renderVars.pager = generatePager(pagerOptions);
+      renderVars.pages = pages;
+
+      return res.render('user/recent-create', renderVars);
+    }).catch(function(err) {
+      debug('Error on rendereing recent-created', err);
+      res.redirect('/');
     });
   };
 
@@ -184,108 +341,114 @@ module.exports = function(crowi, app) {
   api.redirector = function(req, res){
     var id = req.params.id;
 
-    var cb = function(err, d) {
-      if (err) {
-        return res.redirect('/');
+    Page.findPageById(id)
+    .then(function(pageData) {
+      if (pageData.grant == Page.GRANT_RESTRICTED && !pageData.isGrantedFor(req.user)) {
+        return Page.pushToGrantedUsers(pageData, req.user);
       }
-      return res.redirect(encodeURI(d.path));
-    };
 
-    Page.findPageById(id, function(err, pageData) {
-      if (pageData) {
-        if (pageData.grant == Page.GRANT_RESTRICTED && !pageData.isGrantedFor(req.user)) {
-          return Page.pushToGrantedUsers(pageData, req.user, cb);
-        } else {
-          return cb(null, pageData);
-        }
-      } else {
-        // 共有用URLにrevisionのidを使っていた頃の互換性のため
-        Revision.findRevision(id, cb);
-      }
+      return Promise.resolve(pageData);
+    }).then(function(page) {
+
+      return res.redirect(encodeURI(page.path));
+    }).catch(function(err) {
+      return res.redirect('/');
     });
   };
 
   /**
-   * @api pages.get
-   * @param page /page/path
-   * @param page_id XXXXX
+   * @api {get} /pages.get Get page data
+   * @apiName GetPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id
+   * @apiParam {String} path
+   * @apiParam {String} revision_id
    */
   api.get = function(req, res){
-    var pagePath = req.query.page;
-    var revision = req.query.revision;
-    var options = {};
+    var pagePath = req.query.path || null;
+    var pageId = req.query.page_id || null; // TODO: handling
+    var revisionId = req.query.revision_id || null;
 
-    Page.findPage(pagePath, req.user, revision, options, function(err, pageData) {
+    Page.findPage(pagePath, req.user, revisionId)
+    .then(function(pageData) {
       var result = {};
-      if (err) {
-        result = ApiResponse.error(err);
-      }
-      if (pageData) {
-        result = ApiResponse.success(pageData);
-      }
+      result.page = pageData;
 
-      return res.json(result);
+      return res.json(ApiResponse.success(pageData));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
     });
   };
 
   /**
-   * page bookmark
+   * @api {post} /pages.seen Mark as seen user
+   * @apiName SeenPage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
    */
-  api.isBookmarked = function(req, res){
-    var id = req.params.id;
-    Bookmark.findByPageIdAndUser(id, req.user, function(err, bookmark) {
-      debug('isBookmarked', id, req.user._id, err, bookmark);
-      if (err === null && bookmark) {
-        return res.json({bookmarked: true});
-      } else {
-        return res.json({bookmarked: false});
-      }
-    });
-  };
+  api.seen = function(req, res){
+    var pageId = req.body.page_id;
+    if (!pageId) {
+      return res.json(ApiResponse.error('page_id required'));
+    }
 
-  api.bookmark = function(req, res){
-    var id = req.params.id;
-    Page.findPageByIdAndGrantedUser(id, req.user, function(err, pageData) {
-      if (pageData) {
-        Bookmark.add(pageData, req.user, function(err, data) {
-          return res.json({status: true});
-        });
-      } else {
-        return res.json({status: false});
-      }
+    Page.findPageByIdAndGrantedUser(pageId, req.user)
+    .then(function(page) {
+      return page.seen(req.user);
+    }).then(function(user) {
+      var result = {};
+      result.seenUser = user;
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Seen user update error', err);
+      return res.json(ApiResponse.error(err));
     });
   };
 
   /**
-   * page like
+   * @api {post} /likes.add Like page
+   * @apiName LikePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
    */
   api.like = function(req, res){
-    var id = req.params.id;
-    Page.findPageByIdAndGrantedUser(id, req.user, function(err, pageData) {
-      if (pageData) {
-        pageData.like(req.user, function(err, data) {
-          return res.json({status: true});
-        });
-      } else {
-        return res.json({status: false});
-      }
+    var id = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(id, req.user)
+    .then(function(pageData) {
+      return pageData.like(req.user);
+    }).then(function(data) {
+      var result = {page: data};
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Like failed', err);
+      return res.json(ApiResponse.error({}));
     });
   };
 
   /**
-   * page like
+   * @api {post} /likes.remove Unlike page
+   * @apiName UnlikePage
+   * @apiGroup Page
+   *
+   * @apiParam {String} page_id Page Id.
    */
   api.unlike = function(req, res){
-    var id = req.params.id;
-
-    Page.findPageByIdAndGrantedUser(id, req.user, function(err, pageData) {
-      if (pageData) {
-        pageData.unlike(req.user, function(err, data) {
-          return res.json({status: true});
-        });
-      } else {
-        return res.json({status: false});
-      }
+    var id = req.body.page_id;
+
+    Page.findPageByIdAndGrantedUser(id, req.user)
+    .then(function(pageData) {
+      return pageData.unlike(req.user);
+    }).then(function(data) {
+      var result = {page: data};
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      debug('Unlike failed', err);
+      return res.json(ApiResponse.error({}));
     });
   };
 

+ 32 - 7
lib/routes/user.js

@@ -5,6 +5,7 @@ module.exports = function(crowi, app) {
     , User = crowi.model('User')
     , Revision = crowi.model('Revision')
     , Bookmark = crowi.model('Bookmark')
+    , ApiResponse = require('../util/apiResponse')
     , actions = {}
     , api = {};
 
@@ -23,16 +24,40 @@ module.exports = function(crowi, app) {
   api.checkUsername = function(req, res) {
     var username = req.query.username;
 
-    User.findUserByUsername(username, function(err, userData) {
+    User.findUserByUsername(username)
+    .then(function(userData) {
       if (userData) {
-        return res.json({
-          valid: false
-        });
+        return res.json({ valid: false });
       } else {
-        return res.json({
-          valid: true
-        });
+        return res.json({ valid: true });
       }
+    }).catch(function(err) {
+      return res.json({ valid: true });
+    });
+  };
+
+  /**
+   * @api {get} /users.list Get user list
+   * @apiName GetUserList
+   * @apiGroup User
+   *
+   * @apiParam {String} user_ids
+   */
+  api.list = function(req, res) {
+    var userIds = req.query.user_ids || null; // TODO: handling
+    if (!userIds || userIds.split(',').length <= 0) {
+      return res.json(ApiResponse.error('user_ids param is required'));
+    }
+
+    User.findUsersByIds(userIds.split(','))
+    .then(function(userList) {
+      var result = {
+        users: userList,
+      };
+
+      return res.json(ApiResponse.success(result));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
     });
   };
 

+ 2 - 2
lib/util/apiResponse.js

@@ -10,7 +10,7 @@ ApiResponse.error = function (err) {
     ok: false
   };
 
-  if (typeof err == Error) {
+  if (err instanceof Error) {
     result.error = err.toString();
   } else {
     result.error = err;
@@ -20,7 +20,7 @@ ApiResponse.error = function (err) {
 };
 
 ApiResponse.success = function (data) {
-  var result = data;
+  var result = data || {};
 
   result.ok = true;
   return result;

+ 18 - 2
lib/util/middlewares.js

@@ -32,9 +32,20 @@ exports.swigFunctions = function(crowi, app) {
 
 exports.swigFilters = function(app, swig) {
   return function(req, res, next) {
-
     swig.setFilter('path2name', function(string) {
-      return string.replace(/.+\/(.+)?$/, '$1');
+      var name = string.replace(/(\/)$/, '');
+
+      if (name.match(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/)) { // /.../hoge/YYYY/MM/DD 形式のページ
+        return name.replace(/.+\/([^/]+\/\d{4}\/\d{2}\/\d{2})$/, '$1');
+      }
+      if (name.match(/.+\/([^/]+\/\d{4}\/\d{2})$/)) { // /.../hoge/YYYY/MM 形式のページ
+        return name.replace(/.+\/([^/]+\/\d{4}\/\d{2})$/, '$1');
+      }
+      if (name.match(/.+\/([^/]+\/\d{4})$/)) { // /.../hoge/YYYY 形式のページ
+        return name.replace(/.+\/([^/]+\/\d{4})$/, '$1');
+      }
+
+      return name.replace(/.+\/(.+)?$/, '$1'); // ページの末尾を拾う
     });
 
     swig.setFilter('datetz', function(input, format) {
@@ -43,6 +54,11 @@ exports.swigFilters = function(app, swig) {
       return swigFilters.date(input, format, app.get('tzoffset'));
     });
 
+    swig.setFilter('nl2br', function(string) {
+      return string
+        .replace(/\n/g, '<br>');
+    });
+
     swig.setFilter('presentation', function(string) {
       // 手抜き
       return string

+ 8 - 4
lib/views/_form.html

@@ -9,11 +9,11 @@
 </div>
 {% endif %}
 <div id="form-box" class="row">
-  <form action="{{ path }}/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %}">
-    <textarea name="pageForm[body]" class="form-control form-body-height" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not revision.body %}# {{ path|path2name }}{% else %}{{ revision.body }}{% endif %}</textarea>
+  <form action="/_/edit" id="page-form" method="post" class="col-md-6 {% if isUploadable() %}uploadable{% endif %}">
+    <textarea name="pageForm[body]" class="form-control" id="form-body">{% if pageForm.body %}{{ pageForm.body }}{% elseif not page.revision.body %}# {{ path|path2name }}{% else %}{{ page.revision.body }}{% endif %}</textarea>
 
-    <input type="hidden" name="pageForm[format]" value="markdown" id="form-format">
-    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(revision._id.toString()) }}">
+    <input type="hidden" name="pageForm[path]" value="{{ path }}">
+    <input type="hidden" name="pageForm[currentRevision]" value="{{ pageForm.currentRevision|default(page.revision._id.toString()) }}">
     <div class="form-submit-group form-group form-inline">
       {#<button class="btn btn-default">
         <i class="fa fa-file-text"></i>
@@ -21,11 +21,15 @@
       </button>#}
 
       <div class="pull-right form-inline">
+        {% if forceGrant %}
+        <input type="hidden" name="pageForm[grant]" value="{{ forceGrant }}">
+        {% else %}
         <select name="pageForm[grant]" class="form-control">
           {% for grantId, grantLabel in consts.pageGrants %}
           <option value="{{ grantId }}" {% if pageForm.grant|default(page.grant) == grantId %}selected{% endif %}>{{ grantLabel }}</option>
           {% endfor %}
         </select>
+        {% endif %}
         <input type="submit" class="btn btn-primary" id="edit-form-submit" value="ページを更新" />
       </div>
     </div>

+ 6 - 11
lib/views/layout/2column.html

@@ -4,7 +4,10 @@
 <nav class="crowi-header navbar navbar-default" role="navigation">
   <!-- Brand and toggle get grouped for better mobile display -->
   <div class="navbar-header">
-    <a class="navbar-brand" href="/">{% block title %}{{ config.crowi['app:title'] }}{% endblock %}</a>
+    <a class="navbar-brand" href="/">
+      <img alt="Crowi" src="/logo/32x32.png" width="16">
+      {% block title %}{{ config.crowi['app:title'] }}{% endblock %}
+    </a>
   </div>
 
   <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#navbarCollapse">
@@ -124,7 +127,7 @@
   <div id="footer-container" class="footer">
     <footer class="">
     <p>
-    <a href="" data-target="#helpModal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
+    <a href="" data-target="#help-modal" data-toggle="modal"><i class="fa fa-question-circle"> ヘルプ</i></a>
     &copy; {{ now|date('Y') }} {{ config.crowi['app:title'] }} <img src="/logo/100x11_g.png" alt="powered by Crowi"> </p>
     </footer>
   </div>
@@ -141,7 +144,7 @@
 {% endblock %} {# layout_sidebar #}
 
 {% block layout_main %}
-<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %}">
+<div id="main" class="main col-md-9 {% if page %}{{ css.grant(page) }}{% endif %} {% block main_css_class %}{% endblock %}">
   {% if page && page.grant != 1 %}
   <p class="page-grant">
     <i class="fa fa-lock"></i> {{ consts.pageGrants[page.grant] }} (このページの閲覧は制限されています)
@@ -149,10 +152,6 @@
   {% endif %}
   <article>
   {% block content_head %}
-    <header>
-    <h2>-</h2>
-    <p>-</p>
-    </header>
   {% endblock %}
 
   {% block content_main %}
@@ -160,10 +159,6 @@
   {% endblock content_main %}
 
   {% block content_footer %}
-    <footer>
-    <h3>-</h3>
-    <p>-</p>
-    </footer>
   {% endblock %}
   </article>
 </div>

+ 1 - 1
lib/views/modal/widget_help.html

@@ -1,4 +1,4 @@
-<div class="modal fade" id="helpModal">
+<div class="modal fade" id="help-modal">
   <div class="modal-dialog">
     <div class="modal-content">
 

+ 4 - 1
lib/views/modal/widget_rename.html

@@ -19,7 +19,10 @@
             </div>
             <div class="form-group">
               <label for="newPageName">移動先のページ名</label><br>
-              <input type="text" class="form-control" name="newPageName" id="newPageName" value="{{ page.path }}">
+              <div class="input-group">
+                <span class="input-group-addon">{{ config.crowi['app:url'] }}</span>
+                <input type="text" class="form-control" name="newPageName" id="newPageName" value="{{ page.path }}">
+              </div>
             </div>
             <div class="checkbox">
                <label>

+ 57 - 0
lib/views/modal/widget_what_is_portal.html

@@ -0,0 +1,57 @@
+<div class="modal fade" id="help-portal">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <h4 class="modal-title">What is Portal?</h4>
+      </div>
+
+      <div class="modal-body">
+        <h4>Portal とは</h4>
+        <br>
+        <ul>
+          <li>すべての、スラッシュ <code>/</code> で終わるページは、その階層の一覧ページとなります。</li>
+          <li>Portal 機能を使うと、その一覧ページに対して、任意の編集コンテンツを配置することができるようになります (つまり、一般的なページと同様に、編集したコンテンツを作成でき、その内容は常にページ一覧の上部に表示されるようになります)</li>
+        </ul>
+        <br>
+
+        <hr>
+
+        <h4>想定される使われ方</h4>
+        <br>
+        <p>
+        例えば、以下のようなページの階層があったとします。
+        </p>
+        <ul>
+          <li><code>/projects</code>
+            <ul>
+              <li><code>/projects/homepage-renewal</code>
+                <ul>
+                  <li><code>/projects/homepage-renewal/...</code></li>
+                </ul>
+              </li>
+              <li><code>/projects/...</code></li>
+            </ul>
+          </li>
+        </ul>
+
+        <p>
+        こういったケースでは、<code>/projects/homepage-renewal</code> には homepage-renewal プロジェクトについてのイントロや各ページへのリンク、関係者の紹介など、homepage-renewal に関する情報を掲載しておきたいと思うはずです。
+        </p>
+        <p>
+        Poral機能を使うと、こうしたときに、<code>/projects/homepage-renewal/</code> この <strong>"一覧ページ" を、ページ化することができ、そこに、通常のページと同じように Markdown で編集したコンテンツを配置することができるようになります</strong>。
+        </p>
+
+        <p>
+        まさにそのプロジェクトのポータルページを用意したい場合などに活用してください。
+        </p>
+
+        </div>
+
+      </div>
+
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->
+

+ 27 - 154
lib/views/page.html

@@ -3,26 +3,43 @@
 {% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
 
 {% block content_head %}
+
+{% block content_head_before %}
+{% endblock %}
+
 <div class="header-wrap">
   <header id="page-header">
     <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
 
+
+    {% if page %}
+      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+    {% endif %}
     <h1 class="title" id="revision-path">{{ path }}</h1>
   </header>
 </div>
+
+{% block content_head_after %}
+{% endblock %}
+
 {% endblock %}
 
 {% block content_main %}
-<div id="content-main" class="content-main {% if not page %}on-edit{% endif %}"
+
+{% block content_main_before %}
+{% endblock %}
+
+<div id="content-main" class="content-main {% if not page or req.body.pageForm %}on-edit{% endif %}"
   data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
   data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
   data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
   data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
   >
 
   {% if not page %}
   <ul class="nav nav-tabs hidden-print">
-    <li><a>ページ作成: {{ path|path2name }}</a></li>
+    <li><a>Create: {{ path }}</a></li>
     <li class="dropdown pull-right">
       <a href="/"><i class="fa fa-times"></i> キャンセル</a>
     </li>
@@ -97,11 +114,6 @@
       <div class="wiki {{ revision.format }}" id="revision-body-content"></div>
     </div>
 
-    {# raw text #}
-    <div class="tab-pane" id="raw-text">
-      <pre id="">{{ revision.body }}</pre>
-    </div>
-
     {# edit form #}
     <div class="edit-form tab-pane {% if req.body.pageForm %}active{% endif %}" id="edit-form">
       {% include '_form.html' %}
@@ -143,13 +155,6 @@
         Crowi.correctHeaders('#revision-body-content');
         Crowi.revisionToc('#revision-body-content', '#revision-toc');
 
-        $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
-          $('.content-main').addClass('on-edit');
-        });
-        $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
-          $('.content-main').removeClass('on-edit');
-        });
-
         $('#edit-form').submit(function()
         {
           //console.log('save');
@@ -177,6 +182,9 @@
   {% endif %}
 </div>
 
+{% block content_main_after %}
+{% endblock %}
+
 {% endblock %}
 
 {% block content_footer %}
@@ -189,154 +197,19 @@
 </div>
 
 <p class="meta">
-  Path: <span id="pagePath">{{ page.path }}</span><br />
-  Last updated at {{ page.updatedAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(author)|picture }}" class="picture picture-rounded"> {{ page.creator.name|default(author.name) }}
+  Path: <span id="pagePath">{{ page.path }}</span><br>
+  Last updated at {{ page.revision.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.revision.author|picture }}" class="picture picture-rounded"> {{ page.revision.author.name }}<br>
+  Created at {{ page.createdAt|datetz('Y-m-d H:i:s') }} by <img src="{{ page.creator|default(page.creator)|picture }}" class="picture picture-rounded"> {{ page.creator.name }}<br>
 </p>
 
 {% endblock %}
 
 {% block side_header %}
-
-{% if page %} {# {{{ if page #}
-<div class="page-meta">
-  <div class="row">
-    {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
-    <div class="col-md-3 creator-picture">
-      <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg picture-rounded"><br>
-    </div>
-    <div class="col-md-9">
-      <p class="creator">
-        {{ page.creator.name|default(author.name) }}
-      </p>
-      <p class="created-at">
-        作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
-        最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ author.username }}"><img src="{{ author|picture }}" class="picture picture-xs picture-rounded" alt="{{ author.name }}"></a>
-      </p>
-    </div>
-  </div>
-
-  <div class="like-box">
-    <dl class="dl-horizontal">
-      <dt>
-        <i class="fa fa-star"></i> お気に入り
-      </dt>
-      <dd>
-        <button class="btn btn-default btn-sm btn-bookmark" id="bookmarkButton"><i class="fa fa-star-o"></i></button>
-      </dd>
-
-      <dt>
-        <i class="fa fa-thumbs-o-up"></i> いいね!
-      </dt>
-      <dd>
-        <p class="liker-count">
-        {{ page.liker.length }}
-          {% if page.isLiked(user) %}
-            <button data-liked="1" class="btn btn-default btn-sm active" id="pageLikeButton"><i class="fa fa-thumbs-up"></i> いいね!</button>
-          {% else %}
-            <button data-liked="0" class="btn btn-default btn-sm" id="pageLikeButton"><i class="fa fa-thumbs-o-up"></i> いいね!</button>
-          {% endif %}
-        </p>
-        <p class="liker-list">
-          {% for liker in page.liker %}
-            <a href="{{ user_page_root(liker) }}" title="{{ liker.name }}"><img alt="{{ liker.name }}" src="{{ liker|picture }}" class="picture picture-xs picture-rounded"></a>
-          {% endfor %}
-          {% if page.liker.length > 10 %}
-            (...)
-          {% endif %}
-        </p>
-      </dd>
-
-      <dt><i class="fa fa-eye"></i> 見た人</dt>
-      <dd>
-        <p class="seen-user-count">
-          {{ page.seenUsers.length }}
-        </p>
-        <p class="seen-user-list">
-          {% for seenUser in page.seenUsers %}
-          <a href="{{ user_page_root(seenUser) }}" title="{{ seenUser.name }}"><img alt="{{ seenUser.name }}" src="{{ seenUser|picture }}" class="picture picture-xs picture-rounded"></a>
-          {% endfor %}
-          {% if page.seenUsers.length > 10 %}
-            (...)
-          {% endif %}
-        </p>
-      </dd>
-    </dl>
-  </div>
-<script>
-$(function() {
-  $.get('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
-    if (data.bookmarked) {
-      $('#bookmarkButton')
-        .removeClass('btn-default')
-        .addClass('btn-warning active bookmarked');
-      $('#bookmarkButton i')
-        .removeClass('fa-star-o')
-        .addClass('fa-star');
-    }
-  });
-
-  $('#bookmarkButton').click(function() {
-    var pageId = {{page._id|json|safe}};
-    $.post('/_api/page/{{ page._id.toString() }}/bookmark', function(data) {
-    });
-  });
-  $('#pageLikeButton').click(function() {
-    var pageId = {{page._id|json|safe}};
-    $.post('/_api/page/{{ page._id.toString() }}/like', function(data) {
-    });
-  });
-});
-</script>
-</div>
-{% endif %} {# if page }}} #}
+  {% include 'widget/page_side_header.html' %}
 {% endblock %} {# side_header #}
 
 {% block side_content %}
-
-  <h3><i class="fa fa-link"></i> Share</h3>
-  <ul class="fitted-list">
-    <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
-      <span class="input-group-addon">共有用</span>
-      <input class="copy-link form-control" type="text" value="{{ config.crowi['app:title'] }} {{ path }}  {{ baseUrl }}/_r/{{ page._id.toString() }}">
-    </li>
-    <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
-      <span class="input-group-addon">Markdown</span>
-      <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/_r/{{ revision._id.toString() }})">
-    </li>
-  </ul>
-
-  <h3><i class="fa fa-comment"></i> Comments</h3>
-  <div class="page-comments">
-    <form class="form page-comment-form" id="page-comment-form">
-      <div class="comment-form">
-        <div class="comment-form-main">
-          <div class="comment-write" id="comment-write">
-            <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"></textarea>
-          </div>
-          <div class="comment-submit">
-            <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
-            <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
-            <span class="text-danger" id="comment-form-message"></span>
-            <input type="submit" id="commenf-form-button" value="Comment" class="btn btn-primary btn-sm form-inline">
-          </div>
-        </div>
-      </div>
-    </form>
-
-    <div class="page-comments-list" id="page-comments-list">
-      <div class="page-comments-list-newer collapse" id="page-comments-list-newer"></div>
-
-      <a class="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer"><i class="fa fa-angle-double-up"></i> Comments for Newer Revision <i class="fa fa-angle-double-up"></i></a>
-
-      <div class="page-comments-list-current" id="page-comments-list-current"></div>
-
-      <a class="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older"><i class="fa fa-angle-double-down"></i> Comments for Older Revision <i class="fa fa-angle-double-down"></i></a>
-
-      <div class="page-comments-list-older collapse in" id="page-comments-list-older"></div>
-    </div>
-  </div>
-
-
+  {% include 'widget/page_side_content.html' %}
 {% endblock %}
 
 {% block footer %}

+ 119 - 30
lib/views/page_list.html

@@ -1,48 +1,116 @@
 {% extends 'layout/2column.html' %}
 
+{% block html_title %}{{ path|path2name }} · {{ path }}{% endblock %}
+
 {% block content_head %}
-  <header>
-  <h1 class="title" id="revision-path">{{ path }}</h1>
+
+{% block content_head_before %}
+{% endblock %}
+
+<div class="header-wrap">
+  <header class="portal-header {% if page %}has-page{% endif %}">
+    {% if page %}
+      <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+
+    {% endif %}
+    <h1 class="title" id="revision-path">
+      {{ path }}
+    </h1>
   </header>
+</div>
+
+{% endblock %}
+
+{% block content_head_after %}
 {% endblock %}
 
 {% block content_main %}
-<div class="content-main">
 
-<ul class="nav nav-tabs">
-    <li class="active"><a href="#view-list" data-toggle="tab">リスト表示</a></li>
-    <li><a href="#view-timeline" data-toggle="tab">タイムライン表示</a></li>
-</ul>
+{% block content_main_before %}
+{% endblock %}
+
+<div class="page-list content-main {% if req.body.pageForm %}on-edit{% endif %}"
+  id="content-main"
+  data-page-portal="{% if page and page.isPortal() %}1{% else %}0{% endif %}"
+  data-page-id="{% if page %}{{ page._id.toString() }}{% endif %}"
+  data-current-user="{% if user %}{{ user._id.toString() }}{% endif %}"
+  data-page-revision-id="{% if revision %}{{ revision._id.toString() }}{% endif %}"
+  data-page-revision-created="{% if revision %}{{ revision.createdAt|datetz('U') }}{% endif %}"
+  data-page-is-seen="{% if page and page.isSeenUser(user) %}1{% else %}0{% endif %}"
+  >
+
+<div class="portal {% if not page %}hide{% endif %}">
+
+  <ul class="nav nav-tabs hidden-print">
+   {# portal tab #}
+    <li class=" {% if not req.body.pageForm %}active{% endif %}">
+      {% if page %}
+      <a href="#revision-body-content" data-toggle="tab">
+        <i class="fa fa-magic"></i>
+        PORTAL
+      </a>
+      {% else %}
+      <a>Create Portal: {{ path }}</a>
+      {% endif %}
+    </li>
+    <li {% if req.body.pageForm %}class="active"{% endif %}><a href="#edit-form" data-toggle="tab"><i class="fa fa-pencil-square-o"></i> 編集</a></li>
+
+    {% if not page %}
+    <li class="pull-right close-button">
+      <a href="#" id="portal-form-close">
+        <i class="fa fa-times"></i>
+      </a>
+    </li>
+    {% endif %}
+  </ul>
 
-<h2>ページ一覧</h2>
   <div class="tab-content">
-    {# list view #}
-    <div class="active page-list wiki tab-pane fade in" id="view-list">
-      {% for page in pages %}
-        <a class="page-list-link" href="{{ page.path }}">{{ page.path }}</a>
+    <div class="wiki tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body-content">
+    </div>
 
-        <span class="page-list-meta">
-          {% if page.commentCount > 0 %}
-            <i class="fa fa-comment"></i>{{ page.commentCount }}
-          {% endif  %}
+    <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
+    <script type="text/javascript">
+      $(function(){
+          var renderer = new Crowi.renderer($('#raw-text-original').html());
+          renderer.render();
+          Crowi.correctHeaders('#revision-body-content');
+      });
+    </script>
+    <div class="tab-pane edit-form portal-form {% if req.body.pageForm %}active{% endif %}" id="edit-form">
+      {% include '_form.html' with {forceGrant: 1} %}
+    </div>
+  </div>
+</div> {# /.portal #}
 
-          {% if !page.isPublic() %}
-            <i class="fa fa-lock"></i>
-          {% endif %}
-        </span>
+<div class="page-list-container">
+  <ul class="nav nav-tabs">
+      <li class="active"><a href="#view-list" data-toggle="tab">リスト表示</a></li>
+      <li><a href="#view-timeline" data-toggle="tab">タイムライン表示</a></li>
+  </ul>
 
-        <br />
-      {% endfor %}
+  <div class="tab-content">
+    {% if pages.length == 0 %}
+    There are no pages under <strong>{{ path }}</strong>.
+
+    <h3>Next Actions</h3>
 
-        <ul class="pagination">
-          {% if pager.prev != null %}
-            <li class="prev"><a href="{{ path }}?offset={{ pager.prev }}&limit={{ pager.limit }}"><i class="fa fa-arrow-left"></i> Prev</a></li>
-          {% endif %}
-          {# この条件は無いな.. #}
-          {% if pages.length > 0 %}
-            <li class="next"><a href="{{ path }}?offset={{ pager.next }}&limit={{ pager.limit }}">Next <i class="fa fa-arrow-right"></i></a></li>
-          {% endif %}
+    <ul>
+      <li>Create portal page?
+        <ul>
+          <li>Great! To create the portal of <strong>{{ path }}</strong>, click "Create Portal" button.</li>
         </ul>
+      </li>
+      <li>Create the under page directly?
+        <ul>
+          <li>Nice. To create the page under <strong>{{ path }}</strong> directly, type the page name on your browser.</li>
+        </ul>
+      </li>
+    </ul>
+    {% endif  %}
+
+    {# list view #}
+    <div class="active tab-pane fade page-list-container in" id="view-list">
+      {% include 'widget/page_list.html' with { pages: pages, pager: pager } %}
     </div>
 
     {# timeline view #}
@@ -57,6 +125,7 @@
       {% endfor %}
     </div>
   </div>
+</div>
 
   <script type="text/javascript">
     $(function(){
@@ -74,7 +143,10 @@
   </script>
 
 </div> {# /.content-main #}
+{% include 'modal/widget_what_is_portal.html' %}
 
+{% block content_main_after %}
+{% endblock %}
 
 {% endblock %}
 
@@ -85,3 +157,20 @@
 </footer>
 {% endblock %}
 
+
+{% block side_header %}
+
+{% if not page %}
+<div class="portal-side">
+  <div class="portal-form-button">
+    <button class="btn btn-primary" id="create-portal-button">Create Portal</button>
+    <p class="help-block"><a href="#" data-target="#help-portal" data-toggle="modal"><i class="fa fa-question-circle"></i> What is Portal?</a></p>
+  </div>
+
+</div>
+{% else %}
+  {% include 'widget/page_side_header.html' %}
+{% endif %}
+
+{% endblock %} {# side_header #}
+

+ 32 - 0
lib/views/user/bookmarks.html

@@ -0,0 +1,32 @@
+{% extends '../layout/2column.html' %}
+
+{% block main_css_class %}bookmark-page{% endblock %}
+
+{% block html_title %}{{ user.name }}'s Bookmarks · {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
+
+    <h1 class="title">{{ user.name }}'s Bookmarks</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div id="content-main" class="content-main page-list" >
+  <div class="page-list-container" id="bookamrk-list">
+    {% include '../widget/page_list.html' with { pages: bookmarks, pagePropertyName: 'page', pager: pager } %}
+  </div>
+</div>
+{% endblock %}
+
+{% block side_header %}
+{% endblock %} {# side_header #}
+
+{% block side_content %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 32 - 0
lib/views/user/recent-create.html

@@ -0,0 +1,32 @@
+{% extends '../layout/2column.html' %}
+
+{% block main_css_class %}recent-create-page{% endblock %}
+
+{% block html_title %}{{ user.name }}'s Recent Created Pages 揃 {% endblock %}
+
+{% block content_head %}
+<div class="header-wrap">
+  <header id="page-header">
+    <p class="stopper"><a href="#" data-affix-disable="#page-header"><i class="fa fa-chevron-up"></i></a></p>
+
+    <h1 class="title">{{ user.name }}'s Created Pages</h1>
+  </header>
+</div>
+{% endblock %}
+
+{% block content_main %}
+<div id="content-main" class="content-main page-list" >
+  <div class="page-list-container" id="bookamrk-list">
+    {% include '../widget/page_list.html' with { pages: pages, pager: pager } %}
+  </div>
+</div>
+{% endblock %}
+
+{% block side_header %}
+{% endblock %} {# side_header #}
+
+{% block side_content %}
+{% endblock %}
+
+{% block footer %}
+{% endblock %}

+ 77 - 0
lib/views/user_page.html

@@ -1,2 +1,79 @@
 {% extends 'page.html' %}
 
+{% block main_css_class %}user-page{% endblock %}
+
+{% block content_head %}
+
+{% if pageUser %}
+
+<div class="header-wrap">
+  <h1 class="title" id="revision-path">{{ path }}</h1>
+  <div class="user-page-header">
+  {% if page %}
+    <a href="#" title="Bookmark" class="bookmark-link" id="bookmark-button" data-bookmarked="0"><i class="fa fa-star-o"></i></a>
+  {% endif %}
+    <div class="pull-left user-page-picture">
+      <img src="{{ pageUser|picture }}" class="picture picture-rounded">
+    </div>
+    <div class="user-page-meta">
+      <h2>{{ pageUser.name }}</h2>
+      <ul>
+        <li class="user-page-username"><i class="fa fa-user"></i> @{{ pageUser.username }}</li>
+        <li class="user-page-email"><i class="fa fa-envelope-o"></i> {{ pageUser.email }}</li>
+        {% if pageUser.introduction %}
+        <li class="user-page-introduction"><p>{{ pageUser.introduction|nl2br }}</p></li>
+        {% endif %}
+      </ul>
+    </div>
+  </div>
+
+
+  <div class="user-page-content">
+    <ul class="nav nav-tabs user-page-content-menu">
+      <li class="active">
+        <a href="#user-bookmark-list" data-toggle="tab"><i class="fa fa-star"></i> Bookmarks</a>
+      </li>
+      <li>
+        <a href="#user-created-list" data-toggle="tab"><i class="fa fa-pencil"></i> Recent Created</a>
+      </li>
+      <li>
+        <a href="/me"><i class="fa fa-gears"></i> Setting</a>
+      </li>
+    </ul>
+    <div class="user-page-content-tab tab-content">
+
+      <div class="tab-pane user-bookmark-list page-list active" id="user-bookmark-list">
+        <div class="page-list-container">
+          {% if bookmarkList.length == 0 %}
+          No bookmarks yet.
+          {% else %}
+            {% include 'widget/page_list.html' with { pages: bookmarkList, pagePropertyName: 'page' } %}
+            <div class="user-page-list-additional-link">
+              <a href="/user/{{ pageUser.username }}/bookmarks"><i class="fa fa-angle-double-right"></i> See bookmarks</a>
+            </div>
+          {% endif %}
+        </div>
+      </div>
+
+      <div class="tab-pane user-created-list page-list" id="user-created-list">
+        <div class="page-list-container">
+          {% if createdList.length == 0 %}
+          No created pages yet.
+          {% else %}
+            {% include 'widget/page_list.html' with { pages: createdList } %}
+            <div class="user-page-list-additional-link">
+              <a href="/user/{{ pageUser.username }}/recent-create"><i class="fa fa-angle-double-right"></i> See created pages</a>
+            </div>
+          {% endif %}
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+{% else %}
+  {% parent %}
+{% endif %}
+
+{% endblock %}
+

+ 43 - 0
lib/views/widget/page_list.html

@@ -0,0 +1,43 @@
+<ul class="page-list-ul">
+{% for data in pages %}
+
+{% if pagePropertyName %}
+  {% set page = data[pagePropertyName] %}
+{% else %}
+  {% set page = data %}
+{% endif %}
+
+<li class="page-list-li">
+  <img src="{{ page.revision.author|picture }}" class="picture picture-rounded">
+
+  <a class="page-list-link" href="{{ page.path }}"
+    data-path="{{ page.path }}"
+    data-short-path="{{ page.path|path2name }}">{{ page.path }}</a>
+
+  <span class="page-list-meta">
+    {% if page.isPortal() %}
+      <span class="label label-info">PORTAL</span>
+    {% endif  %}
+
+    {% if page.commentCount > 0 %}
+      <i class="fa fa-comment"></i>{{ page.commentCount }}
+    {% endif  %}
+
+    {% if !page.isPublic() %}
+      <i class="fa fa-lock"></i>
+    {% endif %}
+  </span>
+</li>
+{% endfor %}
+</ul>
+
+{% if pager %}
+<ul class="pagination">
+  {% if pager.prev !== null %}
+    <li class="prev"><a href="{{ path }}?offset={{ pager.prev }}"><i class="fa fa-arrow-left"></i> Prev</a></li>
+  {% endif %}
+  {% if pager.next %}
+    <li class="next"><a href="{{ path }}?offset={{ pager.next }}">Next <i class="fa fa-arrow-right"></i></a></li>
+  {% endif %}
+</ul>
+{% endif %}

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

@@ -0,0 +1,42 @@
+<h3><i class="fa fa-link"></i> Share</h3>
+<ul class="fitted-list">
+  <li data-toggle="tooltip" data-placement="bottom" title="共有用リンク" class="input-group">
+    <span class="input-group-addon">共有用</span>
+    <input class="copy-link form-control" type="text" value="{{ config.crowi['app:title'] }} {{ path }}  {{ baseUrl }}/_r/{{ page._id.toString() }}">
+  </li>
+  <li data-toggle="tooltip" data-placement="bottom" title="Markdown形式のリンク" class="input-group">
+    <span class="input-group-addon">Markdown</span>
+    <input class="copy-link form-control" type="text" value="[{{ path }}]({{ baseUrl }}/_r/{{ revision._id.toString() }})">
+  </li>
+</ul>
+
+<h3><i class="fa fa-comment"></i> Comments</h3>
+<div class="page-comments">
+  <form class="form page-comment-form" id="page-comment-form">
+    <div class="comment-form">
+      <div class="comment-form-main">
+        <div class="comment-write" id="comment-write">
+          <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"></textarea>
+        </div>
+        <div class="comment-submit">
+          <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
+          <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
+          <span class="text-danger" id="comment-form-message"></span>
+          <input type="submit" id="commenf-form-button" value="Comment" class="btn btn-primary btn-sm form-inline">
+        </div>
+      </div>
+    </div>
+  </form>
+
+  <div class="page-comments-list" id="page-comments-list">
+    <div class="page-comments-list-newer collapse" id="page-comments-list-newer"></div>
+
+    <a class="page-comments-list-toggle-newer text-center" data-toggle="collapse" href="#page-comments-list-newer"><i class="fa fa-angle-double-up"></i> Comments for Newer Revision <i class="fa fa-angle-double-up"></i></a>
+
+    <div class="page-comments-list-current" id="page-comments-list-current"></div>
+
+    <a class="page-comments-list-toggle-older text-center" data-toggle="collapse" href="#page-comments-list-older"><i class="fa fa-angle-double-down"></i> Comments for Older Revision <i class="fa fa-angle-double-down"></i></a>
+
+    <div class="page-comments-list-older collapse in" id="page-comments-list-older"></div>
+  </div>
+</div>

+ 55 - 0
lib/views/widget/page_side_header.html

@@ -0,0 +1,55 @@
+{% if page %} {# {{{ if page #}
+<div class="page-meta">
+  <div class="row">
+    {# default(author) としているのは、v1.1.1 以前に page.creator データが入ってないから。暫定として最新更新ユーザーを表示しちゃう。 #}
+    <div class="col-md-3 creator-picture">
+      <img src="{{ page.creator|default(author)|picture }}" class="picture picture-lg picture-rounded"><br>
+    </div>
+    <div class="col-md-9">
+      <p class="creator">
+        {{ page.creator.name|default(author.name) }}
+      </p>
+      <p class="created-at">
+        作成日: {{ page.createdAt|datetz('Y/m/d H:i:s') }}<br>
+        最終更新: {{ page.updatedAt|datetz('Y/m/d H:i:s') }} <a href="/user/{{ page.revision.author.username }}"><img src="{{ page.revision.author|picture }}" class="picture picture-xs picture-rounded" alt="{{ page.revision.author.name }}"></a>
+      </p>
+    </div>
+  </div>
+
+  <div class="like-box">
+    <dl class="dl-horizontal">
+      <dt>
+        <i class="fa fa-thumbs-o-up"></i> いいね!
+      </dt>
+      <dd>
+        <p class="liker-count">
+        <span id="like-count">{{ page.liker.length }}</span>
+        <button
+          data-liked="{% if page.isLiked(user) %}1{% else %}0{% endif %}"
+          class="btn btn-default btn-sm {% if page.isLiked(user) %}active{% endif %}"
+          id="like-button"><i class="fa fa-thumbs-o-up"></i> いいね!</button>
+        </p>
+        <p id="liker-list" class="liker-list" data-likers="{{ page.liker|default([])|join(',') }}">
+        </p>
+      </dd>
+
+      <dt><i class="fa fa-eye"></i> 見た人</dt>
+      <dd>
+        <p class="seen-user-count">
+          {{ page.seenUsers.length }}
+        </p>
+        <p id="seen-user-list" class="seen-user-list" data-seen-users="{{ page.seenUsers|default([])|join(',') }}">
+        {#
+          {% for seenUser in page.seenUsers %}
+          <a href="{{ user_page_root(seenUser) }}" title="{{ seenUser.name }}"><img alt="{{ seenUser.name }}" src="{{ seenUser|picture }}" class="picture picture-xs picture-rounded"></a>
+          {% endfor %}
+          {% if page.seenUsers.length > 10 %}
+            (...)
+          {% endif %}
+        #}
+        </p>
+      </dd>
+    </dl>
+  </div>
+</div>
+{% endif %} {# if page }}} #}

+ 1 - 0
package.json

@@ -75,6 +75,7 @@
     "reveal.js": "~3.2.0",
     "socket.io": "~1.3.0",
     "socket.io-client": "~1.3.0",
+    "sprintf": "~0.1.5",
     "swig": "~1.4.0",
     "time": "~0.11.0",
     "vinyl-source-stream": "~1.1.0"

+ 135 - 4
resource/css/_form.scss

@@ -1,16 +1,147 @@
+.crowi.main-container .main .content-main.on-edit { // {{{ Edit Form of Page
+  position: fixed;
+  z-index: 1060;
+  background: #fff;
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 100%;
+
+  .nav {
+    margin-top: 8px;
+    height: 40px;
+  }
+
+  .tab-content {
+    top: 48px;
+    bottom: 58px;
+    padding: 0 12px;
+    position: absolute;
+    z-index: 1061;
+    left: 0;
+    right: 0;
+    margin-top: 4px;
+
+    .alert-info {
+      display: none;
+    }
+
+    .edit-form { // {{{
+      height: 100%;
+      .row {
+        height: 100%;
+        .col-md-6 {
+          height: 100%;
+        }
+        form {
+          padding: 0;
+          border-right: solid 1px #ccc;
+          &::after {
+            position: absolute;
+            top: 0;
+            right: 15px;
+            font-size: 10px;
+            font-weight: 700;
+            color: #959595;
+            text-transform: uppercase;
+            letter-spacing: 1px;
+            content: "Input Content ...";
+          }
+        }
+        textarea {
+          height: 100%;
+          padding-top: 18px;
+          border: none;
+          box-shadow: none;
+
+          &.dragover {
+            border: dashed 6px #ccc;
+            padding: 12px 6px 0px;
+          }
+        }
+        .preview-body {
+          height: 100%;
+          padding-top: 18px;
+          overflow: scroll;
+
+          &::after {
+            position: absolute;
+            top: 0;
+            right: 15px;
+            font-size: 10px;
+            font-weight: 700;
+            color: #959595;
+            text-transform: uppercase;
+            letter-spacing: 1px;
+            content: "Preview";
+          }
+        }
+      }
+    }
+
+  } // }}}
+
+  .form-group.form-submit-group {
+
+    position: fixed;
+    z-index: 1054;
+    bottom: 0;
+    width: 100%;
+    left: 0;
+    padding: 8px;
+    height: 50px;
+    background: rgba(255,255,255,.8);
+    border-top: solid 1px #ccc;
+    margin-bottom: 0;
+  }
+} // }}}
+
+.crowi.main-container .main .page-list.content-main { // {{{ Edit Form of Page List
+  .close-button {
+    display: none;
+  }
+}
+.crowi.main-container .main .page-list.content-main.on-edit { // {{{ Edit Form of Page List
+  .close-button {
+    display: block;
+  }
+
+  .page-list-container {
+    display: none;
+  }
+  .portal-form-header,
+  .portal-form {
+    display: block;
+  }
+
+  .portal-form-header {
+    height: 16px;
+    padding: 8px;
+    border-bottom: solid 1px #ccc;
+  }
+} // }}}
 
 textarea {
   font-family: menlo, monaco, consolas, monospace;
   line-height: 1.1em;
 }
 
-textarea.form-body-height {
-  height: 300px;
-}
-
 input::-webkit-input-placeholder {
   color: #ccc;
 }
 input:-moz-placeholder {
   color: #ccc;
 }
+
+@media (max-width: $screen-sm-max) { // {{{ less than tablet size
+
+  .content-main.on-edit {
+    .form-group.form-submit-group {
+      select.form-control {
+        display: inline-block;
+        width: auto;
+      }
+    }
+  }
+
+} // }}}

+ 11 - 436
resource/css/_layout.scss

@@ -18,6 +18,10 @@
 
       .navbar-brand {
         font-weight: bold;
+        img {
+          display: inline;
+          margin-right: 8px;
+        }
       }
       .navbar-collapse {
         background: $crowiHeaderBackground;
@@ -83,186 +87,6 @@
       }
     } // }}}
 
-
-    aside.sidebar { // {{{
-      z-index: 1030;
-      position: fixed;
-      padding: 65px 0 0 0;
-      margin-bottom: $crowiFooterHeight;
-      color: #333;
-      height: 100%;
-      right: 0;
-      top: 0;
-      overflow: auto;
-      border-left: solid 1px #ccc;
-      background: $crowiAsideBackground;
-
-      transition: .3s ease;
-      -webkit-transition: .3s ease;
-
-
-      .page-meta {
-        padding: 15px 15px 5px 15px;
-        color: #666;
-        font-size: .9em;
-        line-height: 1.4em;
-        border-bottom: solid 1px #ccc;
-
-        .creator-picture {
-          text-align: center;
-          img {
-            width: 48px;
-            height: 48px;
-            box-shadow: 0 0 2px #333;
-          }
-        }
-        .creator {
-          font-size: 1.3em;
-          font-weight: bold;
-        }
-        .created-at {
-        }
-
-        .like-box {
-          padding-bottom: 0;
-
-          .dl-horizontal {
-            margin-bottom: 0;
-
-            dt, dd {
-              border-top: solid 1px #ccc;
-              padding-top: 5px;
-              padding-bottom: 5px;
-            }
-            dt {
-              width: 80px;
-            }
-            dd {
-              margin-left: 90px;
-              text-align: right;
-            }
-          }
-
-          .btn-bookmark {
-            color: #e6b422;
-            &.bookmarked {
-              color: #fff;
-            }
-          }
-        }
-
-        .liker-list, .contributor-list, .seen-user-list {
-          .picture-rounded {
-            box-shadow: 0 0 2px #666;
-          }
-        }
-        .liker-count, .contributor-count, .seen-user-count {
-          font-size: 1.2em;
-          font-weight: bold;
-          margin-bottom: 5px;
-        }
-        .contributor-list, .seen-user-list {
-        }
-      }
-
-
-      .side-content {
-        margin-bottom: $crowiFooterHeight + $crowiHeaderHeight;
-        color: #666;
-        padding: 15px;
-
-        h3 {
-          font-size: 1.1em;
-        }
-
-        a {
-          color: #ccc;
-          &:hover { color: #aaa;}
-        }
-
-        ul.fitted-list {
-          padding-left: 0;
-          li {
-            margin-bottom: 2px;
-
-            .input-group-addon {
-              padding: 5px 6px;
-            }
-          }
-        }
-      }
-    } // }}}
-
-    .main { // {{{
-      transition: .5s ease;
-      -webkit-transition: .5s ease;
-      background: #fff;
-
-      padding: 20px;
-      //margin-left: 10px;
-      //padding: 10px;
-      //
-      article {
-        background: #fff;
-      }
-
-      article header {
-        background: #fff;
-        width: 100%;
-
-        p.stopper {
-          display: none;
-        }
-
-        &.affix {
-          width: 100%;
-          top: 0;
-          left: 0;
-          padding: 5px 20px;
-          z-index: 1039;
-          background: rgba(255, 255, 255, .9);
-          box-shadow: 0 0px 2px #999;
-
-          h1 {
-            font-size: 1.8em;
-          }
-
-          p.stopper {
-            display: block;
-            position: absolute;
-            bottom: -30px;
-            right: 10px;
-            background: #fff;
-            padding: 7px 14px;
-            margin: 0;
-            border: solid 1px #ccc;
-            border-top: none;
-            border-radius: 0 0 5px 5px;
-            font-size: .8em;
-          }
-        }
-      }
-
-      &.col-md-12 article header.affix {
-        width: 100%;
-      }
-
-
-      article header h1 {
-        margin-top: 0;
-
-        a:last-child {
-          color: #D1E2E4;
-          opacity: .7;
-
-          &:hover {
-            color: inherit;
-          }
-        }
-      }
-
-    } // }}}
-
     .page-list {
       .page-list-link {
       }
@@ -272,254 +96,9 @@
       }
     }
 
-    .main.grant-restricted,
-    .main.grant-specified,
-    .main.grant-owner {
-      background: #333;
-      padding: 20px 10px;
-
-      .page-grant {
-        color: #ccc;
-      }
-
-      article {
-        border-radius: 5px;
-        padding: 20px;
-      }
-    }
-
-    .page-attachments {
-      p {
-        font-weight: bold;
-      }
-
-      ul {
-      }
-    }
-
-    .footer { // {{{
-      position: fixed;
-      width: 100%;
-      bottom: 0px;
-      height: 26px;
-      padding: 4px;
-      color: #444;
-      background: $crowiAsideBackground;
-      border-top-left-radius: 5px;
-      z-index: 1055;
-
-      a {
-        color: #666;
-      }
-    } // }}}
-  } // }}}
-
-  &.main-container.aside-hidden { // {{{
-    .layout-control {
-      right: 0;
-      i {
-        transform: rotate(180deg);
-      }
-    }
-
-    aside.sidebar { // {{{
-      right: -25%;
-    } // }}}
-
-    .main { // {{{
-      width: 100%;
-
-      article header.affix {
-        width: 100%;
-      }
-    } // }}}
   } // }}}
-
-  // override bootstrap modals
-  //.modal-backdrop {
-  //  z-index: 1052;
-  //}
-  //.modal {
-  //  z-index: 1055;
-  //}
 } // }}}
 
-.crowi.main-container .main {
-  .wiki-content {
-  }
-
-  .content-main {
-    .tab-content {
-      margin-top: 30px;
-
-      .revision-history {
-        h1 {
-          padding-bottom: 0.3em;
-          font-size: 2.3em;
-          font-weight: bold;
-          border-bottom: solid 1px #ccc;
-        }
-
-
-        .revision-history-list {
-          .revision-hisory-outer {
-            margin-top: 8px;
-
-            .picture {
-              float: left;
-              width: 32px;
-              height: 32px;
-            }
-
-            .revision-history-main {
-              margin-left: 40px;
-
-              .revision-history-author {
-              }
-              .revision-history-comment {
-              }
-              .revision-history-meta {
-              }
-            }
-          }
-
-          li {
-            position: relative;
-            list-style: none;
-
-            a {
-              color: #666;
-              padding: 3px 5px 3px 40px;
-              display: block;
-
-              &:hover {
-                background: darken($crowiAsideBackground, 10%);
-                text-decoration: none;
-                color: darken($link-color, 35%);
-              }
-            }
-
-          }
-        }
-
-      }
-    }
-  }
-
-  .content-main .timeline-body { // {{{ timeline
-    .revision-path {
-      margin-top: 1.6em;
-      margin-bottom: 0;
-      border: solid 2px #ddd;
-      border-bottom: none;
-      padding: 16px;
-      background: #ddd;
-    }
-    .revision-body {
-      font-size: 14px;
-      border: solid 2px #ddd;
-      padding: 16px;
-      background: #fdfdfd;
-    }
-  } // }}}
-
-  // on-edit
-  .content-main.on-edit {
-    position: fixed;
-    z-index: 1060;
-    background: #fff;
-    top: 0;
-    left: 0;
-    height: 100%;
-    width: 100%;
-
-    .nav {
-      margin-top: 8px;
-      height: 40px;
-    }
-
-    .tab-content {
-      .alert-info {
-        display: none;
-      }
-
-      top: 48px;
-      bottom: 58px;
-      padding: 0 12px;
-      position: absolute;
-      left: 0;
-      right: 0;
-      margin-top: 4px;
-
-      .edit-form {
-        height: 100%;
-        .row {
-          height: 100%;
-          .col-md-6 {
-            height: 100%;
-          }
-          form {
-            padding: 0;
-            border-right: solid 1px #ccc;
-            &::after {
-              position: absolute;
-              top: 0;
-              right: 15px;
-              font-size: 10px;
-              font-weight: 700;
-              color: #959595;
-              text-transform: uppercase;
-              letter-spacing: 1px;
-              content: "Input Content ...";
-            }
-          }
-          textarea {
-            height: 100%;
-            padding-top: 18px;
-            border: none;
-            box-shadow: none;
-
-            &.dragover {
-              border: dashed 6px #ccc;
-              padding: 12px 6px 0px;
-            }
-          }
-          .preview-body {
-            height: 100%;
-            padding-top: 18px;
-            overflow: scroll;
-
-            &::after {
-              position: absolute;
-              top: 0;
-              right: 15px;
-              font-size: 10px;
-              font-weight: 700;
-              color: #959595;
-              text-transform: uppercase;
-              letter-spacing: 1px;
-              content: "Preview";
-            }
-          }
-        }
-      }
-    }
-
-    .form-group.form-submit-group {
-
-      position: fixed;
-      z-index: 1054;
-      bottom: 0;
-      width: 100%;
-      left: 0;
-      padding: 8px;
-      height: 50px;
-      background: rgba(255,255,255,.8);
-      border-top: solid 1px #ccc;
-      margin-bottom: 0;
-    }
-  }
-}
 
 .crowi.single { // {{{
 } // }}}
@@ -607,23 +186,16 @@
 
 } // }}}
 
-@media (max-width: $screen-sm-max) { // {{{ less than tablet size
-
-  .content-main.on-edit {
-    .form-group.form-submit-group {
-      select.form-control {
-        display: inline-block;
-        width: auto;
-      }
-    }
-  }
-
+@media (max-width: $screen-sm-max) { // {{{
 } // }}}
 
 @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { // {{{ tablet size
   .crowi.main-container { // {{{
     .main {
       article header {
+        .bookmark-link {
+          font-size: 1.3em;
+        }
         h1 {
           font-size: 1.4em;
           margin-bottom: 0;
@@ -642,6 +214,9 @@
     .main {
       padding: 10px;
       article header {
+        .bookmark-link {
+          font-size: 1em;
+        }
         h1 {
           font-size: 1.1em;
         }

+ 300 - 0
resource/css/_page.scss

@@ -0,0 +1,300 @@
+.crowi.main-container {
+
+  .main { // {{{ .main of layout related
+    transition: .5s ease;
+    -webkit-transition: .5s ease;
+    background: #fff;
+
+    padding: 20px;
+    article {
+      background: #fff;
+    }
+
+    article header {
+      background: #fff;
+      width: 100%;
+
+      p.stopper {
+        display: none;
+      }
+
+      &.affix {
+        width: 100%;
+        top: 0;
+        left: 0;
+        padding: 5px 20px;
+        z-index: 1039;
+        background: rgba(255, 255, 255, .9);
+        box-shadow: 0 0px 2px #999;
+
+        h1 {
+          font-size: 1.8em;
+        }
+
+        p.stopper {
+          display: block;
+          position: absolute;
+          bottom: -30px;
+          right: 10px;
+          background: #fff;
+          padding: 7px 14px;
+          margin: 0;
+          border: solid 1px #ccc;
+          border-top: none;
+          border-radius: 0 0 5px 5px;
+          font-size: .8em;
+        }
+      }
+
+    }
+
+    &.col-md-12 article header.affix {
+      width: 100%;
+    }
+
+    article header { // not affixed
+      .bookmark-link {
+        float: right;
+        color: #e6b422;
+        font-size: 2em;
+        &.bookmarked {
+          //color: #fff;
+        }
+      }
+
+      h1 {
+        margin-top: 0;
+
+        a:last-child {
+          color: #D1E2E4;
+          opacity: .7;
+
+          &:hover {
+            color: inherit;
+          }
+        }
+      }
+    }
+
+    .content-main {
+      .tab-content {
+        margin-top: 30px;
+      }
+    }
+  } // }}}
+
+  // {{{ grant related style
+  .main.grant-restricted,
+  .main.grant-specified,
+  .main.grant-owner {
+    background: #333;
+    padding: 20px 10px;
+
+    .page-grant {
+      color: #ccc;
+    }
+
+    article {
+      border-radius: 5px;
+      padding: 20px;
+    }
+  }
+  // }}}
+
+  .page-attachments { // {{{
+    p {
+      font-weight: bold;
+    }
+
+    ul {
+    }
+  } // }}}
+
+  aside.sidebar { // {{{
+    z-index: 1030;
+    position: fixed;
+    padding: 65px 0 0 0;
+    margin-bottom: $crowiFooterHeight;
+    color: #333;
+    height: 100%;
+    right: 0;
+    top: 0;
+    overflow: auto;
+    border-left: solid 1px #ccc;
+    background: $crowiAsideBackground;
+
+    transition: .3s ease;
+    -webkit-transition: .3s ease;
+
+
+    .page-meta {
+      padding: 15px 15px 5px 15px;
+      color: #666;
+      font-size: .9em;
+      line-height: 1.4em;
+      border-bottom: solid 1px #ccc;
+
+      .creator-picture {
+        text-align: center;
+        img {
+          width: 48px;
+          height: 48px;
+          box-shadow: 0 0 1px #666;
+        }
+      }
+      .creator {
+        font-size: 1.3em;
+        font-weight: bold;
+      }
+      .created-at {
+      }
+
+      .like-box {
+        padding-bottom: 0;
+
+        .dl-horizontal {
+          margin-bottom: 0;
+
+          dt, dd {
+            border-top: solid 1px #ccc;
+            padding-top: 5px;
+            padding-bottom: 5px;
+          }
+          dt {
+            width: 80px;
+          }
+          dd {
+            margin-left: 90px;
+            text-align: right;
+          }
+        }
+      }
+
+      .liker-count, .contributor-count, .seen-user-count {
+        font-size: 1.2em;
+        font-weight: bold;
+        margin-bottom: 5px;
+      }
+      .contributor-list, .seen-user-list {
+      }
+    }
+
+
+    .side-content {
+      margin-bottom: $crowiFooterHeight + $crowiHeaderHeight;
+      color: #666;
+      padding: 15px;
+
+      h3 {
+        font-size: 1.1em;
+      }
+
+      a {
+        color: #ccc;
+        &:hover { color: #aaa;}
+      }
+
+      ul.fitted-list {
+        padding-left: 0;
+        li {
+          margin-bottom: 2px;
+
+          .input-group-addon {
+            padding: 5px 6px;
+          }
+        }
+      }
+    }
+  } // }}}
+
+  .footer { // {{{
+    position: fixed;
+    width: 100%;
+    bottom: 0px;
+    height: 26px;
+    padding: 4px;
+    color: #444;
+    background: $crowiAsideBackground;
+    border-top-left-radius: 5px;
+    z-index: 1055;
+
+    a {
+      color: #666;
+    }
+  } // }}}
+
+  &.aside-hidden { // {{{
+    .layout-control {
+      right: 0;
+      i {
+        transform: rotate(180deg);
+      }
+    }
+
+    aside.sidebar { // {{{
+      right: -25%;
+    } // }}}
+
+    .main { // {{{
+      width: 100%;
+
+      article header.affix {
+        width: 100%;
+      }
+    } // }}}
+  } // }}}
+
+}
+.crowi.main-container .main .content-main .revision-history { // {{{
+  h1 {
+    padding-bottom: 0.3em;
+    font-size: 2.3em;
+    font-weight: bold;
+    border-bottom: solid 1px #ccc;
+  }
+
+
+  .revision-history-list {
+    .revision-hisory-outer {
+      margin-top: 8px;
+
+      .picture {
+        float: left;
+        width: 32px;
+        height: 32px;
+      }
+
+      .revision-history-main {
+        margin-left: 40px;
+
+        .revision-history-author {
+          color: #666;
+        }
+        .revision-history-comment {
+        }
+        .revision-history-meta {
+        }
+      }
+    }
+
+    li {
+      position: relative;
+      list-style: none;
+
+      a {
+        color: #666;
+        padding: 3px 5px 3px 40px;
+        display: block;
+
+        &:hover {
+          background: darken($crowiAsideBackground, 10%);
+          text-decoration: none;
+          color: darken($link-color, 35%);
+        }
+      }
+
+    }
+  }
+
+} // }}}
+

+ 48 - 0
resource/css/_page_list.scss

@@ -0,0 +1,48 @@
+
+.crowi.main-container .main .content-main .timeline-body { // {{{
+  .revision-path {
+    margin-top: 1.6em;
+    margin-bottom: 0;
+    border: solid 2px #ddd;
+    border-bottom: none;
+    padding: 16px;
+    background: #ddd;
+  }
+  .revision-body {
+    font-size: 14px;
+    border: solid 2px #ddd;
+    padding: 16px;
+    background: #fdfdfd;
+  }
+} // }}}
+
+.page-list {
+  .page-list-container {
+    line-height: 1.6em;
+    font-size: 15px;
+  }
+
+  .page-list-ul {
+    padding-left: 0;
+
+    .page-list-li {
+      list-style: none;
+      line-height: 1.8em;
+
+      .picture {
+        width: 16px;
+        height: 16px;
+        margin-right: 4px;
+      }
+
+      .page-list-link {
+        font-size: 1.1em;
+        color: #666;
+
+        strong {
+          color: #333;
+        }
+      }
+    }
+  }
+}

+ 31 - 0
resource/css/_portal.scss

@@ -0,0 +1,31 @@
+.portal-header {
+}
+
+.portal-label {
+  background: #5bc0de;
+  //float: right;
+  font-weight: bold;
+  padding: 2px 4px;
+  //margin: 1em 8px 0;
+  font-size: .8em;
+  color: #fff;
+  border-radius: 4px;
+}
+
+.portal {
+  .wiki {
+    margin-bottom: 16px;
+    padding-bottom: 16px;
+  }
+
+  .portal-form {
+
+  }
+
+} // .portal
+
+.portal-side {
+  .portal-form-button {
+    text-align: center;
+  }
+} // .portal-side

+ 68 - 0
resource/css/_user.scss

@@ -0,0 +1,68 @@
+.crowi.main-container {
+  .main.user-page { // {{{ .main of layout related
+    padding: 0;
+
+    .header-wrap {
+      padding: 20px;
+
+      h1 {
+        margin: 0;
+        font-size: 1.1em;
+      }
+
+      .user-page-header {
+        margin-bottom: 32px;
+
+        .bookmark-link {
+          float: right;
+          color: #e6b422;
+          font-size: 2em;
+          &.bookmarked {
+            //color: #fff;
+          }
+        }
+
+        .user-page-picture {
+          img {
+            height: 64px;
+            width: 64px;
+          }
+        }
+        .user-page-meta {
+          padding-left: 88px;
+          color: #999;
+
+          h2 {
+            font-size: 2.5em;
+            color: #666;
+          }
+
+          ul {
+            padding-left: 0;
+            li {
+              list-style: none;
+            }
+          }
+          .user-page-username {
+            font-weight: bold;
+          }
+          .user-page-email {
+          }
+          .user-page-introduction {
+          }
+        }
+      }
+
+      .user-page-content-tab {
+        padding: 16px 0 8px;
+      }
+
+    }
+    .content-main {
+      padding: 20px;
+      &.on-edit {
+        padding: 0;
+      }
+    }
+  } // }}}
+}

+ 2 - 2
resource/css/_wiki.scss

@@ -108,8 +108,8 @@ div.body {
 
   img {
     margin: 5px 0;
-    box-shadow: 0 0 12px 0px #999;
-    border: solid 1px #999;
+    box-shadow: 0 0 5px 0px rgba(0,0,0,.2);
+    border: solid 1px #ccc;
     max-width: 100%;
   }
 

+ 5 - 1
resource/css/crowi.scss

@@ -8,10 +8,14 @@
 
 // crowi component
 @import 'layout';
+@import 'page';
+@import 'page_list';
 @import 'form';
 @import 'wiki';
 @import 'admin';
 @import 'comment';
+@import 'user';
+@import 'portal';
 
 
 ul {
@@ -172,7 +176,7 @@ footer {
   }
   &.picture-rounded {
     border-radius: 50%;
-    box-shadow: 0 0 2px #ccc;
+    box-shadow: 0 0 2px rgba(0,0,0,.3);
   }
 }
 // components

+ 176 - 2
resource/js/crowi.js

@@ -22,10 +22,14 @@ Crowi.linkPath = function(revisionPath) {
   if (!$title.get(0)) {
     return;
   }
+  var realPath = $title.text().trim();
+  if (realPath.substr(-1, 1) == '/') {
+    realPath = realPath.substr(0, realPath.length - 1);
+  }
 
   var path = '';
   var pathHtml = '';
-  var splittedPath = $title.html().split(/\//);
+  var splittedPath = realPath.split(/\//);
   splittedPath.shift();
   splittedPath.forEach(function(sub) {
     path += '/';
@@ -216,12 +220,21 @@ $(function() {
   var revisionId = $('#content-main').data('page-revision-id');
   var revisionCreatedAt = $('#content-main').data('page-revision-created');
   var currentUser = $('#content-main').data('current-user');
+  var isSeen = $('#content-main').data('page-is-seen');
 
   Crowi.linkPath();
 
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
 
+  $('a[data-toggle="tab"][href="#edit-form"]').on('show.bs.tab', function() {
+    $('.content-main').addClass('on-edit');
+  });
+  $('a[data-toggle="tab"][href="#edit-form"]').on('hide.bs.tab', function() {
+    $('.content-main').removeClass('on-edit');
+  });
+
+
   $('.copy-link').on('click', function () {
     $(this).select();
   });
@@ -269,6 +282,28 @@ $(function() {
     return false;
   });
 
+  $('#create-portal-button').on('click', function(e) {
+    $('.portal').removeClass('hide');
+    $('.content-main').addClass('on-edit');
+    $('.portal a[data-toggle="tab"][href="#edit-form"]').tab('show');
+  });
+  $('#portal-form-close').on('click', function(e) {
+    $('.portal').addClass('hide');
+    $('.content-main').removeClass('on-edit');
+
+    return false;
+  });
+
+  // list-link
+  $('.page-list-link').each(function() {
+    var $link = $(this);
+    var text = $link.text();
+    var path = $link.data('path');
+    var shortPath = $link.data('short-path');
+
+    $link.html(path.replace(new RegExp(shortPath + '(/)?$'), '<strong>' + shortPath + '$1</strong>'));
+  });
+
 
   if (pageId) {
 
@@ -356,7 +391,7 @@ $(function() {
     $('#page-comment-form').on('submit', function() {
       $button = $('#commenf-form-button');
       $button.attr('disabled', 'disabled');
-      $.post('/_api/comments.post', $(this).serialize(), function(data) {
+      $.post('/_api/comments.add', $(this).serialize(), function(data) {
         $button.removeAttr('disabled');
         if (data.ok) {
           var comment = data.comment;
@@ -391,6 +426,145 @@ $(function() {
         $('.page-attachments').remove();
       }
     });
+
+    // bookmark
+    var $bookmarkButton = $('#bookmark-button');
+    $.get('/_api/bookmarks.get', {page_id: pageId}, function(res) {
+      if (res.ok) {
+        if (res.bookmark) {
+          MarkBookmarked();
+        }
+      }
+    });
+
+    $bookmarkButton.click(function() {
+      var bookmarked = $bookmarkButton.data('bookmarked');
+      if (!bookmarked) {
+        $.post('/_api/bookmarks.add', {page_id: pageId}, function(res) {
+          if (res.ok && res.bookmark) {
+            MarkBookmarked();
+          }
+        });
+      } else {
+        $.post('/_api/bookmarks.remove', {page_id: pageId}, function(res) {
+          if (res.ok) {
+            MarkUnBookmarked();
+          }
+        });
+      }
+
+      return false;
+    });
+
+    function MarkBookmarked()
+    {
+      $('i', $bookmarkButton)
+        .removeClass('fa-star-o')
+        .addClass('fa-star');
+      $bookmarkButton.data('bookmarked', 1);
+    }
+
+    function MarkUnBookmarked()
+    {
+      $('i', $bookmarkButton)
+        .removeClass('fa-star')
+        .addClass('fa-star-o');
+      $bookmarkButton.data('bookmarked', 0);
+    }
+
+    // Like
+    var $likeButton = $('#like-button');
+    var $likeCount = $('#like-count');
+    $likeButton.click(function() {
+      var liked = $likeButton.data('liked');
+      if (!liked) {
+        $.post('/_api/likes.add', {page_id: pageId}, function(res) {
+          if (res.ok) {
+            MarkLiked();
+          }
+        });
+      } else {
+        $.post('/_api/likes.remove', {page_id: pageId}, function(res) {
+          if (res.ok) {
+            MarkUnLiked();
+          }
+        });
+      }
+
+      return false;
+    });
+    var $likerList = $("#liker-list");
+    var likers = $likerList.data('likers');
+    if (likers && likers.length > 0) {
+      // FIXME: user data cache
+      $.get('/_api/users.list', {user_ids: likers}, function(res) {
+        // ignore unless response has error
+        if (res.ok) {
+          AddToLikers(res.users);
+        }
+      });
+    }
+
+    function AddToLikers (users) {
+      $.each(users, function(i, user) {
+        $likerList.append(CreateUserLinkWithPicture(user));
+      });
+    }
+
+    function MarkLiked()
+    {
+      $likeButton.addClass('active');
+      $likeButton.data('liked', 1);
+      $likeCount.text(parseInt($likeCount.text()) + 1);
+    }
+
+    function MarkUnLiked()
+    {
+      $likeButton.removeClass('active');
+      $likeButton.data('liked', 0);
+      $likeCount.text(parseInt($likeCount.text()) - 1);
+    }
+
+    if (!isSeen) {
+      $.post('/_api/pages.seen', {page_id: pageId}, function(res) {
+        // ignore unless response has error
+        if (res.ok && res.seenUser) {
+          $('#content-main').data('page-is-seen', 1);
+        }
+      });
+    }
+
+    var $seenUserList = $("#seen-user-list");
+    var seenUsers = $seenUserList.data('seen-users');
+    if (seenUsers && seenUsers.length > 0) {
+      // FIXME: user data cache
+      $.get('/_api/users.list', {user_ids: seenUsers}, function(res) {
+        // ignore unless response has error
+        if (res.ok) {
+          AddToSeenUser(res.users);
+        }
+      });
+    }
+
+    function CreateUserLinkWithPicture (user) {
+      var $userHtml = $('<a>');
+      $userHtml.data('user-id', user._id);
+      $userHtml.attr('href', '/user/' + user.username);
+      $userHtml.attr('title', user.name);
+
+      var $userPicture = $('<img class="picture picture-xs picture-rounded">');
+      $userPicture.attr('alt', user.name);
+      $userPicture.attr('src',  Crowi.userPicture(user));
+
+      $userHtml.append($userPicture);
+      return $userHtml;
+    }
+
+    function AddToSeenUser (users) {
+      $.each(users, function(i, user) {
+        $seenUserList.append(CreateUserLinkWithPicture(user));
+      });
+    }
   }
 });
 

+ 7 - 2
test/crowi/crowi.test.js

@@ -46,8 +46,13 @@ describe('Test for Crowi application context', function () {
       p.then(function() {
         expect(mongoose.connection.readyState).to.equals(1);
         done();
-      }).catch(function() {
-        expect(mongoose.connection.readyState).to.equals(1);
+      }).catch(function(err) {
+        //console.log('readyState', mongoose.connection.readyState);
+        if (mongoose.connection.readyState === 2 || mongoose.connection.readyState === 1) { // alreaady connected
+          // throught
+        } else {
+          expect(mongoose.connection.readyState).to.equals(0);
+        }
         done();
       });
     });

+ 25 - 2
test/models/user.test.js

@@ -23,12 +23,35 @@ describe('User', function () {
       });
 
       it('should be found by findUserByUsername', function(done) {
-        User.findUserByUsername('aoi', function (err, userData) {
-          expect(err).to.be.null;
+        User.findUserByUsername('aoi')
+        .then(function(userData) {
           expect(userData).to.instanceof(User);
           done();
         });
       });
     });
   });
+
+  describe('User Utilities', function () {
+    context('Get username from path', function() {
+      it('found', function(done) {
+        var username = null;
+        username = User.getUsernameByPath('/user/sotarok');
+        expect(username).to.equal('sotarok');
+
+        username = User.getUsernameByPath('/user/some.user.name12/'); // with slash
+        expect(username).to.equal('some.user.name12');
+
+        done();
+      });
+
+      it('not found', function(done) {
+        var username = null;
+        username = User.getUsernameByPath('/the/page/is/not/related/to/user/page');
+        expect(username).to.be.null;
+
+        done();
+      });
+    });
+  });
 });

+ 6 - 3
test/utils.js

@@ -43,9 +43,12 @@ after('Close database connection', function (done) {
 });
 
 
-models.Page   = require(MODEL_DIR + '/page.js')(crowi);
-models.User   = require(MODEL_DIR + '/user.js')(crowi);
-models.Config = require(MODEL_DIR + '/config.js')(crowi);
+models.Page     = require(MODEL_DIR + '/page.js')(crowi);
+models.User     = require(MODEL_DIR + '/user.js')(crowi);
+models.Config   = require(MODEL_DIR + '/config.js')(crowi);
+models.Revision = require(MODEL_DIR + '/revision.js')(crowi);
+
+crowi.models = models;
 
 module.exports = {
   models: models,