Преглед изворни кода

Merge branch 'master' into imprv/display-side-scrollbar

mayu morita пре 7 година
родитељ
комит
d10b72bef4

+ 0 - 1
.gitignore

@@ -34,4 +34,3 @@ package-lock.json
 
 # IDE #
 .idea
-.vscode

+ 33 - 0
.vscode/launch.json

@@ -0,0 +1,33 @@
+{
+    // IntelliSense を使用して利用可能な属性を学べます。
+    // 既存の属性の説明をホバーして表示します。
+    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+      {
+        "type": "node",
+        "request": "launch",
+        "name": "Debug: Server",
+        "runtimeExecutable": "npm",
+        "runtimeArgs": [
+          "run",
+          "server:debug"
+        ],
+        "port": 9229,
+        "restart": true,
+        "console": "integratedTerminal",
+        "internalConsoleOptions": "neverOpen"
+      },
+      {
+        "type": "chrome",
+        "request": "launch",
+        "name": "Debug: Chrome",
+        "sourceMaps": true,
+        "sourceMapPathOverrides": {
+          "webpack:///*": "${workspaceRoot}/*"
+        },
+        "url": "http://localhost:3000",
+        "webRoot": "${workspaceRoot}/public"
+      }
+    ]
+}

+ 19 - 0
.vscode/tasks.json

@@ -0,0 +1,19 @@
+{
+    // See https://go.microsoft.com/fwlink/?LinkId=733558
+    // for the documentation about the tasks.json format
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "type": "npm",
+            "script": "build",
+            "problemMatcher": [
+                "$eslint-compact"
+            ]
+        },
+        {
+            "type": "npm",
+            "script": "server",
+            "problemMatcher": []
+        }
+    ]
+}

+ 13 - 1
CHANGES.md

@@ -1,9 +1,21 @@
 CHANGES
 ========
 
-## 3.1.1
+## 3.1.2-RC
 
 * Improvement: Add 'future' theme
+* Fix: Posting to Slack doesn't work
+    * Introduced by 3.1.0
+* Support: Upgrade libs
+    * assets-webpack-plugin
+    * react-clipboard.js
+    * xss
+
+## 3.1.1
+
+* Improvement: Add 'blue-night' theme
+* Improvement: List up pages which restricted for Group ACL
+* Fix: PageGroupRelation didn't remove when page is removed completely
 
 
 ## 3.1.0

+ 1 - 0
config/webpack.common.js

@@ -31,6 +31,7 @@ module.exports = function(options) {
       'style-theme-nature':   './resource/styles/scss/theme/nature.scss',
       'style-theme-mono-blue':   './resource/styles/scss/theme/mono-blue.scss',
       'style-theme-future': './resource/styles/scss/theme/future.scss',
+      'style-theme-blue-night': './resource/styles/scss/theme/blue-night.scss',
       'style-presentation':   './resource/styles/scss/style-presentation.scss',
     },
     externals: {

+ 24 - 2
lib/locales/en-US/translation.json

@@ -26,10 +26,11 @@
   "Sign in with Google Account": "Sign in with Google Account",
   "Sign up with this Google Account": "Sign up with this Google Account",
   "Example": "Example",
-  "Taro Yamada": "James Bond",
+  "Taro Yamada": "John Doe",
 
   "List View": "List",
   "Timeline View": "Timeline",
+  "History": "History",
   "Presentation Mode": "Presentation",
 
   "Created": "Created",
@@ -41,6 +42,8 @@
 
   "Management Wiki": "Management Wiki",
 
+  "Create/Edit Template": "Create/Edit Template Page",
+
   "Unportalize": "Unportalize",
 
   "View this version": "View this version",
@@ -77,7 +80,7 @@
   "Input page name": "Input page name",
   "Input page name (optional)": "Input page name (optional)",
   "New Page": "New Page",
-  "Create under": "Create page under: <code>%s</code>",
+  "Create under": "Create page under below:",
 
   "Table of Contents": "Table of Contents",
 
@@ -219,6 +222,25 @@
               }
   },
 
+  "template": {
+    "modal_label": {
+      "Create/Edit Template Page": "Create/Edit Template Page",
+      "Create template under": "Create template page under: <code>%s</code>"
+    },
+    "option_label": {
+      "create/edit": "Create/Edit Template page..",
+      "select": "Select template page type"
+    },
+    "local": {
+      "label": "Template for children",
+      "desc": "Applies only to the same level pages which the template exists"
+    },
+    "global": {
+      "label": "Template for descendants",
+      "desc": "Applies to all decendant pages"
+    }
+  },
+
   "admin_top": {
     "Management Wiki": "Management Wiki",
     "System Information": "System Information",

+ 24 - 4
lib/locales/ja/translation.json

@@ -30,6 +30,8 @@
 
   "List View": "リスト表示",
   "Timeline View": "タイムライン表示",
+  "History": "更新履歴",
+  "Presentation Mode": "プレゼンテーション",
 
   "Created": "作成日",
   "Last updated": "最終更新",
@@ -38,6 +40,8 @@
   "Share Link": "共有用リンク",
   "Markdown Link": "Markdown形式のリンク",
 
+  "Create/Edit Template": "テンプレートページの作成/編集",
+
   "Unportalize": "ポータル解除",
 
   "View this version": "このバージョンを見る",
@@ -75,10 +79,7 @@
   "Input page name": "ページ名を入力",
   "Input page name (optional)": "ページ名を入力(空欄OK)",
   "New Page": "新規ページ",
-  "Create under": "<code>%s</code>以下に作成",
-
-
-
+  "Create under": "ページを以下に作成",
 
   "Table of Contents": "目次",
   "Management Wiki Home": "Wiki管理トップ",
@@ -236,6 +237,25 @@
     }
   },
 
+  "template": {
+    "modal_label": {
+      "Create/Edit Template Page": "テンプレートページの作成/編集",
+      "Create template under": "<code>%s</code> にテンプレートページを作成"
+    },
+    "option_label": {
+      "select": "テンプレートタイプを選択してください",
+      "create/edit": "テンプレートページの作成/編集.."
+    },
+    "local": {
+      "label": "同一階層テンプレート",
+      "desc": "テンプレートページが存在する階層にのみ適応されます"
+    },
+    "global": {
+      "label": "下位層テンプレート",
+      "desc": "テンプレートページが存在する下位層のすべてのページに適応されます"
+    }
+  },
+
   "admin_top": {
     "Management Wiki": "Wiki管理",
     "System Information": "システム情報",

+ 167 - 83
lib/models/page.js

@@ -527,19 +527,111 @@ module.exports = function(crowi) {
     });
   };
 
-  // find page by path
-  pageSchema.statics.findPageByPath = function(path) {
-    var Page = this;
+  // check if a given page has a local and global tempalte
+  pageSchema.statics.checkIfTemplatesExist = function(path) {
+    const Page = this;
+    const pathList = generatePathsOnTree(path, []);
+    const regexpList = pathList.map(path => new RegExp(`${path}/_{1,2}template`));
+    let templateInfo = {
+      localTemplateExists: false,
+      globalTemplateExists: false,
+    };
 
-    return new Promise(function(resolve, reject) {
-      Page.findOne({path: path}, function(err, pageData) {
-        if (err || pageData === null) {
-          return reject(err);
-        }
+    return Page
+      .find({path: {$in: regexpList}})
+      .then(templates => {
+        templateInfo.localTemplateExists = (assignTemplateByType(templates, path, '__') ? true : false);
+        templateInfo.globalTemplateExists = (assignGlobalTemplate(templates, path) ? true : false);
 
-        return resolve(pageData);
+        return templateInfo;
       });
-    });
+  };
+
+  /**
+   * find all templates applicable to the new page
+   */
+  pageSchema.statics.findTemplate = function(path) {
+    const Page = this;
+    const templatePath = cutOffLastSlash(path);
+    const pathList = generatePathsOnTree(templatePath, []);
+    const regexpList = pathList.map(path => new RegExp(`^${path}/_{1,2}template$`));
+
+    return Page
+      .find({path: {$in: regexpList}})
+      .populate({path: 'revision', model: 'Revision'})
+      .then(templates => {
+        return fetchTemplate(templates, templatePath);
+      });
+  };
+
+  const cutOffLastSlash = path => {
+    const lastSlash = path.lastIndexOf('/');
+    return path.substr(0, lastSlash);
+  };
+
+  const generatePathsOnTree = (path, pathList) => {
+    if (path === '') {
+      return pathList;
+    }
+
+    pathList.push(path);
+    const newPath = cutOffLastSlash(path);
+
+    return generatePathsOnTree(newPath, pathList);
+  };
+
+  const assignTemplateByType = (templates, path, type) => {
+    for (let i = 0; i < templates.length; i++) {
+      if (templates[i].path === `${path}/${type}template`) {
+        return templates[i];
+      }
+    }
+  };
+
+  const assignGlobalTemplate = (globalTemplates, path) => {
+    const globalTemplate = assignTemplateByType(globalTemplates, path, '_');
+    if (globalTemplate) {
+      return globalTemplate;
+    }
+
+    if (path === '') {
+      return;
+    }
+
+    const newPath = cutOffLastSlash(path);
+    return assignGlobalTemplate(globalTemplates, newPath);
+  };
+
+  const fetchTemplate = (templates, templatePath) => {
+    let templateBody;
+    /**
+     * get local template
+     * __tempate: applicable only to immediate decendants
+     */
+    const localTemplate = assignTemplateByType(templates, templatePath, '__');
+
+    /**
+     * get global templates
+     * _tempate: applicable to all pages under
+     */
+    const globalTemplate = assignGlobalTemplate(templates, templatePath);
+
+    if (localTemplate) {
+      templateBody =  localTemplate.revision.body;
+    }
+    else if (globalTemplate) {
+      templateBody = globalTemplate.revision.body;
+    }
+
+    return templateBody;
+  };
+
+  // find page by path
+  pageSchema.statics.findPageByPath = function(path) {
+    if (path == null) {
+      return null;
+    }
+    return this.findOne({path});
   };
 
   pageSchema.statics.findListByPageIds = function(ids, options) {
@@ -767,6 +859,7 @@ module.exports = function(crowi) {
         {grant: GRANT_RESTRICTED, grantedUsers: userData._id},
         {grant: GRANT_SPECIFIED, grantedUsers: userData._id},
         {grant: GRANT_OWNER, grantedUsers: userData._id},
+        {grant: GRANT_USER_GROUP},
       ], })
       .and({
         $or: pathCondition
@@ -917,10 +1010,11 @@ module.exports = function(crowi) {
       grant = GRANT_PUBLIC;
     }
 
-    return new Promise(function(resolve, reject) {
-      Page.findOne({path: path}, function(err, pageData) {
+    let savedPage = undefined;
+    return Page.findOne({path: path})
+      .then(pageData => {
         if (pageData) {
-          return reject(new Error('Cannot create new page to existed path'));
+          throw new Error('Cannot create new page to existed path');
         }
 
         var newPage = new Page();
@@ -935,28 +1029,22 @@ module.exports = function(crowi) {
         newPage.grantedUsers = [];
         newPage.grantedUsers.push(user);
 
-        newPage.save(function(err, newPage) {
-          if (err) {
-            return reject(err);
-          }
-
-          if (newPage.grant == Page.GRANT_USER_GROUP && grantUserGroupId != null) {
-            Page.updateGrantUserGroup(newPage, grant, grantUserGroupId, user)
-              .catch((err) => {
-                return reject(err);
-              });
-          }
-          var newRevision = Revision.prepareRevision(newPage, body, user, {format: format});
-          Page.pushRevision(newPage, newRevision, user).then(function(data) {
-            resolve(data);
-            pageEvent.emit('create', data, user);
-          }).catch(function(err) {
-            debug('Push Revision Error on create page', err);
-            return reject(err);
-          });
-        });
+        return newPage.save();
+      })
+      .then((newPage) => {
+        savedPage = newPage;
+      })
+      .then(() => {
+        const newRevision = Revision.prepareRevision(savedPage, body, user, {format: format});
+        return Page.pushRevision(savedPage, newRevision, user);
+      })
+      .then(() => {
+        return Page.updateGrantUserGroup(savedPage, grant, grantUserGroupId, user);
+      })
+      .then(() => {
+        pageEvent.emit('create', savedPage, user);
+        return savedPage;
       });
-    });
   };
 
   pageSchema.statics.updatePage = function(pageData, body, user, options) {
@@ -968,14 +1056,22 @@ module.exports = function(crowi) {
     // update existing page
     var newRevision = Revision.prepareRevision(pageData, body, user);
 
+    let savedPage = undefined;
     return Page.pushRevision(pageData, newRevision, user)
-      .then(function(revision) {
-        return Page.updateGrant(pageData, grant, user, grantUserGroupId);
+      .then((revision) => {
+        // fetch Page
+        return Page.findPageByPath(revision.path).populate('revision');
       })
-      .then(function(data) {
+      .then((page) => {
+        savedPage = page;
+      })
+      .then(() => {
+        return Page.updateGrant(savedPage, grant, user, grantUserGroupId);
+      })
+      .then((data) => {
         debug('Page grant update:', data);
-        pageEvent.emit('update', data, user);
-        return data;
+        pageEvent.emit('update', savedPage, user);
+        return savedPage;
       });
   };
 
@@ -984,20 +1080,13 @@ module.exports = function(crowi) {
       , newPath = Page.getDeletedPageName(pageData.path)
       ;
     if (Page.isDeletableName(pageData.path)) {
-      return new Promise(function(resolve, reject) {
-        Page.updatePageProperty(pageData, {status: STATUS_DELETED, lastUpdateUser: user})
-        .then(function(data) {
-          pageData.status = STATUS_DELETED;
-
-          // ページ名が /trash/ 以下に存在する場合、おかしなことになる
-          // が、 /trash 以下にページが有るのは、個別に作っていたケースのみ。
-          // 一応しばらく前から uncreatable pages になっているのでこれでいいことにする
-          debug('Deleted the page, and rename it', pageData.path, newPath);
-          return Page.rename(pageData, newPath, user, {createRedirectPage: true});
-        }).then(function(pageData) {
-          resolve(pageData);
-        }).catch(reject);
-      });
+      return Page.rename(pageData, newPath, user, {createRedirectPage: true})
+        .then((updatedPageData) => {
+          return Page.updatePageProperty(updatedPageData, {status: STATUS_DELETED, lastUpdateUser: user});
+        })
+        .then(() => {
+          return pageData;
+        });
     }
     else {
       return Promise.reject('Page is not deletable.');
@@ -1010,18 +1099,15 @@ module.exports = function(crowi) {
       , options = options || {}
       ;
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .generateQueryToListWithDescendants(path, user, options)
+    return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
-        Promise.all(pages.map(function(page) {
+        return Promise.all(pages.map(function(page) {
           return Page.deletePage(page, user, options);
-        }))
-        .then(function(data) {
-          return resolve(pageData);
-        });
+        }));
+      })
+      .then(function(data) {
+        return pageData;
       });
-    });
 
   };
 
@@ -1064,6 +1150,7 @@ module.exports = function(crowi) {
     return new Promise(function(resolve, reject) {
       Page
         .generateQueryToListWithDescendants(path, user, options)
+        .exec()
         .then(function(pages) {
           Promise.all(pages.map(function(page) {
             return Page.revertDeletedPage(page, user, options);
@@ -1084,6 +1171,7 @@ module.exports = function(crowi) {
       , Attachment = crowi.model('Attachment')
       , Comment = crowi.model('Comment')
       , Revision = crowi.model('Revision')
+      , PageGroupRelation = crowi.model('PageGroupRelation')
       , Page = this
       , pageId = pageData._id
       ;
@@ -1103,6 +1191,8 @@ module.exports = function(crowi) {
         return Page.removePageById(pageId);
       }).then(function(done) {
         return Page.removeRedirectOriginPageByPath(pageData.path);
+      }).then(function(done) {
+        return PageGroupRelation.removeAllByPage(pageData);
       }).then(function(done) {
         pageEvent.emit('delete', pageData, user); // update as renamed page
         resolve(pageData);
@@ -1190,25 +1280,22 @@ module.exports = function(crowi) {
       , createRedirectPage = options.createRedirectPage || 0
       , moveUnderTrees     = options.moveUnderTrees || 0;
 
-    return new Promise(function(resolve, reject) {
-      // pageData の path を変更
-      Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})
-      .then(function(data) {
+    return Page.updatePageProperty(pageData, {updatedAt: Date.now(), path: newPagePath, lastUpdateUser: user})  // pageData の path を変更
+      .then((data) => {
         // reivisions の path を変更
         return Revision.updateRevisionListByPath(path, {path: newPagePath}, {});
-      }).then(function(data) {
+      })
+      .then(function(data) {
         pageData.path = newPagePath;
 
         if (createRedirectPage) {
           var body = 'redirect ' + newPagePath;
-          Page.create(path, body, user, {redirectTo: newPagePath}).then(resolve).catch(reject);
-        }
-        else {
-          resolve(data);
+          Page.create(path, body, user, {redirectTo: newPagePath});
         }
         pageEvent.emit('update', pageData, user); // update as renamed page
+
+        return pageData;
       });
-    });
   };
 
   pageSchema.statics.renameRecursively = function(pageData, newPagePathPrefix, user, options) {
@@ -1216,20 +1303,17 @@ module.exports = function(crowi) {
       , path = pageData.path
       , pathRegExp = new RegExp('^' + escapeStringRegexp(path), 'i');
 
-    return new Promise(function(resolve, reject) {
-      Page
-      .generateQueryToListWithDescendants(path, user, options)
+    return Page.generateQueryToListWithDescendants(path, user, options)
       .then(function(pages) {
-        Promise.all(pages.map(function(page) {
-          newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
+        return Promise.all(pages.map(function(page) {
+          const newPagePath = page.path.replace(pathRegExp, newPagePathPrefix);
           return Page.rename(page, newPagePath, user, options);
-        }))
-        .then(function() {
-          pageData.path = newPagePathPrefix;
-          return resolve();
-        });
+        }));
+      })
+      .then(function() {
+        pageData.path = newPagePathPrefix;
+        return pageData;
       });
-    });
   };
 
   pageSchema.statics.getHistories = function() {

+ 34 - 12
lib/routes/page.js

@@ -251,11 +251,15 @@ module.exports = function(crowi, app) {
       pages: [],
       tree: [],
       pageRelatedGroup: null,
+      template: null,
+      localTemplateExists: false,
+      globalTemplateExists: false,
     };
 
     var pageTeamplate = 'customlayout-selector/page';
 
     var isRedirect = false;
+    var originalPath = path;
     Page.findPage(path, req.user, req.query.revision)
     .then(function(page) {
       debug('Page found', page._id, page.path);
@@ -278,6 +282,13 @@ module.exports = function(crowi, app) {
         .then(function(tree) {
           renderVars.tree = tree;
         })
+        .then(function() {
+          return Page.checkIfTemplatesExist(originalPath);
+        })
+        .then(function(templateInfo) {
+          renderVars.localTemplateExists = templateInfo.localTemplateExists;
+          renderVars.globalTemplateExists = templateInfo.globalTemplateExists;
+        })
         .then(() => {
           return PageGroupRelation.findByPage(renderVars.page);
         })
@@ -318,11 +329,14 @@ module.exports = function(crowi, app) {
         });
       }
     })
-    // page not exists
+    // look for templates if page not exists
     .catch(function(err) {
-      debug('Page not found', path);
-      // change template
       pageTeamplate = 'customlayout-selector/not_found';
+
+      return Page.findTemplate(originalPath)
+        .then(template => {
+          renderVars.template = template;
+        });
     })
     // get list pages
     .then(function() {
@@ -429,9 +443,13 @@ module.exports = function(crowi, app) {
   function renderPage(pageData, req, res) {
     // create page
     if (!pageData) {
-      return res.render('customlayout-selector/not_found', {
-        author: {},
-        page: false,
+      Page.findTemplate(getPathFromRequest(req))
+      .then((template) => {
+        return res.render('customlayout-selector/not_found', {
+          author: {},
+          page: false,
+          template: template,
+        });
       });
     }
 
@@ -444,6 +462,8 @@ module.exports = function(crowi, app) {
       page: pageData,
       revision: pageData.revision || {},
       author: pageData.revision.author || false,
+      localTemplateExists: false,
+      globalTemplateExists: false,
     };
     var userPage = isUserPage(pageData.path);
     var userData = null;
@@ -488,6 +508,12 @@ module.exports = function(crowi, app) {
       }
     }).then(function() {
       return interceptorManager.process('beforeRenderPage', req, res, renderVars);
+    }).then(function() {
+      return Page.checkIfTemplatesExist(pageData.path)
+        .then(function(templateInfo) {
+          renderVars.localTemplateExists = templateInfo.localTemplateExists;
+          renderVars.globalTemplateExists = templateInfo.globalTemplateExists;
+        });
     }).then(function() {
       var defaultPageTeamplate = 'customlayout-selector/page';
       if (userData) {
@@ -549,7 +575,6 @@ module.exports = function(crowi, app) {
           return res.redirect(pagePathUtil.encodePagePath(path) + '/');
         }
         else {
-
           var fixed = Page.fixToCreatableName(path);
           if (fixed !== path) {
             debug('fixed page name', fixed);
@@ -626,7 +651,7 @@ module.exports = function(crowi, app) {
         return Page.create(path, body, req.user, { grant, grantUserGroupId });
       }
     }).then(function(data) {
-      // data is a saved page data.
+      // data is a saved page data with revision.
       pageData = data;
       if (!data) {
         throw new Error('Data not found');
@@ -638,10 +663,7 @@ module.exports = function(crowi, app) {
 
           if (crowi.slack) {
             notify.slack.channel.split(',').map(function(chan) {
-              crowi.slack.post(pageData, req.user, chan, updateOrCreate, previousRevision)
-                .catch((err) => {
-                  debug(err);
-                });
+              crowi.slack.post(pageData, req.user, chan, updateOrCreate, previousRevision);
             });
           }
         }

+ 1 - 0
lib/views/admin/customize.html

@@ -74,6 +74,7 @@
             <div class="d-flex">
               {% include 'widget/theme-colorbox.html' with { name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36' } %}
               {% include 'widget/theme-colorbox.html' with { name: 'future', bg: '#16282D', topbar: '#011414', theme: '#04B4AE' } %}
+              {% include 'widget/theme-colorbox.html' with { name: 'blue-night', bg: '#061F2F', topbar: '#27343B', theme: '#0090C8' } %}
             </div>
           </div>
 

+ 1 - 0
lib/views/admin/widget/theme-colorbox.html

@@ -4,6 +4,7 @@
     data-theme="{{ webpack_asset('style-theme-' + name).css }}">
 
   <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+    <title>{{name}}</title>
     <g>
       <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill="{{bg}}"></path>
       <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill="{{topbar}}"></path>

+ 39 - 1
lib/views/modal/create_page.html

@@ -28,7 +28,7 @@
 
         <form class="row form-horizontal m-t-15" id="create-page-under-tree" role="form">
           <fieldset class="col-xs-12">
-            <legend>{{ t('Create under', parentPath(path)) }}</legend>
+            <legend>{{ t('Create under') }}</legend>
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
                 {% if searchConfigured() %}
@@ -44,8 +44,46 @@
           </fieldset>
         </form>
 
+        <div id = "template-form" class="row form-horizontal m-t-15">
+          <fieldset class="col-xs-12">
+            <legend>{{ t('template.modal_label.Create template under', parentPath(path)) }}</legend>
+            <div class="d-flex create-page-input-container">
+              <div class="create-page-input-row d-flex align-items-center">
+                <select id="template-type" class="page-name-input form-control">
+                  <option value="" disabled selected>{{ t('template.option_label.select') }}</option>
+                  <option value="local">{{ t('template.local.label') }}(__template) - {{ t('template.local.desc') }}</option>
+                  <option value="global">{{ t('template.global.label') }}(_template) - {{ t('template.global.desc') }}</option>
+                </select>
+              </div>
+              <div class="create-page-button-container">
+                  <a id="link-to-template" href="{{ page.path || path }}"><button class="fcbtn btn btn-outline btn-rounded btn-primary btn-1b"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button></a>
+              </div>
+            </div>
+          </fieldset>
+        </div>
+
       </div><!-- /.modal-body -->
 
     </div><!-- /.modal-content -->
   </div><!-- /.modal-dialog -->
 </div><!-- /.modal -->
+<script>
+  if($("#create-page")) {
+    let pagePath = $("#link-to-template").attr("href");
+
+    if (pagePath.endsWith("/")) {
+      pagePath = pagePath.slice(0, -1);
+    };
+
+    $("#template-type").on("change", () => {
+      if ($("#template-type").val() === "local") {
+        href = pagePath + "/__template#edit-form";
+        $("#link-to-template").attr("href", href);
+      }
+      else if ($("#template-type").val() === "global") {
+        href = pagePath + "/_template#edit-form";
+        $("#link-to-template").attr("href", href);
+      };
+    });
+  };
+</script>

+ 50 - 0
lib/views/modal/create_template.html

@@ -0,0 +1,50 @@
+<div class="modal" id="create-template">
+  <div class="modal-dialog">
+    <div class="modal-content">
+
+      <div class="modal-header bg-primary">
+        <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+        <div class="modal-title">{{ t('template.modal_label.Create/Edit Template Page') }}</div>
+      </div>
+      <div class="modal-body">
+        <div class="form-group">
+          <label class="mb-4">{{ t('template.modal_label.Create template under', page.path ) }}</label>
+          <div class="row">
+            <div class="col-sm-6">
+              <div class="panel panel-default">
+                <div class="panel-heading">{{ t('template.local.label') }}</div>
+                <div class="panel-body">
+                  <p class="text-center"><code>__template</code></p>
+                  <p class="help-block text-center"><small>{{ t('template.local.desc') }}</small></p>
+                </div>
+                <div class="panel-footer text-center">
+                  {% if localTemplateExists %}
+                  <a href="{{ page.path }}/__template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Edit') }}</button></a>
+                  {% else %}
+                  <a href="{{ page.path }}/__template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Create') }}</button></a>
+                  {% endif %}
+                </div>
+              </div>
+            </div>
+            <div class="col-sm-6">
+              <div class="panel panel-default">
+                <div class="panel-heading">{{ t('template.global.label') }}</div>
+                <div class="panel-body">
+                  <p class="text-center"><code>_template</code></p>
+                  <p class="help-block text-center"><small>{{ t('template.global.desc') }}</small></p>
+                </div>
+                <div class="panel-footer text-center">
+                  {% if globalTemplateExists %}
+                  <a href="{{ page.path }}/_template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Edit') }}</button></a>
+                  {% else %}
+                  <a href="{{ page.path }}/_template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Create') }}</button></a>
+                  {% endif %}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div><!-- /.modal-content -->
+  </div><!-- /.modal-dialog -->
+</div><!-- /.modal -->

+ 8 - 0
lib/views/widget/not_found_content.html

@@ -17,8 +17,16 @@
 
   <div class="tab-content">
     {% if isEnabledAttachTitleHeader() %}
+    {% if template %}
+    <script type="text/template" id="raw-text-original"># {{ path|path2name }}&NewLine;{{ template }}</script>
+    {% else %}
     <script type="text/template" id="raw-text-original"># {{ path|path2name }}</script>
     {% endif %}
+    {% else %}
+    {% if template %}
+    <script type="text/template" id="raw-text-original">{{ template }}</script>
+    {% endif %}
+    {% endif %}
     {# list view #}
     <div class="p-t-10 active tab-pane page-list-container" id="revision-body">
       {% if pages.length == 0 %}

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

@@ -1,5 +1,6 @@
 {% include '../modal/rename.html' %}
 {% include '../modal/delete.html' %}
+{% include '../modal/create_template.html' %}
 {% include '../modal/duplicate.html' %}
 {% include '../modal/put_back.html' %}
 {% include '../modal/page_name_warning.html' %}

+ 4 - 3
lib/views/widget/page_tabs.html

@@ -29,11 +29,12 @@
       </a>
       <ul class="dropdown-menu">
         <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
-        <li class="divider"></li>
         <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
+        <li class="divider"></li>
+        <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>
         {% if isDeletablePage() %}
         <li class="divider"></li>
-        <li class=""><a href="#" data-target="#deletePage" data-toggle="modal"><i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}</a></li>
+        <li><a href="#" data-target="#deletePage" data-toggle="modal"><i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}</a></li>
         {% endif %}
       </ul>
     </li>
@@ -51,7 +52,7 @@
 
   <li class="nav-main-right-tab pull-right">
     <a href="#revision-history" data-toggle="tab">
-      <i class="icon-layers"></i><span class="hidden-xs">  History</span>
+      <i class="icon-layers"></i><span class="hidden-xs"> {{ t('History') }}</span>
     </a>
   </li>
   {% if not isPortal %}

+ 5 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.1-RC",
+  "version": "3.1.2-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -39,6 +39,7 @@
     "prebuild:prod": "npm run plugin:def",
     "prestart": "npm run build:prod",
     "postserver:prod:container": "echo ---------------------------------------- && echo [WARNING] && echo   'server:prod:container' is deprecated. && echo   Please use 'sever:prod' && echo ----------------------------------------",
+    "server:debug": "env-cmd config/env.dev.js node-dev --inspect app.js",
     "server:dev": "env-cmd config/env.dev.js node-dev --respawn app.js",
     "server:prod:container": "npm run server:prod",
     "server:prod:ci": "npm run server:prod -- --ci",
@@ -104,11 +105,11 @@
     "string-width": "^2.1.1",
     "swig-templates": "^2.0.2",
     "uglifycss": "^0.0.29",
-    "xss": "^0.3.7"
+    "xss": "^1.0.3"
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.0.16",
-    "assets-webpack-plugin": "~3.5.1",
+    "assets-webpack-plugin": "^3.6.0",
     "autoprefixer": "^8.2.0",
     "babel-core": "^6.25.0",
     "babel-loader": "^7.1.1",
@@ -162,7 +163,7 @@
     "postcss-loader": "^2.1.3",
     "react-bootstrap": "^0.32.1",
     "react-bootstrap-typeahead": "^3.0.3",
-    "react-clipboard.js": "^1.1.3",
+    "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.0.0",
     "react-dropzone": "^4.2.7",
     "reveal.js": "^3.5.0",

+ 54 - 0
resource/styles/agile-admin/inverse/colors/blue-night.scss

@@ -0,0 +1,54 @@
+@import '../variables';
+
+$basecolor:#061f2f;
+$themecolor:#0090c8;
+
+$linkcolor: #97d1f0;
+
+$topbar:#27343b;
+$sidebar:#061f2f;
+$bodycolor:#061f2f;
+$headingtext: #fff;
+$bodytext: #d3d4d4;
+$linktext: $linkcolor;
+$linktext-hover: rgba($linktext, 0.8);
+$sidebar-text:#d3d4d4;
+$dark-themecolor:#4f5467;
+
+$primary: $themecolor;
+
+$logo-mark-fill: lighten(desaturate($topbar, 10%), 15%);
+$wikilinktext: $linkcolor;
+$wikilinktext-hover: rgba($linktext, 0.8);
+
+$dark: darken($bodytext, 5%);
+$border: #fff;
+$navbar-border: lighten($basecolor, 25%);
+$active-navbar-border: darken($navbar-border, 3%);
+$btn-default-bgcolor: darken($basecolor, 10%);
+$inline-code-color: #c1f1f0;
+$inline-code-bg: #0a121b;
+
+@import 'apply-colors';
+@import 'apply-colors-dark';
+
+.wiki {
+  .highlighted {
+   background-color: lighten($themecolor, 20%);
+  }
+}
+
+.panel {
+  &, &.panel-white, &.panel-default {
+    border-color: $bodytext;
+    .panel-heading {
+      color: $basecolor;
+      background-color: 1px solid $bodytext;
+    }
+  }
+}
+
+:not(.hljs) > pre:not(.hljs) {
+  color: $bodytext;
+  background-color: $inline-code-bg;
+}

+ 2 - 2
resource/styles/agile-admin/inverse/colors/future.scss

@@ -7,8 +7,8 @@ $topbar:#011414;
 $sidebar:#fff;
 $bodycolor:$basecolor;
 $headingtext: #D9A364;
-$bodytext: #97D7CF;
-$linktext: darken($themecolor, 15%);
+$bodytext: lighten($basecolor, 35%);
+$linktext: lighten($basecolor, 45%);
 $linktext-hover: lighten($linktext, 80%);
 $sidebar-text:rgb(65, 133, 124);
 $dark-themecolor:#4F5467;

+ 1 - 1
resource/styles/scss/_create-page.scss

@@ -11,7 +11,7 @@
       margin-bottom: 10px;
     }
 
-    form {
+    form, #template-form {
 
       // layout
       .create-page-input-container {

+ 8 - 0
resource/styles/scss/theme/blue-night.scss

@@ -0,0 +1,8 @@
+// import colors
+@import '../../agile-admin/inverse/colors/blue-night';
+
+// apply agile-admin theme
+@import '../../agile-admin/inverse/style';
+
+// override
+@import 'override-agileadmin';

+ 23 - 81
yarn.lock

@@ -357,14 +357,14 @@ assertion-error@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
 
-assets-webpack-plugin@~3.5.1:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/assets-webpack-plugin/-/assets-webpack-plugin-3.5.1.tgz#931ce0d66d42e88ed5e7f18d65522943c57a387d"
+assets-webpack-plugin@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/assets-webpack-plugin/-/assets-webpack-plugin-3.6.0.tgz#f0046def66acc728ef62ea9b8abc3716e1957adb"
   dependencies:
-    camelcase "^1.2.1"
+    camelcase "^5.0.0"
     escape-string-regexp "^1.0.3"
-    lodash.assign "^3.2.0"
-    lodash.merge "^3.3.2"
+    lodash.assign "^4.2.0"
+    lodash.merge "^4.6.1"
     mkdirp "^0.5.1"
 
 async-each-series@0.1.1:
@@ -1423,6 +1423,10 @@ camelcase@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
 
+camelcase@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.0.0.tgz#03295527d58bd3cd4aa75363f35b2e8d97be2f42"
+
 caniuse-api@^1.5.2:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c"
@@ -1597,9 +1601,9 @@ cli@~1.0.1:
     exit "0.1.2"
     glob "^7.1.1"
 
-clipboard@^1.6.1:
-  version "1.7.1"
-  resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b"
+clipboard@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.1.tgz#a12481e1c13d8a50f5f036b0560fe5d16d74e46a"
   dependencies:
     good-listener "^1.2.2"
     select "^1.1.2"
@@ -4192,30 +4196,10 @@ lodash._bindcallback@^3.0.0:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e"
 
-lodash._createassigner@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/lodash._createassigner/-/lodash._createassigner-3.1.1.tgz#838a5bae2fdaca63ac22dee8e19fa4e6d6970b11"
-  dependencies:
-    lodash._bindcallback "^3.0.0"
-    lodash._isiterateecall "^3.0.0"
-    lodash.restparam "^3.0.0"
-
 lodash._getnative@^3.0.0:
   version "3.9.1"
   resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5"
 
-lodash._isiterateecall@^3.0.0:
-  version "3.0.9"
-  resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
-
-lodash.assign@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-3.2.0.tgz#3ce9f0234b4b2223e296b8fa0ac1fee8ebca64fa"
-  dependencies:
-    lodash._baseassign "^3.0.0"
-    lodash._createassigner "^3.0.0"
-    lodash.keys "^3.0.0"
-
 lodash.assign@^4.0.3, lodash.assign@^4.0.6, lodash.assign@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7"
@@ -4259,22 +4243,10 @@ lodash.isfinite@^3.3.2:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/lodash.isfinite/-/lodash.isfinite-3.3.2.tgz#fb89b65a9a80281833f0b7478b3a5104f898ebb3"
 
-lodash.isplainobject@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-3.2.0.tgz#9a8238ae16b200432960cd7346512d0123fbf4c5"
-  dependencies:
-    lodash._basefor "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.keysin "^3.0.0"
-
 lodash.isstring@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
 
-lodash.istypedarray@^3.0.0:
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz#c9a477498607501d8e8494d283b87c39281cef62"
-
 lodash.keys@^3.0.0:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
@@ -4283,41 +4255,18 @@ lodash.keys@^3.0.0:
     lodash.isarguments "^3.0.0"
     lodash.isarray "^3.0.0"
 
-lodash.keysin@^3.0.0:
-  version "3.0.8"
-  resolved "https://registry.yarnpkg.com/lodash.keysin/-/lodash.keysin-3.0.8.tgz#22c4493ebbedb1427962a54b445b2c8a767fb47f"
-  dependencies:
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-
 lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
-lodash.merge@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-3.3.2.tgz#0d90d93ed637b1878437bb3e21601260d7afe994"
-  dependencies:
-    lodash._arraycopy "^3.0.0"
-    lodash._arrayeach "^3.0.0"
-    lodash._createassigner "^3.0.0"
-    lodash._getnative "^3.0.0"
-    lodash.isarguments "^3.0.0"
-    lodash.isarray "^3.0.0"
-    lodash.isplainobject "^3.0.0"
-    lodash.istypedarray "^3.0.0"
-    lodash.keys "^3.0.0"
-    lodash.keysin "^3.0.0"
-    lodash.toplainobject "^3.0.0"
+lodash.merge@^4.6.1:
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54"
 
 lodash.mergewith@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.0.tgz#150cf0a16791f5903b8891eab154609274bdea55"
 
-lodash.restparam@^3.0.0:
-  version "3.6.1"
-  resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805"
-
 lodash.set@^4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@@ -4330,13 +4279,6 @@ lodash.toarray@^4.4.0:
   version "4.4.0"
   resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
 
-lodash.toplainobject@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash.toplainobject/-/lodash.toplainobject-3.0.0.tgz#28790ad942d293d78aa663a07ecf7f52ca04198d"
-  dependencies:
-    lodash._basecopy "^3.0.0"
-    lodash.keysin "^3.0.0"
-
 lodash.uniq@^4.5.0:
   version "4.5.0"
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@@ -5986,11 +5928,11 @@ react-bootstrap@^0.32.1:
     uncontrollable "^4.1.0"
     warning "^3.0.0"
 
-react-clipboard.js@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-1.1.3.tgz#86feeb49364553ecd15aea91c75aa142532a60e0"
+react-clipboard.js@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/react-clipboard.js/-/react-clipboard.js-2.0.0.tgz#8ab3c49093e73ea146eb3bbc054889b7a60bf2b4"
   dependencies:
-    clipboard "^1.6.1"
+    clipboard "^2.0.0"
     prop-types "^15.5.0"
 
 react-codemirror2@^5.0.0:
@@ -7659,9 +7601,9 @@ xmlhttprequest-ssl@~1.5.4:
   version "1.5.4"
   resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.4.tgz#04f560915724b389088715cc0ed7813e9677bf57"
 
-xss@^0.3.7:
-  version "0.3.8"
-  resolved "https://registry.yarnpkg.com/xss/-/xss-0.3.8.tgz#d0cbe23bde490bc98c139f08de3899165a68af0e"
+xss@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/xss/-/xss-1.0.3.tgz#d04bd2558fd6c29c46113824d5e8b2a910054e23"
   dependencies:
     commander "^2.9.0"
     cssfilter "0.0.10"