فهرست منبع

Merge branch 'master' into support/gw715-remove-environment-form-readme

TsuyoshiSuzukief 6 سال پیش
والد
کامیت
e8dc5b0626
59فایلهای تغییر یافته به همراه2750 افزوده شده و 1046 حذف شده
  1. 1 1
      .github/workflows/build-rc.yml
  2. 2 3
      .github/workflows/build.yml
  3. 109 33
      .github/workflows/ci.yml
  4. 20 12
      .github/workflows/release.yml
  5. 14 2
      CHANGES.md
  6. 0 6
      bin/github-actions/bump-version.sh
  7. 6 0
      bin/github-actions/update-readme.sh
  8. 1 1
      config/migrate.js
  9. 9 0
      config/webpack.common.js
  10. 0 49
      config/webpack.dev.dll.js
  11. 0 7
      config/webpack.dev.js
  12. 7 5
      docker/README.md
  13. 0 3
      docker/bin/remove-except-artifacts.sh
  14. 4 4
      package.json
  15. 35 2
      resource/locales/en-US/translation.json
  16. 36 3
      resource/locales/ja/translation.json
  17. 48 95
      src/client/js/app.jsx
  18. 28 0
      src/client/js/components/Admin/Common/AdminUpdateButtonRow.jsx
  19. 4 5
      src/client/js/components/Admin/CustomCssEditor.jsx
  20. 4 5
      src/client/js/components/Admin/CustomHeaderEditor.jsx
  21. 4 5
      src/client/js/components/Admin/CustomScriptEditor.jsx
  22. 55 21
      src/client/js/components/Admin/Customize/Customize.jsx
  23. 38 0
      src/client/js/components/Admin/Customize/CustomizeBehaviorOption.jsx
  24. 99 0
      src/client/js/components/Admin/Customize/CustomizeBehaviorSetting.jsx
  25. 91 0
      src/client/js/components/Admin/Customize/CustomizeCssSetting.jsx
  26. 38 0
      src/client/js/components/Admin/Customize/CustomizeFunctionOption.jsx
  27. 143 0
      src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx
  28. 100 0
      src/client/js/components/Admin/Customize/CustomizeHeaderSetting.jsx
  29. 143 0
      src/client/js/components/Admin/Customize/CustomizeHighlightSetting.jsx
  30. 42 0
      src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx
  31. 81 0
      src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx
  32. 79 0
      src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx
  33. 122 0
      src/client/js/components/Admin/Customize/CustomizeScriptSetting.jsx
  34. 91 0
      src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx
  35. 79 0
      src/client/js/components/Admin/Customize/CustomizeTitle.jsx
  36. 45 0
      src/client/js/components/Admin/Customize/ThemeColorBox.jsx
  37. 115 0
      src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx
  38. 8 0
      src/client/js/components/PageEditor/AbstractEditor.jsx
  39. 23 16
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  40. 42 19
      src/client/js/components/PageEditor/MarkdownTableInterceptor.js
  41. 4 12
      src/client/js/components/PageEditor/MarkdownTableUtil.js
  42. 7 0
      src/client/js/components/PageEditor/TextAreaEditor.jsx
  43. 302 0
      src/client/js/services/AdminCustomizeContainer.js
  44. 4 0
      src/client/styles/scss/_admin.scss
  45. 1 1
      src/client/styles/scss/_on-edit.scss
  46. 0 9
      src/server/form/index.js
  47. 7 0
      src/server/models/config.js
  48. 1 15
      src/server/routes/admin.js
  49. 493 0
      src/server/routes/apiv3/customize-setting.js
  50. 2 0
      src/server/routes/apiv3/index.js
  51. 47 56
      src/server/routes/apiv3/markdown-setting.js
  52. 1 2
      src/server/routes/comment.js
  53. 1 10
      src/server/routes/index.js
  54. 20 634
      src/server/views/admin/customize.html
  55. 0 1
      src/server/views/page_presentation.html
  56. 0 1
      src/server/views/widget/headers/scripts-for-dev.html
  57. 1 0
      src/server/views/widget/headers/styles-for-app.html
  58. 37 0
      src/test/config/migrate.test.js
  59. 56 8
      yarn.lock

+ 1 - 1
.github/workflows/build-rc.yml

@@ -3,7 +3,7 @@ name: Release Docker Images for RC
 on:
 on:
   push:
   push:
     branches:
     branches:
-      - rc/*
+      - rc/**
 
 
 jobs:
 jobs:
 
 

+ 2 - 3
.github/workflows/build.yml

@@ -1,9 +1,8 @@
 name: Release Docker Images
 name: Release Docker Images
 
 
 on:
 on:
-  push:
-    tags:
-      - v3.*
+  release:
+    types: [published]
 
 
 jobs:
 jobs:
 
 

+ 109 - 33
.github/workflows/ci.yml

@@ -1,15 +1,19 @@
 name: Node CI
 name: Node CI
 
 
-on: [push]
+on:
+  push:
+    branches-ignore:
+      - release/**
+      - tmp/**
 
 
 jobs:
 jobs:
 
 
-  resolve-dependencies:
+  lint:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v1
     - uses: actions/checkout@v1
@@ -18,34 +22,52 @@ jobs:
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
-      id: cache
+      id: cache-dependencies
       uses: actions/cache@v1
       uses: actions/cache@v1
       with:
       with:
         path: node_modules
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
     - name: Install dependencies
     - name: Install dependencies
-      if: steps.cache.outputs.cache-hit != 'true'
-      run: |
-        yarn
-    - name: Install plugins
-      if: steps.cache.outputs.cache-hit != 'true'
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
-        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
-        yarn add -D react-images react-motion
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images react-motion
     - name: Print dependencies
     - name: Print dependencies
       run: |
       run: |
         echo -n "node " && node -v
         echo -n "node " && node -v
         echo -n "npm " && npm -v
         echo -n "npm " && npm -v
         yarn list --depth=0
         yarn list --depth=0
+    - name: yarn lint
+      run: |
+        yarn lint
+
+    - name: Slack Notification
+      uses: homoluctus/slatify@master
+      if: failure()
+      with:
+        type: ${{ job.status }}
+        job_name: '*test (${{ matrix.node-version }})*'
+        channel: '#ci'
+        url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
 
 
   test:
   test:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    needs: resolve-dependencies
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v1
     - uses: actions/checkout@v1
@@ -54,13 +76,32 @@ jobs:
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
+      id: cache-dependencies
       uses: actions/cache@v1
       uses: actions/cache@v1
       with:
       with:
         path: node_modules
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
-    - name: yarn lint
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
       run: |
       run: |
-        yarn lint
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
     - name: Launch MongoDB
     - name: Launch MongoDB
       uses: wbari/start-mongoDB@v0.2
       uses: wbari/start-mongoDB@v0.2
       with:
       with:
@@ -80,13 +121,13 @@ jobs:
         channel: '#ci'
         channel: '#ci'
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
+
   build-dev:
   build-dev:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    needs: resolve-dependencies
 
 
     strategy:
     strategy:
       matrix:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x]
 
 
     steps:
     steps:
     - uses: actions/checkout@v1
     - uses: actions/checkout@v1
@@ -95,10 +136,32 @@ jobs:
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
     - name: Cache/Restore node_modules
     - name: Cache/Restore node_modules
+      id: cache-dependencies
       uses: actions/cache@v1
       uses: actions/cache@v1
       with:
       with:
         path: node_modules
         path: node_modules
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
         key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+    - name: Get yarn cache dir
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      uses: actions/cache@v1
+      with:
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      if: steps.cache-dependencies.outputs.cache-hit != 'true'
+      run: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs react-images react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
     - name: yarn build:dev
     - name: yarn build:dev
       run: |
       run: |
         yarn build:dev
         yarn build:dev
@@ -115,7 +178,6 @@ jobs:
 
 
   build-prod:
   build-prod:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
-    needs: resolve-dependencies
 
 
     strategy:
     strategy:
       matrix:
       matrix:
@@ -127,33 +189,47 @@ jobs:
       uses: actions/setup-node@v1
       uses: actions/setup-node@v1
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
-    - name: Cache/Restore node_modules
+    - name: Get yarn cache dir
+      id: cache-yarn
+      run: echo "::set-output name=dir::$(yarn cache dir)"
+    - name: Cache/Restore yarn cache
       uses: actions/cache@v1
       uses: actions/cache@v1
       with:
       with:
-        path: node_modules
-        key: ${{ runner.OS }}-node_modules-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        path: ${{ steps.cache-yarn.outputs.dir }}
+        key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
+        restore-keys: |
+          ${{ runner.os }}-yarn-${{ matrix.node-version }}-
+    - name: Install dependencies
+      run: |
+        yarn add growi-plugin-lsx growi-plugin-pukiwiki-like-linker growi-plugin-attachment-refs
+        yarn add -D react-images react-motion
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --depth=0
+    - name: yarn build:prod
+      if: steps.cache-public.outputs.cache-hit != 'true'
+      run: |
+        yarn build:prod
+    - name: yarn install --production
+      run: |
+        yarn install --production
+    - name: Print dependencies
+      run: |
+        echo -n "node " && node -v
+        echo -n "npm " && npm -v
+        yarn list --production --depth=0
     - name: Launch MongoDB
     - name: Launch MongoDB
       uses: wbari/start-mongoDB@v0.2
       uses: wbari/start-mongoDB@v0.2
       with:
       with:
         mongoDBVersion: 3.6
         mongoDBVersion: 3.6
-    - name: yarn build:prod:analyze
-      run: |
-        yarn build:prod:analyze
-    - name: Shrink dependencies for production
-      run: |
-        yarn install --production
     - name: yarn server:prod:ci
     - name: yarn server:prod:ci
       run: |
       run: |
         yarn server:prod:ci
         yarn server:prod:ci
       env:
       env:
         MONGO_URI: mongodb://localhost:27017/growi
         MONGO_URI: mongodb://localhost:27017/growi
 
 
-    - name: Upload reports
-      uses: actions/upload-artifact@v1
-      if: success()
-      with:
-        name: report
-        path: report
     - name: Slack Notification
     - name: Slack Notification
       uses: homoluctus/slatify@master
       uses: homoluctus/slatify@master
       if: failure()
       if: failure()

+ 20 - 12
.github/workflows/release.yml

@@ -3,10 +3,10 @@ name: GitHub Release
 on:
 on:
   push:
   push:
     branches:
     branches:
-      - release/current
+      - release/**
 
 
 jobs:
 jobs:
-  build:
+  release:
 
 
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
@@ -19,19 +19,27 @@ jobs:
       run: |
       run: |
         git config --local user.name "GitHub Action"
         git config --local user.name "GitHub Action"
         git config --local user.email "info@weseek.co.jp"
         git config --local user.email "info@weseek.co.jp"
+        git remote set-url origin "https://$GITHUB_ACTOR:${{ secrets.GITHUB_TOKEN }}@github.com/$GITHUB_REPOSITORY"
 
 
     - name: Bump version
     - name: Bump version
       run: |
       run: |
-        npm version patch
-        sh ./bin/github-actions/bump-version.sh
+        npm --no-git-tag-version version patch
+        export RELEASE_VERSION=`npm run version --silent`
+        sh ./bin/github-actions/update-readme.sh
+        echo ::set-env name=RELEASE_VERSION::$RELEASE_VERSION
 
 
-    - name: Commit
+    - name: Checkout, Commit, Tag and Push
       run: |
       run: |
-        git commit -am "Release $RELEASE_VERSION"
-
-    - name: Push tag
-      uses: ad-m/github-push-action@master
+        TMP_RELEASE_BRANCH=tmp/release-${{ env.RELEASE_VERSION }}
+        git checkout -B $TMP_RELEASE_BRANCH
+        git commit -am "Release v${{ env.RELEASE_VERSION }}"
+        git tag -a v${{ env.RELEASE_VERSION }} -m "v${{ env.RELEASE_VERSION }}"
+        git push --follow-tags origin $TMP_RELEASE_BRANCH
+        git push --delete origin ${{ env.TMP_RELEASE_BRANCH }}
+
+    - name: Upload release notes
+      uses: Roang-zero1/github-create-release-action@master
       with:
       with:
-        branch: null
-        github_token: ${{ secrets. GITHUB_TOKEN }}
-
+        created_tag: v${{ env.RELEASE_VERSION }}
+      env:
+        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 14 - 2
CHANGES.md

@@ -1,10 +1,18 @@
 # CHANGES
 # CHANGES
 
 
-## 3.6.0-RC
+## 3.6.2-RC
+
+* Improvement: Reactify admin pages (Customize)
+* Improvement: Ensure not to consider `[text|site](https://example.com]` as a row in the table
+* Improvement: Enter key behavior in markdown table
+* Fix: Emoji Autocomplete window does not float correctly
+    * Introduced by 3.5.0
+
+## 3.6.1
 
 
 ### BREAKING CHANGES
 ### BREAKING CHANGES
 
 
-* GROWI v3.6.0 no longer support Node.js v8.x
+* GROWI v3.6.x no longer support Node.js v8.x
 * The name of database that is storing migrations meta data has been changed
 * The name of database that is storing migrations meta data has been changed
     * This affects **only when `MONGO_URI` has parameters**
     * This affects **only when `MONGO_URI` has parameters**
     * v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
     * v3.5.x or above has a bug ([#1361](https://github.com/weseek/growi/issues/1361))
@@ -15,14 +23,18 @@ Upgrading Guide: https://docs.growi.org/en/admin-guide/upgrading/36x.html
 
 
 * Improvement: Drop unnecessary MongoDB collection indexes
 * Improvement: Drop unnecessary MongoDB collection indexes
 * Improvement: Accessibility of Antarctic theme
 * Improvement: Accessibility of Antarctic theme
+* Improvement: Reactify admin pages (Markdown Settings)
 * Fix: Appending tag is failed by wrong index of PageTagRelation
 * Fix: Appending tag is failed by wrong index of PageTagRelation
     * Introduced by 3.5.20
     * Introduced by 3.5.20
 * Fix: Pages without heading slash is invalid but creatable
 * Fix: Pages without heading slash is invalid but creatable
 * Fix: Connect to Elasticsearch with `httpAuth` param
 * Fix: Connect to Elasticsearch with `httpAuth` param
 * Support: Support Node.js v12
 * Support: Support Node.js v12
+* Support: Optimize build in dev with hard-source-webpack-plugin
 * Support: Upgrade libs
 * Support: Upgrade libs
     * growi-commons
     * growi-commons
 
 
+## 3.6.0 (Missing number)
+
 ## 3.5.25
 ## 3.5.25
 
 
 * Improvement: Disable ESC key to close Handsontable Modal
 * Improvement: Disable ESC key to close Handsontable Modal

+ 0 - 6
bin/github-actions/bump-version.sh

@@ -1,6 +0,0 @@
-#!/bin/sh
-
-cd docker
-
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.5\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.5-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/nocdn\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 6 - 0
bin/github-actions/update-readme.sh

@@ -0,0 +1,6 @@
+#!/bin/sh
+
+cd docker
+
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.6\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}\2\3${RELEASE_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`3\.6-nocdn\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/docker\/Dockerfile.\+\)$/\1${RELEASE_VERSION}-nocdn\2\3${RELEASE_VERSION}\4/" README.md

+ 1 - 1
config/migrate.js

@@ -17,7 +17,7 @@ const mongoUri = getMongoUri();
 const url = new URL(mongoUri);
 const url = new URL(mongoUri);
 
 
 const mongodb = {
 const mongodb = {
-  url: `${url.protocol}//${url.host}`,
+  url: mongoUri,
   databaseName: url.pathname.substring(1), // omit heading slash
   databaseName: url.pathname.substring(1), // omit heading slash
   options: {
   options: {
     useNewUrlParser: true, // removes a deprecation warning when connecting
     useNewUrlParser: true, // removes a deprecation warning when connecting

+ 9 - 0
config/webpack.common.js

@@ -8,6 +8,7 @@ const webpack = require('webpack');
  */
  */
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const WebpackAssetsManifest = require('webpack-assets-manifest');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
 const LodashModuleReplacementPlugin = require('lodash-webpack-plugin');
+const HardSourceWebpackPlugin = require('hard-source-webpack-plugin');
 const helpers = require('../src/lib/util/helpers');
 const helpers = require('../src/lib/util/helpers');
 
 
 /*
 /*
@@ -127,6 +128,14 @@ module.exports = (options) => {
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
       new webpack.IgnorePlugin(/^\.\/lib\/deflate\.js/, /markdown-it-plantuml/),
       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
       new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/),
 
 
+      new HardSourceWebpackPlugin(),
+      new HardSourceWebpackPlugin.ExcludeModulePlugin([
+        {
+          // see https://github.com/mzgoddard/hard-source-webpack-plugin/blob/master/README.md#excludemoduleplugin
+          test: /mini-css-extract-plugin[\\/]dist[\\/]loader/,
+        },
+      ]),
+
       new LodashModuleReplacementPlugin({
       new LodashModuleReplacementPlugin({
         flattening: true,
         flattening: true,
       }),
       }),

+ 0 - 49
config/webpack.dev.dll.js

@@ -1,49 +0,0 @@
-/**
- * @author: Yuki Takei <yuki@weseek.co.jp>
- */
-const webpack = require('webpack');
-const helpers = require('../src/lib/util/helpers');
-
-
-module.exports = {
-  mode: 'development',
-  entry: {
-    dlls: [
-      // Libraries
-      'axios',
-      'browser-bunyan', 'bunyan-format',
-      'codemirror', 'react-codemirror2',
-      'date-fns',
-      'diff2html',
-      'debug',
-      'entities',
-      'growi-commons',
-      'i18next', 'i18next-browser-languagedetector',
-      'jquery-slimscroll',
-      'lodash', 'pako',
-      'markdown-it', 'csv-to-markdown-table',
-      'react', 'react-dom',
-      'react-bootstrap', 'react-bootstrap-typeahead',
-      'react-i18next', 'react-dropzone', 'react-hotkeys', 'react-copy-to-clipboard', 'react-waypoint',
-      'socket.io-client',
-      'toastr',
-      'unstated',
-      'xss',
-    ],
-  },
-  output: {
-    path: helpers.root('public/dll'),
-    filename: 'dll.js',
-    library: 'growi_dlls',
-  },
-  resolve: {
-    extensions: ['.js', '.json'],
-    modules: [helpers.root('src'), helpers.root('node_modules')],
-  },
-  plugins: [
-    new webpack.DllPlugin({
-      path: helpers.root('public/dll/manifest.json'),
-      name: 'growi_dlls',
-    }),
-  ],
-};

+ 0 - 7
config/webpack.dev.js

@@ -2,8 +2,6 @@
  * @author: Yuki Takei <yuki@weseek.co.jp>
  * @author: Yuki Takei <yuki@weseek.co.jp>
  */
  */
 
 
-const webpack = require('webpack');
-
 /*
 /*
  * Webpack Plugins
  * Webpack Plugins
  */
  */
@@ -59,11 +57,6 @@ module.exports = require('./webpack.common')({
       filename: '[name].bundle.css',
       filename: '[name].bundle.css',
     }),
     }),
 
 
-    new webpack.DllReferencePlugin({
-      context: helpers.root(),
-      manifest: require(helpers.root('public/dll', 'manifest.json')),
-    }),
-
     new BundleAnalyzerPlugin({
     new BundleAnalyzerPlugin({
       analyzerMode: ANALYZE ? 'server' : 'disabled',
       analyzerMode: ANALYZE ? 'server' : 'disabled',
     }),
     }),

+ 7 - 5
docker/README.md

@@ -1,8 +1,8 @@
 
 
-growi-docker
-===========
+GROWI Official docker image
+========================
 
 
-[![wercker status](https://app.wercker.com/status/592aa5cebb59c67c4c266ce1f33b6a7e/s/ "wercker status")](https://app.wercker.com/project/byKey/592aa5cebb59c67c4c266ce1f33b6a7e) [![docker-build-automated](https://img.shields.io/docker/automated/jrottenberg/ffmpeg.svg)](https://hub.docker.com/r/weseek/growi/) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
+[![Actions Status](https://github.com/weseek/growi/workflows/Release%20Docker%20Images/badge.svg)](https://github.com/weseek/growi/actions) [![docker-pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/) [![](https://images.microbadger.com/badges/image/weseek/growi.svg)](https://microbadger.com/images/weseek/growi)
 
 
 ![GROWI-x-docker](https://user-images.githubusercontent.com/1638767/38307565-105956e2-384f-11e8-8534-b1128522d68d.png)
 ![GROWI-x-docker](https://user-images.githubusercontent.com/1638767/38307565-105956e2-384f-11e8-8534-b1128522d68d.png)
 
 
@@ -10,8 +10,10 @@ growi-docker
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`3.5.13`, `3.5`, `3`, `latest` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.13/Dockerfile)
-* [`3.5.13-nocdn`, `3.5-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.13/nocdn/Dockerfile)
+* [`3.6.0`, `3.6`, `3`, `latest`, (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.0/docker/Dockerfile)
+* [`3.6.0-nocdn`, `3.6-nocdn`, `3-nocdn`, `latest-nocdn`, (Dockerfile)](https://github.com/weseek/growi/blob/v3.6.0/docker/Dockerfile)
+* [`3.5.25`, `3.5`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.25/Dockerfile)
+* [`3.5.25-nocdn`, `3.5-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.5.25/nocdn/Dockerfile)
 * [`3.4.7`, `3.4`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/Dockerfile)
 * [`3.4.7`, `3.4`, `3`, (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/Dockerfile)
 * [`3.4.7-nocdn`, `3.4-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/nocdn/Dockerfile)
 * [`3.4.7-nocdn`, `3.4-nocdn`, `3-nocdn` (Dockerfile)](https://github.com/weseek/growi-docker/blob/v3.4.7/nocdn/Dockerfile)
 
 

+ 0 - 3
docker/bin/remove-except-artifacts.sh

@@ -3,8 +3,5 @@
 set -e
 set -e
 
 
 rm -rf \
 rm -rf \
-  ${appDir}/bin \
   ${appDir}/docker \
   ${appDir}/docker \
   ${appDir}/node_modules \
   ${appDir}/node_modules \
-  ${appDir}/src/client \
-  ${appDir}/babel.config.js \

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "3.6.0-RC",
+  "version": "3.6.2-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -23,9 +23,8 @@
     "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/**/*.js",
     "build:apiv3:jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js src/server/**/*.js",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app:watch": "npm run build:dev:app -- --watch",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
     "build:dev:app": "env-cmd -f config/env.dev.js webpack --config config/webpack.dev.js --progress",
-    "build:dev:dll": "webpack --config config/webpack.dev.dll.js",
-    "build:dev:watch": "npm-run-all -s build:dev:dll build:dev:app:watch",
-    "build:dev": "npm-run-all -s build:dev:dll build:dev:app",
+    "build:dev:watch": "npm run build:dev:app:watch",
+    "build:dev": "npm run build:dev:app",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod:analyze": "cross-env ANALYZE=1 npm run build:prod",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build:prod": "env-cmd -f config/env.prod.js webpack --config config/webpack.prod.js --profile --bail",
     "build": "npm run build:dev:watch",
     "build": "npm run build:dev:watch",
@@ -179,6 +178,7 @@
     "eslint-plugin-react": "^7.14.2",
     "eslint-plugin-react": "^7.14.2",
     "file-loader": "^4.0.0",
     "file-loader": "^4.0.0",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
+    "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
     "imports-loader": "^0.8.0",
     "jest": "^24.8.0",
     "jest": "^24.8.0",

+ 35 - 2
resource/locales/en-US/translation.json

@@ -652,6 +652,7 @@
   },
   },
 
 
   "customize_page": {
   "customize_page": {
+    "recommended":"Recommended",
     "Behavior": "Behavior",
     "Behavior": "Behavior",
     "Layout": "Layout",
     "Layout": "Layout",
     "Function": "Function",
     "Function": "Function",
@@ -669,7 +670,7 @@
     "custom_title": "Custom Title",
     "custom_title": "Custom Title",
     "custom_title_detail": "You can customize <code>%s</code> tag.<br><code>%s</code> will be automatically replaced with the app name, and <code>%s</code> will be replaced with the page name/path.",
     "custom_title_detail": "You can customize <code>%s</code> tag.<br><code>%s</code> will be automatically replaced with the app name, and <code>%s</code> will be replaced with the page name/path.",
     "custom_header": "Custom HTML Header",
     "custom_header": "Custom HTML Header",
-    "custom_header_detail": "You can customize HTML header that applies all pages. Your custom script will be inserted in <code>%s</code> but above other <code>%s</code> tags.<br>Relaod page to see changes.",
+    "custom_header_detail": "You can customize HTML header that applies all pages. Your custom script will be inserted in <code>&lt;header&gt;</code> but above other <code>&lt;script&gt;</code> tags.<br>Relaod page to see changes.",
     "Custom CSS": "Custom CSS",
     "Custom CSS": "Custom CSS",
     "write_CSS": "You can write CSS that is applied to whole system.",
     "write_CSS": "You can write CSS that is applied to whole system.",
     "reflect_change": "You need to reload the page to reflect the change.",
     "reflect_change": "You need to reload the page to reflect the change.",
@@ -679,7 +680,39 @@
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header": "Add h1 section when create new page automatically",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
     "attach_title_header_desc": "Add page path to the first line as h1 section when create new page",
     "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
     "recent_created__n_draft_num_desc": "Number of Recently Created Pages & Drafts Displayed",
-    "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page"
+    "recently_created_n_draft_num_desc": "Number of recently created pages and drafts displayed on user page",
+    "update_layout_success": "Succeeded to update layout",
+    "update_behavior_success": "Succeeded to update behavior",
+    "update_function_success": "Succeeded to update function",
+    "update_highlight_success": "Succeeded to update code highlight",
+    "update_customTitle_success": "Succeeded to update customize title",
+    "update_customHeader_success": "Succeeded to update customize html header",
+    "update_customCss_success": "Succeeded to update customize css",
+    "update_script_success": "Succeeded to update custom script",
+    "layout_description":{
+      "growi_title":"Simple and Clear",
+      "growi_text1":"Full screen layout and thin margins/paddings",
+      "growi_text2":"Show and post comments at the bottom of the page",
+      "growi_text3":"Affix Table-of-contents",
+      "kibela_title":"Easy Viewing Structure",
+      "kibela_text1":"Center aligned contents",
+      "kibela_text2":"Show and post comments at the bottom of the page",
+      "kibela_text3":"Affix Table-of-contents",
+      "crowi_title":"Separated Functions",
+      "crowi_text1":"Collapsible Sidebar",
+      "crowi_text2":"Show and post comments in Sidebar",
+      "crowi_text3":"Collapsible Table-of-contents"
+    },
+    "behavior_description":{
+      "growi_text1":"Both of <code>/page</code> and <code>/page/</code> shows the same page。",
+      "growi_text2":"<code>/nonexistent_page</code> shows editing form",
+      "growi_text3":"All pages shows the list of sub pages <b>if using GROWI Enhanced Layout</b>",
+      "crowi_text1":"<code>/page</code> shows the page",
+      "crowi_text2":"<code>/page/</code> shows the list of sub pages",
+      "crowi_text3":"If portal is applied to <code>/page/</code> , the portal and the list of sub pages are shown",
+      "crowi_text4":"<code>/nonexistent_page</code> shows editing form<",
+      "crowi_text5":"<code>/nonexistent_page/</code> the list of sub pages"
+    }
   },
   },
 
 
   "user_management": {
   "user_management": {

+ 36 - 3
resource/locales/ja/translation.json

@@ -636,7 +636,8 @@
   },
   },
 
 
   "customize_page": {
   "customize_page": {
-    "Behavior": "挙動",
+    "recommended":"おすすめ",
+    "Behavior": "動作",
     "Layout": "レイアウト",
     "Layout": "レイアウト",
     "Function": "機能",
     "Function": "機能",
     "function_choose": "機能の有効/無効を選択できます。",
     "function_choose": "機能の有効/無効を選択できます。",
@@ -653,7 +654,7 @@
     "custom_title": "カスタム Title",
     "custom_title": "カスタム Title",
     "custom_title_detail": "<code>%s</code>タグのコンテンツをカスタマイズできます。<br><code>%s</code>がサイト名、<code>%s</code>がページ名またはページパスに置換されます。",
     "custom_title_detail": "<code>%s</code>タグのコンテンツをカスタマイズできます。<br><code>%s</code>がサイト名、<code>%s</code>がページ名またはページパスに置換されます。",
     "custom_header": "カスタム HTML Header",
     "custom_header": "カスタム HTML Header",
-    "custom_header_detail": "システム全体に適用される HTML を記述できます。<code>%s</code> タグ内の他の <code>%s</code> タグ読み込み前に展開されます。<br>変更の反映はページの更新が必要です。",
+    "custom_header_detail": "システム全体に適用される HTML を記述できます。<code>&lt;header&gt;</code> タグ内の他の <code>&lt;script&gt;</code> タグ読み込み前に展開されます。<br>変更の反映はページの更新が必要です。",
     "Custom CSS": "カスタム CSS",
     "Custom CSS": "カスタム CSS",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "write_CSS": " システム全体に適用されるCSSを記述できます。",
     "reflect_change": "変更の反映はページの更新が必要です。",
     "reflect_change": "変更の反映はページの更新が必要です。",
@@ -663,7 +664,39 @@
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header": "新規ページ作成時の h1 セクション自動挿入",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
     "attach_title_header_desc": "新規作成したページの1行目に、ページのパスを h1 セクションとして挿入します。",
     "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
     "recent_created__n_draft_num_desc": "最近作成したページと下書きの表示数",
-    "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。"
+    "recently_created_n_draft_num_desc": "ホーム画面の Recently Created での、1ページの表示数を設定します。",
+    "update_layout_success": "レイアウトを更新しました",
+    "update_behavior_success": "動作を更新しました",
+    "update_function_success": "機能を更新しました",
+    "update_highlight_success": "コードハイライトを更新しました",
+    "update_customTitle_success": "カスタムタイトルを更新しました",
+    "update_customHeader_success": "カスタムHTMLヘッダーを更新しました",
+    "update_customCss_success": "カスタムCSSを更新しました",
+    "update_script_success": "カスタムスクリプトを更新しました",
+    "layout_description":{
+      "growi_title":"シンプル・明瞭",
+      "growi_text1":"全画面レイアウトで、余白は少なくなります。",
+      "growi_text2":"コメントはページの下部に表示されます。",
+      "growi_text3":"ページ情報は下部に表示されます。",
+      "kibela_title":"閲覧重視の構造",
+      "kibela_text1":"コンテンツが中心に表示されます。",
+      "kibela_text2":"コメントはページの下部に表示されます。",
+      "kibela_text3":"ページ情報は下部に表示されます。",
+      "crowi_title":"ビュー・コントロールの分離",
+      "crowi_text1":"サイドバーを開くと情報が表示されます。",
+      "crowi_text2":"コメントはサイドバーに表示されます。",
+      "crowi_text3":"ページ情報はサイドバーに表示されます。"
+    },
+    "behavior_description":{
+      "growi_text1":"<code>/page</code>と<code>/page/</code>どちらのパスも同じページを表示します。",
+      "growi_text2":"<code>/nonexistent_page</code> では編集フォームを表示します",
+      "growi_text3":"<b>GROWI Enhanced Layout</b>では全てのページが配下のページリストを表示します",
+      "crowi_text1":"<code>/page</code> ではページを表示します。",
+      "crowi_text2":"<code>/page/</code> では配下のページを表示します。",
+      "crowi_text3":"<code>/page/</code>がポータルに適応している場合、ポータルページと配下のページリストを表示します。",
+      "crowi_text4":"<code>/nonexistent_page</code> では編集フォームを表示します",
+      "crowi_text5":"<code>/nonexistent_page</code> では配下のページリストを表示します。"
+    }
   },
   },
 
 
   "user_management": {
   "user_management": {

+ 48 - 95
src/client/js/app.jsx

@@ -35,9 +35,6 @@ import UserPictureList from './components/User/UserPictureList';
 import TableOfContents from './components/TableOfContents';
 import TableOfContents from './components/TableOfContents';
 
 
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
 import UserGroupDetailPage from './components/Admin/UserGroupDetail/UserGroupDetailPage';
-import CustomCssEditor from './components/Admin/CustomCssEditor';
-import CustomScriptEditor from './components/Admin/CustomScriptEditor';
-import CustomHeaderEditor from './components/Admin/CustomHeaderEditor';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import MarkdownSetting from './components/Admin/MarkdownSetting/MarkDownSetting';
 import UserManagement from './components/Admin/UserManagement';
 import UserManagement from './components/Admin/UserManagement';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
 import ManageExternalAccount from './components/Admin/ManageExternalAccount';
@@ -52,6 +49,7 @@ import PageContainer from './services/PageContainer';
 import CommentContainer from './services/CommentContainer';
 import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import TagContainer from './services/TagContainer';
+import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import UserGroupDetailContainer from './services/UserGroupDetailContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import AdminUsersContainer from './services/AdminUsersContainer';
 import WebsocketContainer from './services/WebsocketContainer';
 import WebsocketContainer from './services/WebsocketContainer';
@@ -110,7 +108,6 @@ let componentMappings = {
   'user-draft-list': <MyDraftList />,
   'user-draft-list': <MyDraftList />,
 
 
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
-  'admin-customize': <Customize />,
 
 
   'staff-credit': <StaffCredit />,
   'staff-credit': <StaffCredit />,
   'admin-importer': <ImportDataPage />,
   'admin-importer': <ImportDataPage />,
@@ -121,17 +118,17 @@ if (pageContainer.state.pageId != null) {
   componentMappings = Object.assign({
   componentMappings = Object.assign({
     'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
-    'page-attachment':  <PageAttachment />,
-    'page-timeline':  <PageTimeline />,
-    'page-comment-write':  <CommentEditorLazyRenderer />,
+    'page-attachment': <PageAttachment />,
+    'page-timeline': <PageTimeline />,
+    'page-comment-write': <CommentEditorLazyRenderer />,
     'revision-toc': <TableOfContents />,
     'revision-toc': <TableOfContents />,
     'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
     'like-button': <LikeButton pageId={pageContainer.state.pageId} isLiked={pageContainer.state.isLiked} />,
     'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
     'seen-user-list': <UserPictureList userIds={pageContainer.state.seenUserIds} />,
     'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
     'liker-list': <UserPictureList userIds={pageContainer.state.likerUserIds} />,
-    'bookmark-button':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
-    'bookmark-button-lg':  <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
-    'rename-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
-    'duplicate-page-name-input':  <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'bookmark-button': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} />,
+    'bookmark-button-lg': <BookmarkButton pageId={pageContainer.state.pageId} crowi={appContainer} size="lg" />,
+    'rename-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
+    'duplicate-page-name-input': <PagePathAutoComplete crowi={appContainer} initializedPath={pageContainer.state.path} />,
   }, componentMappings);
   }, componentMappings);
 }
 }
 if (pageContainer.state.path != null) {
 if (pageContainer.state.path != null) {
@@ -139,7 +136,7 @@ if (pageContainer.state.path != null) {
     // eslint-disable-next-line quote-props
     // eslint-disable-next-line quote-props
     'page': <Page />,
     'page': <Page />,
     'revision-path': <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />,
     'revision-path': <RevisionPath behaviorType={appContainer.config.behaviorType} pageId={pageContainer.state.pageId} pagePath={pageContainer.state.path} />,
-    'tag-label':  <TagLabels />,
+    'tag-label': <TagLabels />,
   }, componentMappings);
   }, componentMappings);
 }
 }
 
 
@@ -157,32 +154,46 @@ Object.keys(componentMappings).forEach((key) => {
   }
   }
 });
 });
 
 
-// render for admin
-const adminUsersElem = document.getElementById('admin-user-page');
-if (adminUsersElem != null) {
-  const adminUsersContainer = new AdminUsersContainer(appContainer);
-  ReactDOM.render(
-    <Provider inject={[injectableContainers, adminUsersContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <UserManagement />
-      </I18nextProvider>
-    </Provider>,
-    adminUsersElem,
-  );
-}
+// create unstated container instance for admin
+const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
+const adminUsersContainer = new AdminUsersContainer(appContainer);
+const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
+const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
+const adminContainers = {
+  'admin-customize': adminCustomizeContainer,
+  'admin-user-page': adminUsersContainer,
+  'admin-external-account-setting': adminExternalAccountsContainer,
+  'admin-markdown-setting': adminMarkDownContainer,
+  'admin-export-page': websocketContainer,
+};
 
 
-const adminExternalAccountsElem = document.getElementById('admin-external-account-setting');
-if (adminExternalAccountsElem != null) {
-  const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appContainer);
-  ReactDOM.render(
-    <Provider inject={[injectableContainers, adminExternalAccountsContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <ManageExternalAccount />
-      </I18nextProvider>
-    </Provider>,
-    adminExternalAccountsElem,
-  );
-}
+/**
+ * define components
+ *  key: id of element
+ *  value: React Element
+ */
+const adminComponentMappings = {
+  'admin-customize': <Customize />,
+  'admin-user-page': <UserManagement />,
+  'admin-external-account-setting': <ManageExternalAccount />,
+  'admin-markdown-setting': <MarkdownSetting />,
+  'admin-export-page': <ExportArchiveDataPage crowi={appContainer} />,
+};
+
+
+Object.keys(adminComponentMappings).forEach((key) => {
+  const adminElem = document.getElementById(key);
+  if (adminElem) {
+    ReactDOM.render(
+      <Provider inject={[injectableContainers, adminContainers[key]]}>
+        <I18nextProvider i18n={i18n}>
+          {adminComponentMappings[key]}
+        </I18nextProvider>
+      </Provider>,
+      adminElem,
+    );
+  }
+});
 
 
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 const adminUserGroupDetailElem = document.getElementById('admin-user-group-detail');
 if (adminUserGroupDetailElem != null) {
 if (adminUserGroupDetailElem != null) {
@@ -197,50 +208,6 @@ if (adminUserGroupDetailElem != null) {
   );
   );
 }
 }
 
 
-const adminMarkDownSettingElem = document.getElementById('admin-markdown-setting');
-if (adminMarkDownSettingElem != null) {
-  const markDownSettingContainer = new AdminMarkDownContainer(appContainer);
-  ReactDOM.render(
-    <Provider inject={[injectableContainers, markDownSettingContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <MarkdownSetting />
-      </I18nextProvider>
-    </Provider>,
-    adminMarkDownSettingElem,
-  );
-}
-
-const customCssEditorElem = document.getElementById('custom-css-editor');
-if (customCssEditorElem != null) {
-  // get input[type=hidden] element
-  const customCssInputElem = document.getElementById('inputCustomCss');
-
-  ReactDOM.render(
-    <CustomCssEditor inputElem={customCssInputElem} />,
-    customCssEditorElem,
-  );
-}
-const customScriptEditorElem = document.getElementById('custom-script-editor');
-if (customScriptEditorElem != null) {
-  // get input[type=hidden] element
-  const customScriptInputElem = document.getElementById('inputCustomScript');
-
-  ReactDOM.render(
-    <CustomScriptEditor inputElem={customScriptInputElem} />,
-    customScriptEditorElem,
-  );
-}
-const customHeaderEditorElem = document.getElementById('custom-header-editor');
-if (customHeaderEditorElem != null) {
-  // get input[type=hidden] element
-  const customHeaderInputElem = document.getElementById('inputCustomHeader');
-
-  ReactDOM.render(
-    <CustomHeaderEditor inputElem={customHeaderInputElem} />,
-    customHeaderEditorElem,
-  );
-}
-
 const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
 const adminUserGroupPageElem = document.getElementById('admin-user-group-page');
 if (adminUserGroupPageElem != null) {
 if (adminUserGroupPageElem != null) {
   const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
   const isAclEnabled = adminUserGroupPageElem.getAttribute('data-isAclEnabled') === 'true';
@@ -258,20 +225,6 @@ if (adminUserGroupPageElem != null) {
   );
   );
 }
 }
 
 
-const adminExportPageElem = document.getElementById('admin-export-page');
-if (adminExportPageElem != null) {
-  ReactDOM.render(
-    <Provider inject={[appContainer, websocketContainer]}>
-      <I18nextProvider i18n={i18n}>
-        <ExportArchiveDataPage
-          crowi={appContainer}
-        />
-      </I18nextProvider>
-    </Provider>,
-    adminExportPageElem,
-  );
-}
-
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 // うわーもうー (commented by Crowi team -- 2018.03.23 Yuki Takei)
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
 $('a[data-toggle="tab"][href="#revision-history"]').on('show.bs.tab', () => {
   ReactDOM.render(
   ReactDOM.render(

+ 28 - 0
src/client/js/components/Admin/Common/AdminUpdateButtonRow.jsx

@@ -0,0 +1,28 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class AdminUpdateButtonRow extends React.PureComponent {
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <div className="row my-3">
+        <div className="col-xs-offset-4 col-xs-5">
+          <button type="button" className="btn btn-primary" onClick={this.props.onClick} disabled={this.props.disabled}>{ t('Update') }</button>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+AdminUpdateButtonRow.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  onClick: PropTypes.func.isRequired,
+  disabled: PropTypes.bool.isRequired,
+};
+
+export default withTranslation()(AdminUpdateButtonRow);

+ 4 - 5
src/client/js/components/Admin/CustomCssEditor.jsx

@@ -16,12 +16,10 @@ require('jquery-ui/ui/widgets/resizable');
 export default class CustomCssEditor extends React.Component {
 export default class CustomCssEditor extends React.Component {
 
 
   render() {
   render() {
-    // get initial value from inputElem
-    const value = this.props.inputElem.value;
 
 
     return (
     return (
       <CodeMirror
       <CodeMirror
-        value={value}
+        value={this.props.value}
         autoFocus
         autoFocus
         options={{
         options={{
           mode: 'css',
           mode: 'css',
@@ -43,7 +41,7 @@ export default class CustomCssEditor extends React.Component {
           });
           });
         }}
         }}
         onChange={(editor, data, value) => {
         onChange={(editor, data, value) => {
-          this.props.inputElem.value = value;
+          this.props.onChange(value);
         }}
         }}
       />
       />
     );
     );
@@ -52,5 +50,6 @@ export default class CustomCssEditor extends React.Component {
 }
 }
 
 
 CustomCssEditor.propTypes = {
 CustomCssEditor.propTypes = {
-  inputElem: PropTypes.object.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
 };
 };

+ 4 - 5
src/client/js/components/Admin/CustomHeaderEditor.jsx

@@ -14,12 +14,10 @@ require('jquery-ui/ui/widgets/resizable');
 export default class CustomHeaderEditor extends React.Component {
 export default class CustomHeaderEditor extends React.Component {
 
 
   render() {
   render() {
-    // get initial value from inputElem
-    const value = this.props.inputElem.value;
 
 
     return (
     return (
       <CodeMirror
       <CodeMirror
-        value={value}
+        value={this.props.value}
         autoFocus
         autoFocus
         options={{
         options={{
           mode: 'htmlmixed',
           mode: 'htmlmixed',
@@ -41,7 +39,7 @@ export default class CustomHeaderEditor extends React.Component {
           });
           });
         }}
         }}
         onChange={(editor, data, value) => {
         onChange={(editor, data, value) => {
-          this.props.inputElem.value = value;
+          this.props.onChange(value);
         }}
         }}
       />
       />
     );
     );
@@ -50,5 +48,6 @@ export default class CustomHeaderEditor extends React.Component {
 }
 }
 
 
 CustomHeaderEditor.propTypes = {
 CustomHeaderEditor.propTypes = {
-  inputElem: PropTypes.object.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
 };
 };

+ 4 - 5
src/client/js/components/Admin/CustomScriptEditor.jsx

@@ -16,12 +16,10 @@ require('jquery-ui/ui/widgets/resizable');
 export default class CustomScriptEditor extends React.Component {
 export default class CustomScriptEditor extends React.Component {
 
 
   render() {
   render() {
-    // get initial value from inputElem
-    const value = this.props.inputElem.value;
 
 
     return (
     return (
       <CodeMirror
       <CodeMirror
-        value={value}
+        value={this.props.value}
         autoFocus
         autoFocus
         options={{
         options={{
           mode: 'javascript',
           mode: 'javascript',
@@ -43,7 +41,7 @@ export default class CustomScriptEditor extends React.Component {
           });
           });
         }}
         }}
         onChange={(editor, data, value) => {
         onChange={(editor, data, value) => {
-          this.props.inputElem.value = value;
+          this.props.onChange(value);
         }}
         }}
       />
       />
     );
     );
@@ -52,5 +50,6 @@ export default class CustomScriptEditor extends React.Component {
 }
 }
 
 
 CustomScriptEditor.propTypes = {
 CustomScriptEditor.propTypes = {
-  inputElem: PropTypes.object.isRequired,
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
 };
 };

+ 55 - 21
src/client/js/components/Admin/Customize/Customize.jsx

@@ -3,36 +3,69 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import loggerFactory from '@alias/logger';
+
 import AppContainer from '../../../services/AppContainer';
 import AppContainer from '../../../services/AppContainer';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
 
 
 import { createSubscribedElement } from '../../UnstatedUtils';
 import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastError } from '../../../util/apiNotification';
+
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeBehaviorSetting from './CustomizeBehaviorSetting';
+import CustomizeFunctionSetting from './CustomizeFunctionSetting';
+import CustomizeHighlightSetting from './CustomizeHighlightSetting';
+import CustomizeCssSetting from './CustomizeCssSetting';
+import CustomizeScriptSetting from './CustomizeScriptSetting';
+import CustomizeHeaderSetting from './CustomizeHeaderSetting';
+import CustomizeTitle from './CustomizeTitle';
+
+const logger = loggerFactory('growi:Customize');
 
 
 class Customize extends React.Component {
 class Customize extends React.Component {
 
 
+  async componentDidMount() {
+    const { adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.retrieveCustomizeData();
+    }
+    catch (err) {
+      toastError(err);
+      adminCustomizeContainer.setState({ retrieveError: err });
+      logger.error(err);
+    }
+
+  }
+
   render() {
   render() {
-    const { t } = this.props;
 
 
     return (
     return (
       <Fragment>
       <Fragment>
-        {/* fieldset + legend ではなく、row + header + フォームコンポーネントに書き換える(GC244着手時に対応) */}
-        <fieldset>
-          <legend>{t('customize_page.Layout')}</legend>
-          {/* レイアウトフォームの react componentをここで呼ぶ(GW-244) */}
-          <legend>{t('customize_page.Theme')}</legend>
-          {/* テーマフォームの react componentをここで呼ぶ(GW-245) */}
-          <legend>{t('customize_page.Behavior')}</legend>
-          {/* 挙動フォームの react componentをここで呼ぶ(GW-246) */}
-          <legend>{t('customize_page.Function')}</legend>
-          {/* 機能フォームの react componentをここで呼ぶ(GW-276) */}
-          <legend>{t('customize_page.Code Highlight')}</legend>
-          {/* コードハイライトフォームの react componentをここで呼ぶ(GW-277) */}
-          <legend>{t('customize_page.custom_title')}</legend>
-          {/* カスタムタイトルフォームの react componentをここで呼ぶ(GW-278) */}
-          <legend>{t('customize_page.Custom CSS')}</legend>
-          {/* カスタムCSSフォームの react componentをここで呼ぶ(GW-279) */}
-          <legend>{t('customize_page.Custom script')}</legend>
-          {/* カスタムスクリプトフォームの react componentをここで呼ぶ(GW-280) */}
-        </fieldset>
+        <div className="mb-5">
+          <CustomizeLayoutSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeBehaviorSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeFunctionSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeHighlightSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeTitle />
+        </div>
+        <div className="mb-5">
+          <CustomizeHeaderSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeCssSetting />
+        </div>
+        <div className="mb-5">
+          <CustomizeScriptSetting />
+        </div>
       </Fragment>
       </Fragment>
     );
     );
   }
   }
@@ -40,12 +73,13 @@ class Customize extends React.Component {
 }
 }
 
 
 const CustomizeWrapper = (props) => {
 const CustomizeWrapper = (props) => {
-  return createSubscribedElement(Customize, props, [AppContainer]);
+  return createSubscribedElement(Customize, props, [AppContainer, AdminCustomizeContainer]);
 };
 };
 
 
 Customize.propTypes = {
 Customize.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(CustomizeWrapper);
 export default withTranslation()(CustomizeWrapper);

+ 38 - 0
src/client/js/components/Admin/Customize/CustomizeBehaviorOption.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeBehaviorOption extends React.PureComponent {
+
+  render() {
+
+    return (
+      <React.Fragment>
+        <h4>
+          <div className="radio radio-primary">
+            <input type="radio" id={`radioBehavior${this.props.behaviorType}`} checked={this.props.isSelected} onChange={this.props.onSelected} />
+            <label htmlFor={`radioBehavior${this.props.behaviorType}`}>
+              {/* eslint-disable-next-line react/no-danger */}
+              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
+            </label>
+          </div>
+        </h4>
+        {/* render layout description */}
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeBehaviorOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  behaviorType: PropTypes.string.isRequired,
+  labelHtml: PropTypes.string.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  children: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorOption);

+ 99 - 0
src/client/js/components/Admin/Customize/CustomizeBehaviorSetting.jsx

@@ -0,0 +1,99 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import CustomizeBehaviorOption from './CustomizeBehaviorOption';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeBehaviorSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeBehavior();
+      toastSuccess(t('customize_page.update_behavior_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Behavior')}</h2>
+        <div className="row">
+          <div className="col-xs-6">
+            <CustomizeBehaviorOption
+              behaviorType="growi"
+              isSelected={adminCustomizeContainer.state.currentBehavior === 'growi'}
+              onSelected={() => adminCustomizeContainer.switchBehaviorType('growi')}
+              labelHtml={`GROWI Simplified Behavior <small class="text-success">${t('customize_page.recommended')}</small>`}
+            >
+              <ul>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.growi_text1') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.growi_text2') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.growi_text3') }} /></li>
+              </ul>
+            </CustomizeBehaviorOption>
+          </div>
+
+          <div className="col-xs-6">
+            <CustomizeBehaviorOption
+              behaviorType="crowi-plus"
+              isSelected={adminCustomizeContainer.state.currentBehavior === 'crowi-plus'}
+              onSelected={() => adminCustomizeContainer.switchBehaviorType('crowi-plus')}
+              labelHtml="Crowi Classic Behavior"
+            >
+              <ul>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text1') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text2') }} /></li>
+                <ul>
+                  <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text3') }} /></li>
+                </ul>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text4') }} /></li>
+                <li><span dangerouslySetInnerHTML={{ __html: t('customize_page.behavior_description.crowi_text5') }} /></li>
+              </ul>
+            </CustomizeBehaviorOption>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeBehaviorSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeBehaviorSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeBehaviorSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorSettingWrapper);

+ 91 - 0
src/client/js/components/Admin/Customize/CustomizeCssSetting.jsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomCssEditor from '../CustomCssEditor';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeCssSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      editorInputValue: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  componentDidMount() {
+    const { customizeCss } = this.props.appContainer.getConfig();
+    this.setState({ editorInputValue: customizeCss || '' });
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeCss();
+      toastSuccess(t('customize_page.update_customCss_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Custom CSS')}</h2>
+        <p className="well">
+          { t('customize_page.write_CSS') }<br />
+          { t('customize_page.reflect_change') }
+        </p>
+        <div className="form-group">
+          <div className="col-xs-12">
+            <CustomCssEditor
+              // The value passed must be immutable
+              value={this.state.editorInputValue}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeCss(inputValue) }}
+            />
+          </div>
+          <div className="col-xs-12">
+            <p className="help-block text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
+              { t('customize_page.ctrl_space') }
+            </p>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeCssSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeCssSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeCssSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeCssSettingWrapper);

+ 38 - 0
src/client/js/components/Admin/Customize/CustomizeFunctionOption.jsx

@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeFunctionOption extends React.PureComponent {
+
+  render() {
+    return (
+      <React.Fragment>
+        <div className="checkbox checkbox-success">
+          <input
+            type="checkbox"
+            id={this.props.optionId}
+            checked={this.props.isChecked}
+            onChange={this.props.onChecked}
+          />
+          <label htmlFor={this.props.optionId}>
+            <strong>{this.props.label}</strong>
+          </label>
+        </div>
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeFunctionOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  optionId: PropTypes.string.isRequired,
+  label: PropTypes.string.isRequired,
+  isChecked: PropTypes.bool.isRequired,
+  onChecked: PropTypes.func.isRequired,
+  children: PropTypes.object.isRequired,
+};
+
+export default withTranslation()(CustomizeFunctionOption);

+ 143 - 0
src/client/js/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -0,0 +1,143 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomizeFunctionOption from './CustomizeFunctionOption';
+
+const logger = loggerFactory('growi:importer');
+
+class CustomizeBehaviorSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeFunction();
+      toastSuccess(t('customize_page.update_function_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Function')}</h2>
+        <p className="well">{ t('customize_page.function_choose') }</p>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isEnabledTimeline"
+              label={t('customize_page.Timeline function')}
+              isChecked={adminCustomizeContainer.state.isEnabledTimeline}
+              onChecked={() => { adminCustomizeContainer.switchEnableTimeline() }}
+            >
+              <p className="help-block">
+                { t('customize_page.subpage_display') }<br />
+                { t('customize_page.performance_decrease') }<br />
+                { t('customize_page.list_page_display') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isSavedStatesOfTabChanges"
+              label={t('customize_page.tab_switch')}
+              isChecked={adminCustomizeContainer.state.isSavedStatesOfTabChanges}
+              onChecked={() => { adminCustomizeContainer.switchSavedStatesOfTabChanges() }}
+            >
+              <p className="help-block">
+                { t('customize_page.save_edit') }<br />
+                { t('customize_page.by_invalidating') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <CustomizeFunctionOption
+              optionId="isEnabledAttachTitleHeader"
+              label={t('customize_page.attach_title_header')}
+              isChecked={adminCustomizeContainer.state.isEnabledAttachTitleHeader}
+              onChecked={() => { adminCustomizeContainer.switchEnabledAttachTitleHeader() }}
+            >
+              <p className="help-block">
+                { t('customize_page.attach_title_header_desc') }
+              </p>
+            </CustomizeFunctionOption>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="my-0 btn-group">
+              <label>{t('customize_page.recent_created__n_draft_num_desc')}</label>
+              <div className="dropdown">
+                <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                  <span className="pull-left">{adminCustomizeContainer.state.currentRecentCreatedLimit}</span>
+                  <span className="bs-caret pull-right">
+                    <span className="caret" />
+                  </span>
+                </button>
+                {/* TODO adjust dropdown after BS4 */}
+                <ul className="dropdown-menu" role="menu">
+                  <li key={10} role="presentation" type="button" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(10) }}>
+                    <a role="menuitem">10</a>
+                  </li>
+                  <li key={30} role="presentation" type="button" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(30) }}>
+                    <a role="menuitem">30</a>
+                  </li>
+                  <li key={50} role="presentation" type="button" onClick={() => { adminCustomizeContainer.switchRecentCreatedLimit(50) }}>
+                    <a role="menuitem">50</a>
+                  </li>
+                </ul>
+              </div>
+              <p className="help-block">
+                { t('customize_page.recently_created_n_draft_num_desc') }
+              </p>
+            </div>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeBehaviorSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeBehaviorSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeBehaviorSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeBehaviorSettingWrapper);

+ 100 - 0
src/client/js/components/Admin/Customize/CustomizeHeaderSetting.jsx

@@ -0,0 +1,100 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomHeaderEditor from '../CustomHeaderEditor';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeHeaderSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      editorInputValue: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  componentDidMount() {
+    const { customizeHeader } = this.props.appContainer.getConfig();
+    this.setState({ editorInputValue: customizeHeader || '' });
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeHeader();
+      toastSuccess(t('customize_page.update_customHeader_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.custom_header')}</h2>
+
+        <p
+          className="well"
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{ __html: t('customize_page.custom_header_detail') }}
+        />
+
+        <div className="help-block">
+          { t('Example') }:
+          <pre className="hljs">
+            {/* eslint-disable-next-line react/no-unescaped-entities */}
+            <code>&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js" defer&gt;&lt;/script&gt;</code>
+          </pre>
+        </div>
+
+        <div className="col-xs-12">
+          <CustomHeaderEditor
+            // The value passed must be immutable
+            value={this.state.editorInputValue}
+            onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeHeader(inputValue) }}
+          />
+        </div>
+        <div className="col-xs-12">
+          <p className="help-block text-right">
+            <i className="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
+            { t('customize_page.ctrl_space') }
+          </p>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeHeaderSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeHeaderSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeHeaderSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeHeaderSettingWrapper);

+ 143 - 0
src/client/js/components/Admin/Customize/CustomizeHighlightSetting.jsx

@@ -0,0 +1,143 @@
+/* eslint-disable no-useless-escape */
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:customizeHighlight');
+
+class CustomizeHighlightSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateHighlightJsStyle();
+      toastSuccess(t('customize_page.update_highlight_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  getDemoFunction() {
+    return `function $initHighlight(block, cls) {
+    try {
+
+      if (cls.search(/\bno\-highlight\b/) !== -1) {
+        return \`\${process(block, true, 0x0F)} class="\${cls}"\`;
+      }
+    }
+    catch (e) {
+      /* handle exception */
+    }
+    for (let i = 0 / 2; i < classes.length; i++) {
+      if (checkCondition(classes[i]) === undefined) { console.log('undefined') }
+    }
+  };`;
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+    const options = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
+    const menuItem = [];
+
+    Object.entries(options).forEach((option) => {
+      const styleId = option[0];
+      const styleName = option[1].name;
+      const isBorderEnable = option[1].border;
+
+      menuItem.push(
+        <li key={styleId} role="presentation" type="button" onClick={() => adminCustomizeContainer.switchHighlightJsStyle(styleId, styleName, isBorderEnable)}>
+          <a role="menuitem">{styleName}</a>
+        </li>,
+      );
+    });
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Code Highlight')}</h2>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="my-0 btn-group">
+              <label>{t('customize_page.Theme')}</label>
+              <div className="dropdown">
+                <button className="btn btn-default dropdown-toggle w-100" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                  <span className="pull-left">{adminCustomizeContainer.state.currentHighlightJsStyleName}</span>
+                  <span className="bs-caret pull-right">
+                    <span className="caret" />
+                  </span>
+                </button>
+                {/* TODO adjust dropdown after BS4 */}
+                <ul className="dropdown-menu" role="menu">
+                  {menuItem}
+                </ul>
+              </div>
+              {/* eslint-disable-next-line react/no-danger */}
+              <p className="help-block text-warning"><span dangerouslySetInnerHTML={{ __html:  t('customize_page.nocdn_desc') }} /></p>
+            </div>
+          </div>
+        </div>
+
+        <div className="form-group row">
+          <div className="col-xs-offset-3 col-xs-6 text-left">
+            <div className="checkbox checkbox-success">
+              <input
+                type="checkbox"
+                id="highlightBorder"
+                checked={adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled}
+                onChange={() => { adminCustomizeContainer.switchHighlightJsStyleBorder() }}
+              />
+              <label htmlFor="highlightBorder">
+                <strong>Border</strong>
+              </label>
+            </div>
+          </div>
+        </div>
+
+        <div className="help-block">
+          <label>Examples:</label>
+          <div className="wiki">
+            <pre className={`hljs ${!adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled && 'hljs-no-border'}`}>
+              <code className="highlightjs-demo">
+                {this.getDemoFunction()}
+              </code>
+            </pre>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeHighlightSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeHighlightSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeHighlightSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeHighlightSettingWrapper);

+ 42 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutOption.jsx

@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+class CustomizeLayoutOption extends React.Component {
+
+  render() {
+    const { layoutType } = this.props;
+
+    return (
+      <React.Fragment>
+        <h4>
+          <div className="radio radio-primary">
+            <input type="radio" id={`radio-layout-${layoutType}`} checked={this.props.isSelected} onChange={this.props.onSelected} />
+            <label htmlFor={`radio-layout-${layoutType}`}>
+              {/* eslint-disable-next-line react/no-danger */}
+              <span dangerouslySetInnerHTML={{ __html: this.props.labelHtml }} />
+            </label>
+          </div>
+        </h4>
+        <a href={`/images/admin/customize/layout-${layoutType}.gif`} className="ss-container">
+          <img src={`/images/admin/customize/layout-${layoutType}-thumb.gif`} width="240px" />
+        </a>
+        {/* render layout description */}
+        {this.props.children}
+      </React.Fragment>
+    );
+  }
+
+}
+
+CustomizeLayoutOption.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  layoutType: PropTypes.string.isRequired,
+  labelHtml: PropTypes.string.isRequired,
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  children: PropTypes.array.isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutOption);

+ 81 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutOptions.jsx

@@ -0,0 +1,81 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AppContainer from '../../../services/AppContainer';
+
+import CustomizeLayoutOption from './CustomizeLayoutOption';
+
+class CustomizeLayoutOptions extends React.Component {
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <div className="row">
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="crowi-plus"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'growi'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('growi')}
+            labelHtml={`GROWI Enhanced Layout <small class="text-success">${t('customize_page.recommended')}</small>`}
+          >
+            <h4>{t('customize_page.layout_description.growi_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.growi_text1')}</li>
+              <li>{t('customize_page.layout_description.growi_text2')}</li>
+              <li>{t('customize_page.layout_description.growi_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="kibela"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'kibela'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('kibela')}
+            labelHtml="Kibela Like Layout"
+          >
+            <h4>{t('customize_page.layout_description.kibela_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.kibela_text1')}</li>
+              <li>{t('customize_page.layout_description.kibela_text2')}</li>
+              <li>{t('customize_page.layout_description.kibela_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+
+        <div className="col-sm-4">
+          <CustomizeLayoutOption
+            layoutType="classic"
+            isSelected={adminCustomizeContainer.state.currentLayout === 'crowi'}
+            onSelected={() => adminCustomizeContainer.switchLayoutType('crowi')}
+            labelHtml="Crowi Classic Layout"
+          >
+            <h4>{t('customize_page.layout_description.crowi_title')}</h4>
+            <ul>
+              <li>{t('customize_page.layout_description.crowi_text1')}</li>
+              <li>{t('customize_page.layout_description.crowi_text2')}</li>
+              <li>{t('customize_page.layout_description.crowi_text3')}</li>
+            </ul>
+          </CustomizeLayoutOption>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+const CustomizeLayoutOptionsWrapper = (props) => {
+  return createSubscribedElement(CustomizeLayoutOptions, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeLayoutOptions.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutOptionsWrapper);

+ 79 - 0
src/client/js/components/Admin/Customize/CustomizeLayoutSetting.jsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import CustomizeLayoutOptions from './CustomizeLayoutOptions';
+import CustomizeThemeOptions from './CustomizeThemeOptions';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:importer');
+
+
+class CustomizeLayoutSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeLayoutAndTheme();
+      toastSuccess(t('customize_page.update_layout_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  renderDevAlert() {
+    if (process.env.NODE_ENV === 'development') {
+      return (
+        <div className="alert alert-warning">
+          <strong>DEBUG MESSAGE:</strong> development build では、リアルタイムプレビューが無効になります
+        </div>
+      );
+    }
+  }
+
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Layout')}</h2>
+        <CustomizeLayoutOptions />
+        <h2 className="admin-setting-header">{t('customize_page.Theme')}</h2>
+        {this.renderDevAlert()}
+        <CustomizeThemeOptions />
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeLayoutSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeLayoutSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeLayoutSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLayoutSettingWrapper);

+ 122 - 0
src/client/js/components/Admin/Customize/CustomizeScriptSetting.jsx

@@ -0,0 +1,122 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomScriptEditor from '../CustomScriptEditor';
+
+const logger = loggerFactory('growi:customizeScript');
+
+class CustomizeScriptSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      editorInputValue: '',
+    };
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  componentDidMount() {
+    const { customizeScript } = this.props.appContainer.getConfig();
+    this.setState({ editorInputValue: customizeScript || '' });
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeScript();
+      toastSuccess(t('customize_page.update_script_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  getExampleCode() {
+    return `console.log($('.main-container'));
+    window.addEventListener('load', (event) => {
+      console.log('config: ', appContainer.config);
+    });
+    `;
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.Custom script')}</h2>
+        <p className="well">
+          { t('customize_page.write_java') }<br />
+          { t('customize_page.reflect_change') }
+        </p>
+
+        <div className="help-block">
+          Placeholders:<br />
+          (Available after <code>load</code> event)
+          <dl className="dl-horizontal">
+            <dt><code>$</code></dt>
+            <dd>jQuery instance</dd>
+            <dt><code>appContainer</code></dt>
+            <dd>GROWI App <a href="https://github.com/jamiebuilds/unstated">Unstated Container</a></dd>
+            <dt><code>growiRenderer</code></dt>
+            <dd>GROWI Renderer origin instance</dd>
+            <dt><code>growiPlugin</code></dt>
+            <dd>GROWI Plugin Manager instance</dd>
+            <dt><code>Crowi</code></dt>
+            <dd>Crowi legacy instance (jQuery based)</dd>
+          </dl>
+        </div>
+
+        <div className="help-block">
+          Examples:
+          <pre className="hljs"><code>{this.getExampleCode()}</code></pre>
+        </div>
+
+        <div className="form-group">
+          <div className="col-xs-12">
+            <CustomScriptEditor
+              // The value passed must be immutable
+              value={this.state.editorInputValue}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeScript(inputValue) }}
+            />
+          </div>
+          <div className="col-xs-12">
+            <p className="help-block text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
+              { t('customize_page.ctrl_space') }
+            </p>
+          </div>
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeScriptSettingWrapper = (props) => {
+  return createSubscribedElement(CustomizeScriptSetting, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeScriptSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeScriptSettingWrapper);

+ 91 - 0
src/client/js/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -0,0 +1,91 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+
+import AppContainer from '../../../services/AppContainer';
+import ThemeColorBox from './ThemeColorBox';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+
+class CustomizeThemeOptions extends React.Component {
+
+  render() {
+    const { adminCustomizeContainer } = this.props;
+    const { currentLayout, currentTheme } = adminCustomizeContainer.state;
+
+    const lightTheme = [{
+      name: 'default', bg: '#ffffff', topbar: '#334455', theme: '#112744',
+    }, {
+      name: 'nature', bg: '#f9fff3', topbar: '#118050', theme: '#460039',
+    }, {
+      name: 'mono-blue', bg: '#F7FBFD', topbar: '#00587A', theme: '#00587A',
+    }, {
+      name: 'wood', bg: '#fffefb', topbar: '#aaa45f', theme: '#dddebf',
+    }, {
+      name: 'island', bg: '#8ecac0', topbar: '#0c2a44', theme: '#cef2ef',
+    }, {
+      name: 'christmas', bg: '#fffefb', topbar: '#b3000c', theme: '#017e20',
+    }, {
+      name: 'antarctic', bg: '#ffffff', topbar: '#000080', theme: '#99cccc',
+    }];
+
+    const darkTheme = [{
+      name: 'default-dark', bg: '#212731', topbar: '#151515', theme: '#f75b36',
+    }, {
+      name: 'future', bg: '#16282D', topbar: '#011414', theme: '#04B4AE',
+    }, {
+      name: 'blue-night', bg: '#061F2F', topbar: '#27343B', theme: '#0090C8',
+    }, {
+      name: 'halloween', bg: '#030003', topbar: '#cc5d1f', theme: '#e9af2b',
+    }];
+
+    return (
+      <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
+        {/* Light Themes  */}
+        <div className="d-flex">
+          {lightTheme.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={currentTheme === theme.name}
+                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                name={theme.name}
+                bg={theme.bg}
+                topbar={theme.topbar}
+                theme={theme.theme}
+              />
+            );
+          })}
+        </div>
+        {/* Dark Themes  */}
+        <div className="d-flex mt-3">
+          {darkTheme.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={currentTheme === theme.name}
+                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                name={theme.name}
+                bg={theme.bg}
+                topbar={theme.topbar}
+                theme={theme.theme}
+              />
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+const CustomizeThemeOptionsWrapper = (props) => {
+  return createSubscribedElement(CustomizeThemeOptions, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeThemeOptions.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default CustomizeThemeOptionsWrapper;

+ 79 - 0
src/client/js/components/Admin/Customize/CustomizeTitle.jsx

@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import loggerFactory from '@alias/logger';
+
+import AppContainer from '../../../services/AppContainer';
+import AdminCustomizeContainer from '../../../services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+const logger = loggerFactory('growi:Customize');
+
+class CustomizeTitle extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.onClickSubmit = this.onClickSubmit.bind(this);
+  }
+
+  async onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.updateCustomizeTitle();
+      toastSuccess(t('customize_page.update_customTitle_success'));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+    const { currentCustomizeTitle } = adminCustomizeContainer.state;
+
+    return (
+      <React.Fragment>
+        <h2 className="admin-setting-header">{t('customize_page.custom_title')}</h2>
+        <p
+          className="well"
+          // eslint-disable-next-line react/no-danger, max-len
+          dangerouslySetInnerHTML={{ __html: '<code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。<br><code>&#123;&#123;sitename&#125;&#125;</code>がサイト名、<code>&#123;&#123;page&#125;&#125;</code>がページ名またはページパスに置換されます。' }}
+        />
+        {/* TODO i18n */}
+        <div className="help-block">
+          Default Value: <code>&#123;&#123;page&#125;&#125; - &#123;&#123;sitename&#125;&#125;</code>
+          <br />
+          Default Output: <pre><code className="xml">&lt;title&gt;/Sandbox - {'GROWI'}&lt;&#047;title&gt;</code></pre>
+        </div>
+        <div className="form-group">
+          <input
+            className="form-control"
+            defaultValue={currentCustomizeTitle}
+            onChange={(e) => { adminCustomizeContainer.changeCustomizeTitle(e.target.value) }}
+          />
+        </div>
+
+        <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeTitleWrapper = (props) => {
+  return createSubscribedElement(CustomizeTitle, props, [AppContainer, AdminCustomizeContainer]);
+};
+
+CustomizeTitle.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeTitleWrapper);

+ 45 - 0
src/client/js/components/Admin/Customize/ThemeColorBox.jsx

@@ -0,0 +1,45 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+
+class ThemeColorBox extends React.PureComponent {
+
+  render() {
+    const { name } = this.props;
+
+    return (
+      <div
+        id={`theme-option-${name}`}
+        className={`theme-option-container d-flex flex-column align-items-center ${this.props.isSelected && 'active'}`}
+        onClick={this.props.onSelected}
+      >
+        <a
+          className={`m-0 ${name} theme-button`}
+          id={name}
+        >
+          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
+            <g>
+              <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={this.props.bg}></path>
+              <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={this.props.topbar}></path>
+              <path d="M 44 15 L65 15 L65 65 L44 65 L44 15 Z" fill={this.props.theme}></path>
+            </g>
+          </svg>
+        </a>
+        <span className="theme-option-name"><b>{ name }</b></span>
+      </div>
+    );
+  }
+
+}
+
+
+ThemeColorBox.propTypes = {
+  isSelected: PropTypes.bool.isRequired,
+  onSelected: PropTypes.func.isRequired,
+  name: PropTypes.string.isRequired,
+  bg: PropTypes.string.isRequired,
+  topbar: PropTypes.string.isRequired,
+  theme: PropTypes.string.isRequired,
+};
+
+export default ThemeColorBox;

+ 115 - 0
src/client/js/components/Admin/MarkdownSetting/LineBreakSetting.jsx

@@ -0,0 +1,115 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+import loggerFactory from '@alias/logger';
+
+import { createSubscribedElement } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '../../../util/apiNotification';
+
+import AppContainer from '../../../services/AppContainer';
+
+const logger = loggerFactory('growi:LineBreak');
+
+class LineBreakSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const { appContainer } = this.props;
+
+    this.state = {
+      isEnabledLinebreaks: appContainer.config.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: appContainer.config.isEnabledLinebreaksInComments,
+    };
+    this.onChangeEnableLineBreaks = this.onChangeEnableLineBreaks.bind(this);
+    this.onChangeEnableLineBreaksInComments = this.onChangeEnableLineBreaksInComments.bind(this);
+    this.changeLineBreakSettings = this.changeLineBreakSettings.bind(this);
+  }
+
+
+  onChangeEnableLineBreaks() {
+    this.setState({ isEnabledLinebreaks: !this.state.isEnabledLinebreaks });
+  }
+
+  onChangeEnableLineBreaksInComments() {
+    this.setState({ isEnabledLinebreaksInComments: !this.state.isEnabledLinebreaksInComments });
+  }
+
+  async changeLineBreakSettings() {
+    const { appContainer } = this.props;
+    const params = {
+      isEnabledLinebreaks: this.state.isEnabledLinebreaks,
+      isEnabledLinebreaksInComments: this.state.isEnabledLinebreaksInComments,
+    };
+    try {
+      await appContainer.apiPost('/admin/markdown/lineBreaksSetting', { params });
+      toastSuccess('Success change line braek setting');
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }
+
+
+  render() {
+    const { t } = this.props;
+
+    return (
+      <React.Fragment>
+        <div className="row my-3">
+          <div className="form-group">
+            <legend>{ t('markdown_setting.line_break_setting') }</legend>
+            <p className="well">{ t('markdown_setting.line_break_setting_desc') }</p>
+            <fieldset className="row">
+              <div className="form-group">
+                <div className="col-xs-4 text-right">
+                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaks}>
+                    <input type="checkbox" name="isEnabledLinebreaks" checked={this.state.isEnabledLinebreaks} />
+                    <label>
+                      { t('markdown_setting.Enable Line Break') }
+                    </label>
+                    <p className="help-block">{ t('markdown_setting.Enable Line Break desc') }</p>
+                  </div>
+                </div>
+              </div>
+            </fieldset>
+            <fieldset className="row">
+              <div className="form-group my-3">
+                <div className="col-xs-4 text-right">
+                  <div className="checkbox checkbox-success" onChange={this.onChangeEnableLineBreaksInComments}>
+                    <input type="checkbox" name="isEnabledLinebreaksInComments" checked={this.state.isEnabledLinebreaksInComments} />
+                    <label>
+                      { t('markdown_setting.Enable Line Break for comment') }
+                    </label>
+                    <p className="help-block">{ t('markdown_setting.Enable Line Break for comment desc') }</p>
+                  </div>
+                </div>
+              </div>
+            </fieldset>
+          </div>
+          <div className="form-group my-3">
+            <div className="col-xs-offset-4 col-xs-5">
+              <button type="submit" className="btn btn-primary" onClick={this.changeLineBreakSettings}>{ t('Update') }</button>
+            </div>
+          </div>
+        </div>
+      </React.Fragment>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const LineBreakSettingWrapper = (props) => {
+  return createSubscribedElement(LineBreakSetting, props, [AppContainer]);
+};
+
+LineBreakSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+};
+
+export default withTranslation()(LineBreakSettingWrapper);

+ 8 - 0
src/client/js/components/PageEditor/AbstractEditor.jsx

@@ -79,6 +79,14 @@ export default class AbstractEditor extends React.Component {
     throw new Error('this method should be impelemented in subclass');
     throw new Error('this method should be impelemented in subclass');
   }
   }
 
 
+  /**
+   * replace the current line with param 'text'
+   * @param {string} text
+   */
+  replaceLine(text) {
+    throw new Error('this method should be impelemented in subclass');
+  }
+
   /**
   /**
    * insert text
    * insert text
    * @param {string} text
    * @param {string} text

+ 23 - 16
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -49,6 +49,9 @@ require('codemirror/addon/display/placeholder');
 require('codemirror/mode/gfm/gfm');
 require('codemirror/mode/gfm/gfm');
 require('../../util/codemirror/autorefresh.ext');
 require('../../util/codemirror/autorefresh.ext');
 
 
+
+const MARKDOWN_TABLE_ACTIVATED_CLASS = 'markdown-table-activated';
+
 export default class CodeMirrorEditor extends AbstractEditor {
 export default class CodeMirrorEditor extends AbstractEditor {
 
 
   constructor(props) {
   constructor(props) {
@@ -247,6 +250,14 @@ export default class CodeMirrorEditor extends AbstractEditor {
     editor.getDoc().replaceRange(text, this.getBol(), pos);
     editor.getDoc().replaceRange(text, this.getBol(), pos);
   }
   }
 
 
+  /**
+   * @inheritDoc
+   */
+  replaceLine(text) {
+    const editor = this.getCodeMirror();
+    editor.getDoc().replaceRange(text, this.getBol(), this.getEol());
+  }
+
   /**
   /**
    * @inheritDoc
    * @inheritDoc
    */
    */
@@ -442,23 +453,19 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   cursorHandler(editor, event) {
   cursorHandler(editor, event) {
-    const strFromBol = this.getStrFromBol();
-
-    const autoformatTableClass = 'autoformat-markdown-table-activated';
-    const additionalClassSet = this.state.additionalClassSet;
-    const hasCustomClass = additionalClassSet.has(autoformatTableClass);
-    if (mtu.isEndOfLine(editor) && mtu.linePartOfTableRE.test(strFromBol)) {
-      if (!hasCustomClass) {
-        additionalClassSet.add(autoformatTableClass);
-        this.setState({ additionalClassSet });
-      }
+    const { additionalClassSet } = this.state;
+    const hasCustomClass = additionalClassSet.has(MARKDOWN_TABLE_ACTIVATED_CLASS);
+
+    const isInTable = mtu.isInTable(editor);
+
+    if (!hasCustomClass && isInTable) {
+      additionalClassSet.add(MARKDOWN_TABLE_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
     }
     }
-    else {
-      // eslint-disable-next-line no-lonely-if
-      if (hasCustomClass) {
-        additionalClassSet.delete(autoformatTableClass);
-        this.setState({ additionalClassSet });
-      }
+
+    if (hasCustomClass && !isInTable) {
+      additionalClassSet.delete(MARKDOWN_TABLE_ACTIVATED_CLASS);
+      this.setState({ additionalClassSet });
     }
     }
   }
   }
 
 

+ 42 - 19
src/client/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -24,44 +24,67 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
     return false;
     return false;
   }
   }
 
 
+  addRow(cm) {
+    // get lines all of table from current position to beginning of table
+    const strFromBot = mtu.getStrFromBot(cm);
+    let table = MarkdownTable.fromMarkdownString(strFromBot);
+
+    mtu.addRowToMarkdownTable(table);
+
+    const strToEot = mtu.getStrToEot(cm);
+    const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
+    if (tableBottom.table.length > 0) {
+      table = mtu.mergeMarkdownTable([table, tableBottom]);
+    }
+
+    mtu.replaceMarkdownTableWithReformed(cm, table);
+  }
+
+  reformTable(cm) {
+    const tableStr = mtu.getStrFromBot(cm) + mtu.getStrToEot(cm);
+    const table = MarkdownTable.fromMarkdownString(tableStr);
+    mtu.replaceMarkdownTableWithReformed(cm, table);
+  }
+
+  removeRow(editor) {
+    editor.replaceLine('\n');
+  }
+
   /**
   /**
    * @inheritdoc
    * @inheritdoc
    */
    */
-  process(contextName, ...args) {
+  async process(contextName, ...args) {
     const context = Object.assign(args[0]); // clone
     const context = Object.assign(args[0]); // clone
     const editor = context.editor; // AbstractEditor instance
     const editor = context.editor; // AbstractEditor instance
 
 
     // do nothing if editor is not a CodeMirrorEditor
     // do nothing if editor is not a CodeMirrorEditor
     if (editor == null || editor.getCodeMirror() == null) {
     if (editor == null || editor.getCodeMirror() == null) {
-      return Promise.resolve(context);
+      return context;
     }
     }
 
 
     const cm = editor.getCodeMirror();
     const cm = editor.getCodeMirror();
 
 
-    // get strings from BOL(beginning of line) to current position
-    const strFromBol = editor.getStrFromBol();
-
-    if (mtu.isEndOfLine(cm) && mtu.linePartOfTableRE.test(strFromBol)) {
-      // get lines all of table from current position to beginning of table
-      const strFromBot = mtu.getStrFromBot(cm);
-      let table = MarkdownTable.fromMarkdownString(strFromBot);
-
-      mtu.addRowToMarkdownTable(table);
+    const isInTable = mtu.isInTable(cm);
+    const isLastRow = mtu.getStrToEot(cm) === editor.getStrToEol();
 
 
-      const strToEot = mtu.getStrToEot(cm);
-      const tableBottom = MarkdownTable.fromMarkdownString(strToEot);
-      if (tableBottom.table.length > 0) {
-        table = mtu.mergeMarkdownTable([table, tableBottom]);
+    if (isInTable) {
+      // at EOL in the table
+      if (mtu.isEndOfLine(cm)) {
+        this.addRow(cm);
+      }
+      // last empty row
+      else if (isLastRow && mtu.emptyLineOfTableRE.test(editor.getStrFromBol() + editor.getStrToEol())) {
+        this.removeRow(editor);
+      }
+      else {
+        this.reformTable(cm);
       }
       }
-
-      mtu.replaceMarkdownTableWithReformed(cm, table);
 
 
       // report to manager that handling was done
       // report to manager that handling was done
       context.handlers.push(this.className);
       context.handlers.push(this.className);
+      return context;
     }
     }
 
 
-    // resolve
-    return Promise.resolve(context);
   }
   }
 
 
 }
 }

+ 4 - 12
src/client/js/components/PageEditor/MarkdownTableUtil.js

@@ -7,14 +7,14 @@ class MarkdownTableUtil {
 
 
   constructor() {
   constructor() {
     // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
     // https://github.com/markdown-it/markdown-it/blob/d29f421927e93e88daf75f22089a3e732e195bd2/lib/rules_block/table.js#L83
-    // https://regex101.com/r/7BN2fR/7
     this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
     this.tableAlignmentLineRE = /^[-:|][-:|\s]*$/;
     this.tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
     this.tableAlignmentLineNegRE = /^[^-:]*$/; // it is need to check to ignore empty row which is matched above RE
-    this.linePartOfTableRE = /^\|[^\r\n]*|[^\r\n]*\|$|([^|\r\n]+\|[^|\r\n]*)+/; // own idea
+    // https://regex101.com/r/7BN2fR/10
+    this.linePartOfTableRE = /^([^\r\n|]*)\|(([^\r\n|]*\|)+)$/;
+    // https://regex101.com/r/1UuWBJ/3
+    this.emptyLineOfTableRE = /^([^\r\n|]*)\|((\s*\|)+)$/;
 
 
-    this.getBot = this.getBot.bind(this);
     this.getEot = this.getEot.bind(this);
     this.getEot = this.getEot.bind(this);
-    this.getBol = this.getBol.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrFromBot = this.getStrFromBot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
     this.getStrToEot = this.getStrToEot.bind(this);
     this.isInTable = this.isInTable.bind(this);
     this.isInTable = this.isInTable.bind(this);
@@ -67,14 +67,6 @@ class MarkdownTableUtil {
     return { line: eotLine, ch: lineLength };
     return { line: eotLine, ch: lineLength };
   }
   }
 
 
-  /**
-   * return the postion of the BOL(beginning of line)
-   */
-  getBol(editor) {
-    const curPos = editor.getCursor();
-    return { line: curPos.line, ch: 0 };
-  }
-
   /**
   /**
    * return strings from BOT(beginning of table) to the cursor position
    * return strings from BOT(beginning of table) to the cursor position
    */
    */

+ 7 - 0
src/client/js/components/PageEditor/TextAreaEditor.jsx

@@ -149,6 +149,13 @@ export default class TextAreaEditor extends AbstractEditor {
     this.replaceValue(text, this.getBolPos(), lowerPos);
     this.replaceValue(text, this.getBolPos(), lowerPos);
   }
   }
 
 
+  /**
+   * @inheritDoc
+   */
+  replaceLine(text) {
+    this.replaceValue(text, this.getBolPos(), this.getEolPos());
+  }
+
   getBolPos() {
   getBolPos() {
     const currentPos = this.textarea.selectionStart;
     const currentPos = this.textarea.selectionStart;
     return this.textarea.value.lastIndexOf('\n', currentPos - 1) + 1;
     return this.textarea.value.lastIndexOf('\n', currentPos - 1) + 1;

+ 302 - 0
src/client/js/services/AdminCustomizeContainer.js

@@ -0,0 +1,302 @@
+import { Container } from 'unstated';
+
+import loggerFactory from '@alias/logger';
+
+import { toastError } from '../util/apiNotification';
+
+// eslint-disable-next-line no-unused-vars
+const logger = loggerFactory('growi:services:AdminCustomizeContainer');
+
+/**
+ * Service container for admin customize setting page (Customize.jsx)
+ * @extends {Container} unstated Container
+ */
+export default class AdminCustomizeContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+
+    this.state = {
+      retrieveError: null,
+      currentTheme: '',
+      currentLayout: '',
+      currentBehavior: '',
+      isEnabledTimeline: false,
+      isSavedStatesOfTabChanges: false,
+      isEnabledAttachTitleHeader: false,
+      currentRecentCreatedLimit: 10,
+      currentHighlightJsStyleId: '',
+      isHighlightJsStyleBorderEnabled: false,
+      currentCustomizeTitle: '',
+      currentCustomizeHeader: '',
+      currentCustomizeCss: '',
+      currentCustomizeScript: '',
+      /* eslint-disable quote-props, no-multi-spaces */
+      highlightJsCssSelectorOptions: {
+        'github':           { name: '[Light] GitHub',         border: false },
+        'github-gist':      { name: '[Light] GitHub Gist',    border: true },
+        'atom-one-light':   { name: '[Light] Atom One Light', border: true },
+        'xcode':            { name: '[Light] Xcode',          border: true },
+        'vs':               { name: '[Light] Vs',             border: true },
+        'atom-one-dark':    { name: '[Dark] Atom One Dark',   border: false },
+        'hybrid':           { name: '[Dark] Hybrid',          border: false },
+        'monokai':          { name: '[Dark] Monokai',         border: false },
+        'tomorrow-night':   { name: '[Dark] Tomorrow Night',  border: false },
+        'vs2015':           { name: '[Dark] Vs 2015',         border: false },
+      },
+      /* eslint-enable quote-props, no-multi-spaces */
+    };
+
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'AdminCustomizeContainer';
+  }
+
+  /**
+   * retrieve customize data
+   */
+  async retrieveCustomizeData() {
+    try {
+      const response = await this.appContainer.apiv3.get('/customize-setting/');
+      const { customizeParams } = response.data;
+
+      this.setState({
+        currentTheme: customizeParams.themeType,
+        currentLayout: customizeParams.layoutType,
+        currentBehavior: customizeParams.behaviorType,
+        isEnabledTimeline: customizeParams.isEnabledTimeline,
+        isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
+        isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
+        currentRecentCreatedLimit: customizeParams.recentCreatedLimit,
+        currentHighlightJsStyleId: customizeParams.styleName,
+        isHighlightJsStyleBorderEnabled: customizeParams.styleBorder,
+        currentCustomizeTitle: customizeParams.customizeTitle,
+        currentCustomizeHeader: customizeParams.customizeHeader,
+        currentCustomizeCss: customizeParams.customizeCss,
+        currentCustomizeScript: customizeParams.customizeScript,
+      });
+
+      // search style name from object for display
+      this.setState({ currentHighlightJsStyleName: this.state.highlightJsCssSelectorOptions[customizeParams.styleName].name });
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to fetch data'));
+    }
+  }
+
+  /**
+   * Switch layoutType
+   */
+  switchLayoutType(lauoutName) {
+    this.setState({ currentLayout: lauoutName });
+  }
+
+  /**
+   * Switch themeType
+   */
+  switchThemeType(themeName) {
+    // can't choose theme when kibela
+    if (this.state.currentLayout === 'kibela') {
+      return;
+    }
+    this.setState({ currentTheme: themeName });
+  }
+
+  /**
+   * Switch behaviorType
+   */
+  switchBehaviorType(behaviorName) {
+    this.setState({ currentBehavior: behaviorName });
+  }
+
+  /**
+   * Switch enabledTimeLine
+   */
+  switchEnableTimeline() {
+    this.setState({ isEnabledTimeline:  !this.state.isEnabledTimeline });
+  }
+
+  /**
+   * Switch savedStatesOfTabChanges
+   */
+  switchSavedStatesOfTabChanges() {
+    this.setState({ isSavedStatesOfTabChanges:  !this.state.isSavedStatesOfTabChanges });
+  }
+
+  /**
+   * Switch enabledAttachTitleHeader
+   */
+  switchEnabledAttachTitleHeader() {
+    this.setState({ isEnabledAttachTitleHeader:  !this.state.isEnabledAttachTitleHeader });
+  }
+
+  /**
+   * Switch recentCreatedLimit
+   */
+  switchRecentCreatedLimit(value) {
+    this.setState({ currentRecentCreatedLimit: value });
+  }
+
+  /**
+   * Switch highlightJsStyle
+   */
+  switchHighlightJsStyle(styleId, styleName, isBorderEnable) {
+    this.setState({ currentHighlightJsStyleId: styleId });
+    this.setState({ currentHighlightJsStyleName: styleName });
+    // recommended settings are applied
+    this.setState({ isHighlightJsStyleBorderEnabled: isBorderEnable });
+  }
+
+  /**
+   * Switch highlightJsStyleBorder
+   */
+  switchHighlightJsStyleBorder() {
+    this.setState({ isHighlightJsStyleBorderEnabled: !this.state.isHighlightJsStyleBorderEnabled });
+  }
+
+  /**
+   * Change customize Title
+   */
+  changeCustomizeTitle(inputValue) {
+    this.setState({ currentCustomizeTitle: inputValue });
+  }
+
+  /**
+   * Change customize Html header
+   */
+  changeCustomizeHeader(inputValue) {
+    this.setState({ currentCustomizeHeader: inputValue });
+  }
+
+  /**
+   * Change customize css
+   */
+  changeCustomizeCss(inputValue) {
+    this.setState({ currentCustomizeCss: inputValue });
+  }
+
+  /**
+   * Change customize script
+   */
+  changeCustomizeScript(inpuValue) {
+    this.setState({ currentCustomizeScript: inpuValue });
+  }
+
+
+  /**
+   * Update layout
+   * @memberOf AdminCustomizeContainer
+   * @return {Array} Appearance
+   */
+  async updateCustomizeLayoutAndTheme() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/layoutTheme', {
+      layoutType: this.state.currentLayout,
+      themeType: this.state.currentTheme,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update behavior
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Behavior
+   */
+  async updateCustomizeBehavior() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/behavior', {
+      behaviorType: this.state.currentBehavior,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update function
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Functions
+   */
+  async updateCustomizeFunction() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/function', {
+      isEnabledTimeline: this.state.isEnabledTimeline,
+      isSavedStatesOfTabChanges: this.state.isSavedStatesOfTabChanges,
+      isEnabledAttachTitleHeader: this.state.isEnabledAttachTitleHeader,
+      recentCreatedLimit: this.state.currentRecentCreatedLimit,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update code highlight
+   * @memberOf AdminCustomizeContainer
+   * @return {Array} Code highlight
+   */
+  async updateHighlightJsStyle() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/highlight', {
+      highlightJsStyle: this.state.currentHighlightJsStyleId,
+      highlightJsStyleBorder: this.state.isHighlightJsStyleBorderEnabled,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customTitle
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize title
+   */
+  async updateCustomizeTitle() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-title', {
+      customizeTitle: this.state.currentCustomizeTitle,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customHeader
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize html header
+   */
+  async updateCustomizeHeader() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-header', {
+      customizeHeader: this.state.currentCustomizeHeader,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customCss
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize css
+   */
+  async updateCustomizeCss() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-css', {
+      customizeCss: this.state.currentCustomizeCss,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+  /**
+   * Update customize script
+   * @memberOf AdminCustomizeContainer
+   * @return {string} Customize scripts
+   */
+  async updateCustomizeScript() {
+    const response = await this.appContainer.apiv3.put('/customize-setting/customize-script', {
+      customizeScript: this.state.currentCustomizeScript,
+    });
+    const { customizedParams } = response.data;
+    return customizedParams;
+  }
+
+}

+ 4 - 0
src/client/styles/scss/_admin.scss

@@ -29,6 +29,10 @@
     }
     }
   }
   }
 
 
+  .admin-setting-header {
+    border-bottom: 1px solid #dee2e6;
+  }
+
   .admin-security {
   .admin-security {
     .passport-logo {
     .passport-logo {
       height: 32px;
       height: 32px;

+ 1 - 1
src/client/styles/scss/_on-edit.scss

@@ -181,7 +181,7 @@ body.on-edit {
       }
       }
 
 
       // add icon on cursor
       // add icon on cursor
-      .autoformat-markdown-table-activated .CodeMirror-cursor {
+      .markdown-table-activated .CodeMirror-cursor {
         &:after {
         &:after {
           position: relative;
           position: relative;
           top: -1.1em;
           top: -1.1em;

+ 0 - 9
src/server/form/index.js

@@ -25,15 +25,6 @@ module.exports = {
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportGitHub: require('./admin/securityPassportGitHub'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),
     securityPassportTwitter: require('./admin/securityPassportTwitter'),
     securityPassportOidc: require('./admin/securityPassportOidc'),
     securityPassportOidc: require('./admin/securityPassportOidc'),
-    customcss: require('./admin/customcss'),
-    customscript: require('./admin/customscript'),
-    customheader: require('./admin/customheader'),
-    customtheme: require('./admin/customtheme'),
-    customtitle: require('./admin/customtitle'),
-    custombehavior: require('./admin/custombehavior'),
-    customlayout: require('./admin/customlayout'),
-    customfeatures: require('./admin/customfeatures'),
-    customhighlightJsStyle: require('./admin/customhighlightJsStyle'),
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackIwhSetting: require('./admin/slackIwhSetting'),
     slackSetting: require('./admin/slackSetting'),
     slackSetting: require('./admin/slackSetting'),
     userGroupCreate: require('./admin/userGroupCreate'),
     userGroupCreate: require('./admin/userGroupCreate'),

+ 7 - 0
src/server/models/config.js

@@ -174,6 +174,7 @@ module.exports = function(crowi) {
       },
       },
       behaviorType: crowi.configManager.getConfig('crowi', 'customize:behavior'),
       behaviorType: crowi.configManager.getConfig('crowi', 'customize:behavior'),
       layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
       layoutType: crowi.configManager.getConfig('crowi', 'customize:layout'),
+      themeType: crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaks: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
       isEnabledLinebreaksInComments: crowi.configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
       pageBreakSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
       pageBreakSeparator: crowi.configManager.getConfig('markdown', 'markdown:presentation:pageBreakSeparator'),
@@ -183,8 +184,14 @@ module.exports = function(crowi) {
       xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
       xssOption: crowi.configManager.getConfig('markdown', 'markdown:xss:option'),
       tagWhiteList: crowi.xssService.getTagWhiteList(),
       tagWhiteList: crowi.xssService.getTagWhiteList(),
       attrWhiteList: crowi.xssService.getAttrWhiteList(),
       attrWhiteList: crowi.xssService.getAttrWhiteList(),
+      highlightJsStyle: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+      customizeTitle: crowi.configManager.getConfig('crowi', 'customize:title'),
+      customizeHeader: crowi.configManager.getConfig('crowi', 'customize:header'),
+      customizeCss: crowi.configManager.getConfig('crowi', 'customize:css'),
       isSavedStatesOfTabChanges: crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
       isSavedStatesOfTabChanges: crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
+      isEnabledAttachTitleHeader: crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
+      customizeScript: crowi.configManager.getConfig('crowi', 'customize:script'),
       hasSlackConfig: crowi.slackNotificationService.hasSlackConfig(),
       hasSlackConfig: crowi.slackNotificationService.hasSlackConfig(),
       env: {
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
         PLANTUML_URI: env.PLANTUML_URI || null,

+ 1 - 15
src/server/routes/admin.js

@@ -16,7 +16,6 @@ module.exports = function(crowi, app) {
     configManager,
     configManager,
     aclService,
     aclService,
     slackNotificationService,
     slackNotificationService,
-    customizeService,
     exportService,
     exportService,
   } = crowi;
   } = crowi;
 
 
@@ -142,6 +141,7 @@ module.exports = function(crowi, app) {
   actions.customize.index = function(req, res) {
   actions.customize.index = function(req, res) {
     const settingForm = configManager.getConfigByPrefix('crowi', 'customize:');
     const settingForm = configManager.getConfigByPrefix('crowi', 'customize:');
 
 
+    // TODO delete after apiV3
     /* eslint-disable quote-props, no-multi-spaces */
     /* eslint-disable quote-props, no-multi-spaces */
     const highlightJsCssSelectorOptions = {
     const highlightJsCssSelectorOptions = {
       'github':           { name: '[Light] GitHub',         border: false },
       'github':           { name: '[Light] GitHub',         border: false },
@@ -852,20 +852,6 @@ module.exports = function(crowi, app) {
     return res.json({ status: true });
     return res.json({ status: true });
   };
   };
 
 
-  actions.api.customizeSetting = async function(req, res) {
-    const form = req.form.settingForm;
-
-    if (req.form.isValid) {
-      debug('form content', form);
-      await configManager.updateConfigsInTheSameNamespace('crowi', form);
-      customizeService.initCustomCss();
-      customizeService.initCustomTitle();
-
-      return res.json({ status: true });
-    }
-
-    return res.json({ status: false, message: req.form.errors.join('\n') });
-  };
 
 
   // app.post('/_api/admin/notifications.add'    , admin.api.notificationAdd);
   // app.post('/_api/admin/notifications.add'    , admin.api.notificationAdd);
   actions.api.notificationAdd = function(req, res) {
   actions.api.notificationAdd = function(req, res) {

+ 493 - 0
src/server/routes/apiv3/customize-setting.js

@@ -0,0 +1,493 @@
+/* eslint-disable no-unused-vars */
+const loggerFactory = require('@alias/logger');
+
+const logger = loggerFactory('growi:routes:apiv3:customize-setting');
+
+const express = require('express');
+
+const router = express.Router();
+
+const { body } = require('express-validator/check');
+const ErrorV3 = require('../../models/vo/error-apiv3');
+
+/**
+ * @swagger
+ *  tags:
+ *    name: CustomizeSetting
+ */
+
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      CustomizeLayoutTheme:
+ *        type: object
+ *        properties:
+ *          layoutType:
+ *            type: string
+ *          themeType:
+ *            type: string
+ *      CustomizeBehavior:
+ *        type: object
+ *        properties:
+ *          behaviorType:
+ *            type: string
+ *      CustomizeFunction:
+ *        type: object
+ *        properties:
+ *          isEnabledTimeline:
+ *            type: boolean
+ *          isSavedStatesOfTabChanges:
+ *            type: boolean
+ *          isEnabledAttachTitleHeader:
+ *            type: boolean
+ *          recentCreatedLimit:
+ *            type: number
+ *      CustomizeHighlight:
+ *        type: object
+ *        properties:
+ *          styleName:
+ *            type: string
+ *          styleBorder:
+ *            type: boolean
+ *      CustomizeTitle:
+ *        type: object
+ *        properties:
+ *          customizeTitle:
+ *            type: string
+ *      CustomizeHeader:
+ *        type: object
+ *        properties:
+ *          customizeHeader:
+ *            type: string
+ *      CustomizeCss:
+ *        type: object
+ *        properties:
+ *          customizeCss:
+ *            type: string
+ *      CustomizeScript:
+ *        type: object
+ *        properties:
+ *          customizeScript:
+ *            type: string
+ */
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('../../middleware/login-required')(crowi);
+  const adminRequired = require('../../middleware/admin-required')(crowi);
+  const csrf = require('../../middleware/csrf')(crowi);
+  const { customizeService } = crowi;
+
+
+  const { ApiV3FormValidator } = crowi.middlewares;
+
+  const validator = {
+    layoutTheme: [
+      body('layoutType').isString().isIn(['growi', 'kibela', 'crowi']),
+      body('themeType').isString().isIn([
+        'default', 'nature', 'mono-blue', 'wood', 'island', 'christmas', 'antarctic', 'default-dark', 'future', 'blue-night', 'halloween',
+      ]),
+    ],
+    behavior: [
+      body('behaviorType').isString().isIn(['growi', 'crowi-plus']),
+    ],
+    function: [
+      body('isEnabledTimeline').isBoolean(),
+      body('isSavedStatesOfTabChanges').isBoolean(),
+      body('isEnabledAttachTitleHeader').isBoolean(),
+      body('recentCreatedLimit').isInt().isInt({ min: 1, max: 1000 }),
+    ],
+    customizeTitle: [
+      body('customizeTitle').isString(),
+    ],
+    customizeHeader: [
+      body('customizeHeader').isString(),
+    ],
+    highlight: [
+      body('highlightJsStyle').isString().isIn([
+        'github', 'github-gist', 'atom-one-light', 'xcode', 'vs', 'atom-one-dark', 'hybrid', 'monokai', 'tororrow-night', 'vs2015',
+      ]),
+      body('highlightJsStyleBorder').isBoolean(),
+    ],
+    customizeCss: [
+      body('customizeCss').isString(),
+    ],
+    customizeScript: [
+      body('customizeScript').isString(),
+    ],
+  };
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/:
+   *      get:
+   *        tags: [CustomizeSetting]
+   *        description: Get customize paramators
+   *        responses:
+   *          200:
+   *            description: params of customize
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  $ref: '#/components/schemas/CustomizeLayoutTheme'
+   *                  $ref: '#/components/schemas/CustomizeBehavior'
+   *                  $ref: '#/components/schemas/CustomizeFunction'
+   *                  $ref: '#/components/schemas/CustomizeHighlight'
+   *                  $ref: '#/components/schemas/CustomizeTitle'
+   *                  $ref: '#/components/schemas/CustomizeHeader'
+   *                  $ref: '#/components/schemas/CustomizeCss'
+   *                  $ref: '#/components/schemas/CustomizeScript'
+   */
+  router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
+
+    const customizeParams = {
+      layoutType: await crowi.configManager.getConfig('crowi', 'customize:layout'),
+      themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
+      behaviorType: await crowi.configManager.getConfig('crowi', 'customize:behavior'),
+      isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
+      isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
+      isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
+      recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+      styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
+      styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+      customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
+      customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
+      customizeCss: await crowi.configManager.getConfig('crowi', 'customize:header'),
+      customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
+    };
+
+    return res.apiv3({ customizeParams });
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/layoutTheme:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update layout and theme
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeLayoutTheme'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update layout and theme
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeLayoutTheme'
+   */
+  router.put('/layoutTheme', loginRequiredStrictly, adminRequired, csrf, validator.layoutTheme, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:layout': req.body.layoutType,
+      'customize:theme': req.body.themeType,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        layoutType: await crowi.configManager.getConfig('crowi', 'customize:layout'),
+        themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating layout and theme';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-layoutTheme-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/behavior:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update behavior
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeBehavior'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update behavior
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeBehavior'
+   */
+  router.put('/behavior', loginRequiredStrictly, adminRequired, csrf, validator.behavior, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:behavior': req.body.behaviorType,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        behaviorType: await crowi.configManager.getConfig('crowi', 'customize:behavior'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating behavior';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-behavior-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/function:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update function
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeFunction'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update function
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeFunction'
+   */
+  router.put('/function', loginRequiredStrictly, adminRequired, csrf, validator.function, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:isEnabledTimeline': req.body.isEnabledTimeline,
+      'customize:isSavedStatesOfTabChanges': req.body.isSavedStatesOfTabChanges,
+      'customize:isEnabledAttachTitleHeader': req.body.isEnabledAttachTitleHeader,
+      'customize:showRecentCreatedNumber': req.body.recentCreatedLimit,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
+        isSavedStatesOfTabChanges: await crowi.configManager.getConfig('crowi', 'customize:isSavedStatesOfTabChanges'),
+        isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
+        recentCreatedLimit: await crowi.configManager.getConfig('crowi', 'customize:showRecentCreatedNumber'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating function';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-function-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/highlight:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update highlight
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeHighlight'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update highlight
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeHighlight'
+   */
+  router.put('/highlight', loginRequiredStrictly, adminRequired, csrf, validator.highlight, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:highlightJsStyle': req.body.highlightJsStyle,
+      'customize:highlightJsStyleBorder': req.body.highlightJsStyleBorder,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
+        styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating highlight';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-highlight-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/customizeTitle:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update customizeTitle
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeTitle'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update customizeTitle
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeTitle'
+   */
+  router.put('/customize-title', loginRequiredStrictly, adminRequired, csrf, validator.customizeTitle, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:title': req.body.customizeTitle,
+    };
+
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
+      };
+      customizeService.initCustomTitle();
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating customizeTitle';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeTitle-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/customizeHeader:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update customizeHeader
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeHeader'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update customize header
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeHeader'
+   */
+  router.put('/customize-header', loginRequiredStrictly, adminRequired, csrf, validator.customizeHeader, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:header': req.body.customizeHeader,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        customizeHeader: await crowi.configManager.getConfig('crowi', 'customize:header'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating customizeHeader';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeHeader-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/customizeCss:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update customizeCss
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeCss'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update customize css
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeCss'
+   */
+  router.put('/customize-css', loginRequiredStrictly, adminRequired, csrf, validator.customizeCss, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:css': req.body.customizeCss,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        customizeCss: await crowi.configManager.getConfig('crowi', 'customize:css'),
+      };
+      customizeService.initCustomCss();
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating customizeCss';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeCss-failed'));
+    }
+  });
+
+  /**
+   * @swagger
+   *
+   *    /customize-setting/customizeScript:
+   *      put:
+   *        tags: [CustomizeSetting]
+   *        description: Update customizeScript
+   *        requestBody:
+   *          required: true
+   *          content:
+   *            application/json:
+   *              schama:
+   *                $ref: '#/components/schemas/CustomizeScript'
+   *      responses:
+   *        200:
+   *          description: Succeeded to update customize script
+   *          content:
+   *            application/json:
+   *              schema:
+   *                $ref: '#/components/schemas/CustomizeScript'
+   */
+  router.put('/customize-script', loginRequiredStrictly, adminRequired, csrf, validator.customizeScript, ApiV3FormValidator, async(req, res) => {
+    const requestParams = {
+      'customize:script': req.body.customizeScript,
+    };
+    try {
+      await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
+      const customizedParams = {
+        customizeScript: await crowi.configManager.getConfig('crowi', 'customize:script'),
+      };
+      return res.apiv3({ customizedParams });
+    }
+    catch (err) {
+      const msg = 'Error occurred in updating customizeScript';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'update-customizeScript-failed'));
+    }
+  });
+
+  return router;
+};

+ 2 - 0
src/server/routes/apiv3/index.js

@@ -15,6 +15,8 @@ module.exports = (crowi) => {
 
 
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
   router.use('/markdown-setting', require('./markdown-setting')(crowi));
 
 
+  router.use('/customize-setting', require('./customize-setting')(crowi));
+
   router.use('/users', require('./users')(crowi));
   router.use('/users', require('./users')(crowi));
 
 
   router.use('/user-groups', require('./user-group')(crowi));
   router.use('/user-groups', require('./user-group')(crowi));

+ 47 - 56
src/server/routes/apiv3/markdown-setting.js

@@ -38,47 +38,45 @@ const validator = {
  *
  *
  *  components:
  *  components:
  *    schemas:
  *    schemas:
- *      CustomizeParams:
+ *      LineBreakParams:
  *        type: object
  *        type: object
- *          LineBreakParams:
- *            type: object
- *            properties:
- *              isEnabledLinebreaks:
- *                type: boolean
- *                description: enable lineBreak
- *              isEnabledLinebreaksInComments:
- *                type: boolean
- *                description: enable lineBreak in comment
- *          PresentationParams:
- *            type: object
- *            properties:
- *              pageBreakSeparator:
- *                type: number
- *                description: number of pageBreakSeparator
- *              pageBreakCustomSeparator:
- *                type: string
- *                description: string of pageBreakCustomSeparator
- *          XssParams:
- *            type: object
- *            properties:
- *              isEnabledPrevention:
- *                type: boolean
- *                description: enable xss
- *              xssOption:
- *                type: number
- *                description: number of xss option
- *              tagWhiteList:
- *                type: array
- *                description: array of tag whiteList
- *                items:
- *                  type: string
- *                  description: tag whitelist
- *              attrWhiteList:
- *                type: array
- *                description: array of attr whiteList
- *                items:
- *                  type: string
- *                  description: attr whitelist
+ *        properties:
+ *          isEnabledLinebreaks:
+ *            type: boolean
+ *            description: enable lineBreak
+ *          isEnabledLinebreaksInComments:
+ *            type: boolean
+ *            description: enable lineBreak in comment
+ *      PresentationParams:
+ *        type: object
+ *        properties:
+ *          pageBreakSeparator:
+ *            type: number
+ *            description: number of pageBreakSeparator
+ *          pageBreakCustomSeparator:
+ *            type: string
+ *            description: string of pageBreakCustomSeparator
+ *      XssParams:
+ *        type: object
+ *        properties:
+ *          isEnabledPrevention:
+ *            type: boolean
+ *            description: enable xss
+ *          xssOption:
+ *            type: number
+ *            description: number of xss option
+ *          tagWhiteList:
+ *            type: array
+ *            description: array of tag whiteList
+ *            items:
+ *              type: string
+ *              description: tag whitelist
+ *          attrWhiteList:
+ *            type: array
+ *            description: array of attr whiteList
+ *            items:
+ *              type: string
+ *              description: attr whitelist
  */
  */
 
 
 module.exports = (crowi) => {
 module.exports = (crowi) => {
@@ -103,7 +101,9 @@ module.exports = (crowi) => {
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
    *                    markdonwParams:
    *                    markdonwParams:
-   *                      $ref: '#/components/schemas/CustomizeParams'
+   *                      $ref: '#/components/schemas/LineBreakParams'
+   *                      $ref: '#/components/schemas/PresentationParams'
+   *                      $ref: '#/components/schemas/XssParams'
    */
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
     const markdownParams = {
     const markdownParams = {
@@ -132,17 +132,14 @@ module.exports = (crowi) => {
    *          content:
    *          content:
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
-   *                type: object
-   *                properties:
-   *                  $ref: '#/components/schemas/CustomizeParams/LineBreakParams'
+   *                $ref: '#/components/schemas/LineBreakParams'
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to update lineBreak setting
    *            description: Succeeded to update lineBreak setting
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  properties:
-   *                    $ref: '#/components/schemas/CustomizeParams/LineBreakParams'
+  *                   $ref: '#/components/schemas/LineBreakParams'
    */
    */
   router.put('/lineBreak', loginRequiredStrictly, adminRequired, csrf, validator.lineBreak, ApiV3FormValidator, async(req, res) => {
   router.put('/lineBreak', loginRequiredStrictly, adminRequired, csrf, validator.lineBreak, ApiV3FormValidator, async(req, res) => {
 
 
@@ -179,17 +176,14 @@ module.exports = (crowi) => {
    *          content:
    *          content:
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
-   *                type: object
-   *                properties:
-   *                  $ref: '#/components/schemas/CustomizeParams/PresentationParams'
+   *                $ref: '#/components/schemas/PresentationParams'
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to update presentation setting
    *            description: Succeeded to update presentation setting
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  properties:
-   *                    $ref: '#/components/schemas/CustomizeParams/PresentationParams'
+   *                  $ref: '#/components/schemas/PresentationParams'
    */
    */
   router.put('/presentation', loginRequiredStrictly, adminRequired, csrf, validator.presentationSetting, ApiV3FormValidator, async(req, res) => {
   router.put('/presentation', loginRequiredStrictly, adminRequired, csrf, validator.presentationSetting, ApiV3FormValidator, async(req, res) => {
     if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
     if (req.body.pageBreakSeparator === 3 && req.body.pageBreakCustomSeparator === '') {
@@ -229,17 +223,14 @@ module.exports = (crowi) => {
    *          content:
    *          content:
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
-   *                type: object
-   *                properties:
-   *                  $ref: '#/components/schemas/CustomizeParams/XssParams'
+   *                $ref: '#/components/schemas/XssParams'
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to update xss setting
    *            description: Succeeded to update xss setting
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  properties:
-   *                    $ref: '#/components/schemas/CustomizeParams/XssParams'
+   *                  $ref: '#/components/schemas/XssParams'
    */
    */
   router.put('/xss', loginRequiredStrictly, adminRequired, csrf, validator.xssSetting, ApiV3FormValidator, async(req, res) => {
   router.put('/xss', loginRequiredStrictly, adminRequired, csrf, validator.xssSetting, ApiV3FormValidator, async(req, res) => {
     if (req.body.isEnabledXss && req.body.xssOption == null) {
     if (req.body.isEnabledXss && req.body.xssOption == null) {

+ 1 - 2
src/server/routes/comment.js

@@ -169,7 +169,6 @@ module.exports = function(crowi, app) {
     const { commentForm } = req.body;
     const { commentForm } = req.body;
 
 
     const pageId = commentForm.page_id;
     const pageId = commentForm.page_id;
-    const revisionId = commentForm.revision_id;
     const comment = commentForm.comment;
     const comment = commentForm.comment;
     const isMarkdown = commentForm.is_markdown;
     const isMarkdown = commentForm.is_markdown;
     const commentId = commentForm.comment_id;
     const commentId = commentForm.comment_id;
@@ -188,7 +187,7 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     // check whether accessible
     // check whether accessible
-    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user._id, revisionId, comment, isMarkdown, req.user);
+    const isAccessible = await Page.isAccessiblePageByViewer(pageId, req.user);
     if (!isAccessible) {
     if (!isAccessible) {
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
       return res.json(ApiResponse.error('Current user is not accessible to this page.'));
     }
     }

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

@@ -93,17 +93,8 @@ module.exports = function(crowi, app) {
   // markdown admin
   // markdown admin
   app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
   app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
 
 
-  // markdown admin
+  // customize admin
   app.get('/admin/customize'                , loginRequiredStrictly , adminRequired , admin.customize.index);
   app.get('/admin/customize'                , loginRequiredStrictly , adminRequired , admin.customize.index);
-  app.post('/_api/admin/customize/css'      , loginRequiredStrictly , adminRequired , csrf, form.admin.customcss, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/script'   , loginRequiredStrictly , adminRequired , csrf, form.admin.customscript, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/header'   , loginRequiredStrictly , adminRequired , csrf, form.admin.customheader, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/theme'    , loginRequiredStrictly , adminRequired , csrf, form.admin.customtheme, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/title'    , loginRequiredStrictly , adminRequired , csrf, form.admin.customtitle, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/behavior' , loginRequiredStrictly , adminRequired , csrf, form.admin.custombehavior, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/layout'   , loginRequiredStrictly , adminRequired , csrf, form.admin.customlayout, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/features' , loginRequiredStrictly , adminRequired , csrf, form.admin.customfeatures, admin.api.customizeSetting);
-  app.post('/_api/admin/customize/highlightJsStyle' , loginRequiredStrictly , adminRequired , csrf, form.admin.customhighlightJsStyle, admin.api.customizeSetting);
 
 
   // search admin
   // search admin
   app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);
   app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);

+ 20 - 634
src/server/views/admin/customize.html

@@ -1,46 +1,29 @@
-{% extends '../layout/admin.html' %}
-
-{% block html_title %}{{ customizeService.generateCustomTitle(t('Customize')) }} {% endblock %}
-
-{% block theme_css_block %}
-  {% set themeName = getConfig('crowi', 'customize:theme') %}
-  {% if env === 'development' %}
-    <script src="{{ webpack_asset('styles/theme-' + themeName + '.js') }}"></script>
-  {% else %}
-  <link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-' + themeName + '.css') }}">
-  {% endif %}
-{% endblock %}
-
-{% block html_additional_headers %}
-  {% parent %}
-  <!-- CodeMirror -->
-  {{ cdnStyleTag('jquery-ui') }}
-  <style>
-    .CodeMirror {
-      border: 1px solid #eee;
-    }
-  </style>
-{% endblock %}
-
-{% block content_header %}
+{% extends '../layout/admin.html' %} {% block html_title %}{{ customizeService.generateCustomTitle(t('Customize')) }} {% endblock %} {% block theme_css_block %}
+{% set themeName = getConfig('crowi', 'customize:theme') %} {% if env === 'development' %}
+<script src="{{ webpack_asset('styles/theme-' + themeName + '.js') }}"></script>
+{% else %}
+<link rel="stylesheet" id="jssDefault" {# append id for theme selector #} href="{{ webpack_asset('styles/theme-' + themeName + '.css') }}" />
+{% endif %} {% endblock %} {% block html_additional_headers %} {% parent %}
+<!-- CodeMirror -->
+{{ cdnStyleTag('jquery-ui') }}
+<style>
+  .CodeMirror {
+    border: 1px solid #eee;
+  }
+</style>
+{% endblock %} {% block content_header %}
 <div class="header-wrap">
 <div class="header-wrap">
   <header id="page-header">
   <header id="page-header">
-    <h1 id="admin-title" class="title">{{ t('Customize') }} </h1>
+    <h1 id="admin-title" class="title">{{ t('Customize') }}</h1>
   </header>
   </header>
 </div>
 </div>
-{% endblock %}
-
-{% block content_main %}
+{% endblock %} {% block content_main %}
 <div class="content-main admin-customize">
 <div class="content-main admin-customize">
-  {% set smessage = req.flash('successMessage') %}
-  {% if smessage.length %}
+  {% set smessage = req.flash('successMessage') %} {% if smessage.length %}
   <div class="alert alert-success">
   <div class="alert alert-success">
     {{ smessage }}
     {{ smessage }}
   </div>
   </div>
-  {% endif %}
-
-  {% set emessage = req.flash('errorMessage') %}
-  {% if emessage.length %}
+  {% endif %} {% set emessage = req.flash('errorMessage') %} {% if emessage.length %}
   <div class="alert alert-danger">
   <div class="alert alert-danger">
     {{ emessage }}
     {{ emessage }}
   </div>
   </div>
@@ -51,605 +34,8 @@
       {% include './widget/menu.html' with {current: 'customize'} %}
       {% include './widget/menu.html' with {current: 'customize'} %}
     </div>
     </div>
     <div class="col-md-9">
     <div class="col-md-9">
-      <!-- TODO reactify admin -->
-      <!-- <div id="admin-customize"></div> -->
-
-      <form action="/_api/admin/customize/layout" method="post" class="form-horizontal" id="customlayoutSettingForm" role="form">
-      <fieldset>
-        <legend>{{ t('customize_page.Layout') }}</legend>
-        <div class="form-group">
-          <div class="col-sm-4">
-            <h4>
-              <div class="radio radio-primary">
-                <input type="radio" id="radioLayoutGrowi" name="settingForm[customize:layout]" value="growi" onclick="selectableTheme(event)"
-                    {% if 'growi' === settingForm['customize:layout'] %}checked="checked"{% endif %}>
-                <label for="radioLayoutGrowi">
-                  GROWI Enhanced Layout <small class="text-success">(Recommended)</small>
-                </label>
-              </div>
-            </h4>
-            <a href="/images/admin/customize/layout-crowi-plus.gif" class="ss-container">
-              <img src="/images/admin/customize/layout-crowi-plus-thumb.gif" width="240px">
-            </a>
-            <h4>Simple and Clear</h4>
-            <ul>
-              <li>Full screen layout and thin margins/paddings</li>
-              <li>Show and post comments at the bottom of the page</li>
-              <li>Affix Table-of-contents</li>
-            </ul>
-          </div>
-          <div class="col-sm-4">
-            <h4>
-              <div class="radio radio-primary">
-                <input type="radio" id="radioLayoutKibela" name="settingForm[customize:layout]" value="kibela" onclick="selectableTheme(event)"
-                    {% if 'kibela' === settingForm['customize:layout'] %}checked="checked"{% endif %}>
-                <label   for="radioLayoutKibela">
-                  Kibela Like Layout
-                </label>
-              </div>
-            </h4>
-            <a href="/images/admin/customize/layout-kibela.gif" class="ss-container">
-              <img src="/images/admin/customize/layout-kibela-thumb.gif" width="240px">
-            </a>
-            <h4>Easy Viewing Structure</h4>
-            <ul>
-              <li>Center aligned contents</li>
-              <li>Show and post comments at the bottom of the page</li>
-              <li>Affix Table-of-contents</li>
-            </ul>
-          </div>
-          <div class="col-sm-4">
-            <h4>
-              <div class="radio radio-primary">
-                <input type="radio" id="radioLayoutCrowi" name="settingForm[customize:layout]" value="crowi" onclick="selectableTheme(event)"
-                    {% if 'crowi' === settingForm['customize:layout'] %}checked="checked"{% endif %}>
-                <label for="radioLayoutCrowi">
-                  Crowi Classic Layout
-                </label>
-              </div>
-            </h4>
-            <a href="/images/admin/customize/layout-classic.gif" class="ss-container">
-              <img src="/images/admin/customize/layout-classic-thumb.gif" width="240px">
-            </a>
-            <h4>Separated Functions</h4>
-            <ul>
-              <li>Collapsible Sidebar</li>
-              <li>Show and post comments in Sidebar</li>
-              <li>Collapsible Table-of-contents</li>
-            </ul>
-          </div>
-        </div>
-        <h2>{{ t('customize_page.Theme') }}</h2>
-        {% if env === 'development' %}
-          <div class="alert alert-warning">
-            <strong>DEBUG MESSAGE:</strong> development build では、リアルタイムプレビューが無効になります
-          </div>
-        {% endif %}
-        <div id="themeOptions" {% if 'kibela' == settingForm['customize:layout'] %}class="disabled"{% endif %}>
-          {# Light Themes #}
-          <div class="d-flex">
-            {% include 'widget/theme-colorbox.html' with { name: 'default',  bg: '#ffffff', topbar: '#334455', theme: '#112744'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'nature',   bg: '#f9fff3', topbar: '#118050', theme: '#460039'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'mono-blue',   bg: '#F7FBFD', topbar: '#00587A', theme: '#00587A'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'wood',   bg: '#fffefb', topbar: '#aaa45f', theme: '#dddebf'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'island',   bg: '#8ecac0', topbar: '#0c2a44', theme: '#cef2ef'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'christmas',   bg: '#fffefb', topbar: '#b3000c', theme: '#017e20'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'antarctic',   bg: '#ffffff', topbar: '#000080', theme: '#99cccc'} %}
-          </div>
-          {# Dark Themes #}
-          <div class="d-flex mt-3">
-            {% 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'} %}
-            {% include 'widget/theme-colorbox.html' with { name: 'halloween',   bg: '#030003', topbar: '#cc5d1f', theme: '#e9af2b'} %}
-          </div>
-        </div>
-        <div class="form-group">
-          <div class="col-xs-offset-5 col-xs-6">
-            <input type="hidden" id="hiddenInputTheme" name="settingForm[customize:theme]" value="{{ settingForm['customize:theme'] }}">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit"  class="btn btn-primary">{{ t('Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
-      <form action="/_api/admin/customize/behavior" method="post" class="form-horizontal" id="custombehaviorSettingForm" role="form">
-        <fieldset>
-          <legend>{{ t('customize_page.Behavior') }}</legend>
-
-          {% set isBehaviorGrowi = 'growi' === settingForm['customize:behavior'] || 'crowi-plus' === settingForm['customize:behavior'] %}
-          <div class="form-group">
-            <div class="col-xs-6">
-              <h4>
-                <div class="radio radio-primary">
-                  <input type="radio" id="radioBehaviorGrowi" name="settingForm[customize:behavior]" value="growi"
-                      {% if isBehaviorGrowi %}checked="checked"{% endif %}>
-                  <label for="radioBehaviorGrowi">
-                    GROWI Simplified Behavior <small class="text-success">(Recommended)</small>
-                  </label>
-              </div>
-              </h4>
-              <ul>
-                <li>Both of <code>/page</code> and <code>/page/</code> shows the same page</li>
-                <li><code>/nonexistent_page</code> shows editing form</li>
-                <li>All pages shows the list of sub pages <b>if using GROWI Enhanced Layout</b></li>
-              </ul>
-            </div>
-            <div class="col-xs-6">
-              <h4>
-                <div class="radio radio-primary">
-                  <input type="radio" id="radioBehaviorCrowi" name="settingForm[customize:behavior]" value="crowi"
-                      {% if !isBehaviorGrowi %}checked="checked"{% endif %}>
-                  <label for="radioBehaviorCrowi">
-                    Crowi Classic Behavior
-                  </label>
-                </div>
-              </h4>
-              <ul>
-                <li><code>/page</code> shows the page</li>
-                <li><code>/page/</code> shows the list of sub pages</li>
-                <ul>
-                  <li>If portal is applied to <code>/page/</code> , the portal and the list of sub pages are shown</li>
-                </ul>
-                <li><code>/nonexistent_page</code> shows editing form</li>
-                <li><code>/nonexistent_page/</code> the list of sub pages</li>
-              </ul>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <div class="col-xs-offset-5 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-            </div>
-          </div>
-
-        </fieldset>
-      </form>
-
-      <form action="/_api/admin/customize/features" method="post" class="form-horizontal" id="customfeaturesSettingForm" role="form">
-        <fieldset>
-        <legend>{{ t('customize_page.Function') }}</legend>
-          <p class="well">{{ t("customize_page.function_choose") }}</p>
-
-          <div class="form-group">
-            <label for="settingForm[customize:isEnabledTimeline]" class="col-xs-3 control-label">{{ t('customize_page.Timeline function') }}</label>
-            <div class="col-xs-9">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isEnabledTimeline'] %}active{% endif %}" data-active-class="primary">
-                  <input name="settingForm[customize:isEnabledTimeline]" value="true" type="radio"
-                      {% if true === settingForm['customize:isEnabledTimeline'] %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isEnabledTimeline'] %}active{% endif %}" data-active-class="default">
-                  <input name="settingForm[customize:isEnabledTimeline]" value="false" type="radio"
-                      {% if !settingForm['customize:isEnabledTimeline'] %}checked{% endif %}> OFF
-                </label>
-              </div>
-
-              <p class="help-block">
-                {{ t("customize_page.subpage_display") }}
-              </p>
-              <p class="help-block">
-                {{ t("customize_page.performance_decrease") }}<br>
-                {{ t("customize_page.list_page_display") }}
-              </p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <label for="settingForm[customize:isSavedStatesOfTabChanges]" class="col-xs-3 control-label">{{ t("customize_page.tab_switch") }}</label>
-            <div class="col-xs-9">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isSavedStatesOfTabChanges'] %}active{% endif %}" data-active-class="primary">
-                  <input name="settingForm[customize:isSavedStatesOfTabChanges]" value="true" type="radio"
-                      {% if true === settingForm['customize:isSavedStatesOfTabChanges'] %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isSavedStatesOfTabChanges'] %}active{% endif %}" data-active-class="default">
-                  <input name="settingForm[customize:isSavedStatesOfTabChanges]" value="false" type="radio"
-                      {% if !settingForm['customize:isSavedStatesOfTabChanges'] %}checked{% endif %}> OFF
-                </label>
-              </div>
-
-              <p class="help-block">
-                {{ t("customize_page.save_edit") }}<br>
-                {{ t("customize_page.by_invalidating") }}
-              </p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <label for="settingForm[customize:isEnabledAttachTitleHeader]" class="col-xs-3 control-label">{{ t("customize_page.attach_title_header") }}</label>
-            <div class="col-xs-9">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:isEnabledAttachTitleHeader'] %}active{% endif %}" data-active-class="primary">
-                  <input name="settingForm[customize:isEnabledAttachTitleHeader]" value="true" type="radio" {% if true===settingForm['customize:isEnabledAttachTitleHeader'] %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:isEnabledAttachTitleHeader'] %}active{% endif %}" data-active-class="default">
-                  <input name="settingForm[customize:isEnabledAttachTitleHeader]" value="false" type="radio" {% if !settingForm['customize:isEnabledAttachTitleHeader'] %}checked{% endif %}> OFF
-                </label>
-              </div>
-
-              <p class="help-block">
-                {{ t("customize_page.attach_title_header_desc") }}
-              </p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <label for="settingForm[customize:showRecentCreatedNumber]" class="col-xs-3 control-label">{{ t("customize_page.recent_created__n_draft_num_desc") }}</label>
-            <div class="col-xs-5">
-              <select class="form-control selectpicker" name="settingForm[customize:showRecentCreatedNumber]" value="{{ settingForm['customize:showRecentCreatedNumber'] }}">
-                <option value="10" {% if 10 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>10</option>
-                <option value="30" {% if 30 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>30</option>
-                <option value="50" {% if 50 == settingForm['customize:showRecentCreatedNumber'] %}selected{% endif %}>50</option>
-              </select>
-
-              <p class="help-block">
-                {{ t("customize_page.recently_created_n_draft_num_desc") }}
-              </p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <div class="col-xs-offset-3 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-            </div>
-          </div>
-
-        </fieldset>
-      </form>
-
-      <form action="/_api/admin/customize/highlightJsStyle" method="post" class="form-horizontal" id="customhighlightJsStyleSettingForm" role="form">
-        <fieldset>
-          <legend>{{ t('customize_page.Code Highlight') }}</legend>
-          <div class="form-group">
-            <label for="settingForm[customize:highlightJsStyle]" class="col-xs-3 control-label">{{ t('customize_page.Theme') }}</label>
-            <div class="col-xs-9">
-              <select class="form-control selectpicker" name="settingForm[customize:highlightJsStyle]" onChange="selectHighlightJsStyle(event)" {% if noCdn() %}disabled{% endif %}>
-                {% for key in Object.keys(highlightJsCssSelectorOptions) %}
-                  <option value={{key}} {% if key == getConfig('crowi', 'customize:highlightJsStyle') %} selected {% endif %}>{{highlightJsCssSelectorOptions[key].name}}</option>
-                {% endfor %}
-              </select>
-              <p class="help-block text-warning">{{ t('customize_page.nocdn_desc') }}</p>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <label for="settingForm[customize:highlightJsStyleBorder]" class="col-xs-3 control-label">Border</label>
-            <div class="col-xs-9">
-              <div class="btn-group btn-toggle" data-toggle="buttons">
-                <label class="btn btn-default btn-rounded btn-outline {% if settingForm['customize:highlightJsStyleBorder'] %}active{% endif %}" data-active-class="primary" onclick="selectBorderOn()">
-                  <input name="settingForm[customize:highlightJsStyleBorder]" value="true" type="radio"
-                      {% if true === settingForm['customize:highlightJsStyleBorder'] %}checked{% endif %}> ON
-                </label>
-                <label class="btn btn-default btn-rounded btn-outline {% if !settingForm['customize:highlightJsStyleBorder'] %}active{% endif %}" data-active-class="default" onclick="selectBorderOff()">
-                  <input name="settingForm[customize:highlightJsStyleBorder]" value="false" type="radio"
-                      {% if !settingForm['customize:highlightJsStyleBorder'] %}checked{% endif %}> OFF
-                </label>
-              </div>
-            </div>
-          </div>
-
-          <div id="highlightJsCssContainer">
-            {{ cdnHighlightJsStyleTag(getConfig('crowi', 'customize:highlightJsStyle')) }}
-          </div>
-
-          <p class="help-block">
-            Examples:
-            <div class="wiki">
-              <pre class="hljs {% if !settingForm['customize:highlightJsStyleBorder'] %}hljs-no-border{% endif %}"><code class="highlightjs-demo">function $initHighlight(block, cls) {
-  try {
-    if (cls.search(/\bno\-highlight\b/) != -1)
-      return process(block, true, 0x0F) +
-              ` class="${cls}"`;
-  } catch (e) {
-    /* handle exception */
-  }
-  for (var i = 0 / 2; i < classes.length; i++) {
-    if (checkCondition(classes[i]) === undefined)
-      console.log('undefined');
-  }
-}
-
-export  $initHighlight;</code></pre>
-            </div>
-          </p>
-
-          <div class="form-group">
-            <div class="col-xs-offset-5 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-            </div>
-          </div>
-
-        </fieldset>
-      </form>
-
-      <form action="/_api/admin/customize/title" method="post" class="form-horizontal" id="customtitleSettingForm" role="form">
-        <fieldset>
-          <legend>{{ t('customize_page.custom_title') }}</legend>
-
-          <p class="well">
-            {{ t('customize_page.custom_title_detail', '&lt;title&gt;', '&#123;&#123;sitename&#125;&#125;', '&#123;&#123;page&#125;&#125;') }}
-          </p>
-
-          <p class="help-block">
-            Default Value: <code>&#123;&#123;page&#125;&#125; - &#123;&#123;sitename&#125;&#125;</code>
-            <br>
-            Default Output: <pre><code class="xml">&lt;title&gt;/Sandbox - {{ appTitle }}&lt;&#047;title&gt;</code></pre>
-          </p>
-
-          <div class="form-group">
-            <div class="col-xs-12">
-              <input class="form-control" name="settingForm[customize:title]" value="{{ settingForm['customize:title'] | default('') }}"></input>
-            </div>
-          </div>
-
-          <div class="form-group">
-            <div class="col-xs-offset-5 col-xs-6">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <button type="submit" class="btn btn-primary">更新</button>
-            </div>
-          </div>
-
-        </fieldset>
-      </form>
-
-      <form action="/_api/admin/customize/header" method="post" class="form-horizontal" id="customheaderSettingForm" role="form">
-      <fieldset>
-        <legend>{{ t('customize_page.custom_header') }}</legend>
-
-        <p class="well">
-          {{ t('customize_page.custom_header_detail', '&lt;header&gt;', '&lt;script&gt;') }}
-        </p>
-
-        <p class="help-block">
-          {{ t('Example') }}:
-          <pre class="hljs"><code>&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js" defer&gt;&lt;/script&gt;</code></pre>
-        </p>
-
-        <div class="form-group">
-          <div class="col-xs-12">
-            <div id="custom-header-editor"></div>
-            <input type="hidden" id="inputCustomHeader" name="settingForm[customize:header]" value="{{ settingForm['customize:header'] | default('') }}">
-          </div>
-          <div class="col-xs-12">
-            <p class="help-block text-right">
-              <i class="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
-              {{ t("customize_page.ctrl_space") }}
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-5 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
-      <form action="/_api/admin/customize/css" method="post" class="form-horizontal" id="customcssSettingForm" role="form">
-      <fieldset>
-        <legend>{{ t('customize_page.Custom CSS') }}</legend>
-
-        <p class="well">
-          {{ t("customize_page.write_CSS") }}<br>
-          {{ t("customize_page.reflect_change") }}
-        </p>
-
-        <div class="form-group">
-          <div class="col-xs-12">
-            <div id="custom-css-editor"></div>
-            <input type="hidden" id="inputCustomCss" name="settingForm[customize:css]" value="{{ settingForm['customize:css'] | default('') }}">
-          </div>
-          <div class="col-xs-12">
-            <p class="help-block text-right">
-              <i class="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
-              {{ t("customize_page.ctrl_space") }}
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-5 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
-
-      <form action="/_api/admin/customize/script" method="post" class="form-horizontal" id="customscriptSettingForm" role="form">
-      <fieldset>
-        <legend>{{ t('customize_page.Custom script') }}</legend>
-
-        <p class="well">
-          {{ t("customize_page.write_java") }}<br>
-          {{ t("customize_page.reflect_change") }}
-        </p>
-
-        <p class="help-block">
-          Placeholders:<br>
-          (Available after <code>load</code> event)
-          <dl class="dl-horizontal">
-            <dt><code>$</code></dt>
-            <dd>jQuery instance</dd>
-            <dt><code>appContainer</code></dt>
-            <dd>GROWI App <a href="https://github.com/jamiebuilds/unstated">Unstated Container</a></dd>
-            <dt><code>growiRenderer</code></dt>
-            <dd>GROWI Renderer origin instance</dd>
-            <dt><code>growiPlugin</code></dt>
-            <dd>GROWI Plugin Manager instance</dd>
-            <dt><code>Crowi</code></dt>
-            <dd>Crowi legacy instance (jQuery based)</dd>
-          </dl>
-        </p>
-        <p class="help-block">
-          Examples:
-<pre class="hljs"><code>console.log($('.main-container'));
-
-window.addEventListener('load', (event) => {
-  console.log('config: ', appContainer.config);
-});</code></pre>
-        </p>
-
-        <div class="form-group">
-          <div class="col-xs-12">
-            <div id="custom-script-editor"></div>
-            <input type="hidden" id="inputCustomScript" name="settingForm[customize:script]" value="{{ settingForm['customize:script'] | default('') }}">
-          </div>
-          <div class="col-xs-12">
-            <p class="help-block text-right">
-              <i class="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
-              {{ t("customize_page.ctrl_space") }}
-            </p>
-          </div>
-        </div>
-
-        <div class="form-group">
-          <div class="col-xs-offset-5 col-xs-6">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <button type="submit" class="btn btn-primary">{{ t('Update') }}</button>
-          </div>
-        </div>
-
-      </fieldset>
-      </form>
-
+      <div id="admin-customize"></div>
     </div>
     </div>
   </div>
   </div>
-{% endblock content_main %}
-
-<!-- {% block body_end %}
-  {% parent %}
-  <script>
-    $(`#customlayoutSettingForm, #custombehaviorSettingForm, #customhighlightJsStyleSettingForm,
-       #customfeaturesSettingForm, #customheaderSettingForm, #customcssSettingForm, #customscriptSettingForm, #customtitleSettingForm`
-    ).each(function() {
-      $(this).submit(function()
-      {
-        function showMessage(formId, msg, status) {
-          $('#' + formId + ' #alert-results').remove();
-
-          if (!status) {
-            status = 'success';
-          }
-          var $message = $('<p id="alert-results" class="alert"></p>');
-          $message.addClass('alert-' + status);
-          $message.html(msg.replace(/\n/g, '<br>'));
-          $message.insertAfter('#' + formId + ' legend');
-
-          if (status == 'success') {
-            setTimeout(function()
-            {
-              $message.fadeOut({
-                complete: function() {
-                  $message.remove();
-                }
-              });
-            }, 5000);
-          }
-        }
-
-        var $form = $(this);
-        var $id = $form.attr('id');
-        var $button = $('button', this);
-        $button.attr('disabled', 'disabled');
-        var jqxhr = $.post($form.attr('action'), $form.serialize(), function(data)
-          {
-            if (data.status) {
-              showMessage($id, '更新しました');
-            } else {
-              showMessage($id, data.message, 'danger');
-            }
-          })
-          .fail(function() {
-            showMessage($id, 'エラーが発生しました', 'danger');
-          })
-          .always(function() {
-            $button.prop('disabled', false);
-        });
-        return false;
-      });
-    });
-
-    /*
-     * highlight.js style switcher
-     */
-    hljs.initHighlightingOnLoad()
-    hljs.initLineNumbersOnLoad()
-
-    function selectHighlightJsStyle(event) {
-      var highlightJsCssDOM = $("#highlightJsCssContainer link")[0]
-      // selected value
-      var val = event.target.value
-      // replace css url
-      // see https://regex101.com/r/gBNZYu/4
-      highlightJsCssDOM.href = highlightJsCssDOM.href.replace(/[^/]+\.css$/, `${val}.css`);
-    }
-
-    function selectableTheme(event) {
-      var val = event.target.value;
-      var themeButtons = document.getElementsByClassName('theme-button');
-
-      if(val == 'kibela') {
-        $('#themeOptions').addClass("disabled");
-        var i=0;
-        while(i < themeButtons.length) {
-          themeButtons[i].removeAttribute("onclick");
-          i++;
-        }
-      }
-      else {
-        $('#themeOptions').removeClass("disabled")
-        var i=0;
-        while(i < themeButtons.length) {
-          var name = themeButtons[i].getAttribute("id");
-          themeButtons[i].setAttribute("onclick",`selectTheme('${name}')`);
-          i++;
-        }
-      }
-    }
-    /*
-     * Theme Selector
-     */
-    options = {
-      hasPreview: false,
-      fullPath: '',
-      cookie: {
-        isManagingLoad: false
-      }
-    };
-    $(document).ready(function() {
-      $('#themeOptions').styleSwitcher(options);
-    });
-
-    function selectTheme(theme) {
-      // update hidden
-      $('#hiddenInputTheme').val(theme);
-      // update .active class
-      $('#themeOptions .active').removeClass('active');
-      $(`#themeOptions #theme-option-${theme}`).addClass('active');
-    }
-
-    function selectBorderOn(){
-      $('.hljs-no-border').removeClass('hljs-no-border');
-    }
-    function selectBorderOff(){
-      $('#customhighlightJsStyleSettingForm .hljs').addClass('hljs-no-border')
-    }
-  </script>
-
+  {% endblock content_main %} {% block content_footer %} {% endblock content_footer %}
 </div>
 </div>
-{% endblock %} -->
-
-{% block content_footer %}
-{% endblock content_footer %}

+ 0 - 1
src/server/views/page_presentation.html

@@ -22,7 +22,6 @@
     {{ cdnScriptTagsByGroup('basis') }}
     {{ cdnScriptTagsByGroup('basis') }}
 
 
     {% if env === 'development' %}
     {% if env === 'development' %}
-      <script src="/dll/dll.js"></script>
       <script src="{{ webpack_asset('js/dev.js') }}" async></script>
       <script src="{{ webpack_asset('js/dev.js') }}" async></script>
     {% endif %}
     {% endif %}
 
 

+ 0 - 1
src/server/views/widget/headers/scripts-for-dev.html

@@ -1,5 +1,4 @@
 {% if env === 'development' %}
 {% if env === 'development' %}
-  <script src="/dll/dll.js"></script>
   <script src="{{ webpack_asset('js/dev.js') }}" async></script>
   <script src="{{ webpack_asset('js/dev.js') }}" async></script>
   <!-- Browsersync -->
   <!-- Browsersync -->
   <script id="__bs_script__">//<![CDATA[
   <script id="__bs_script__">//<![CDATA[

+ 1 - 0
src/server/views/widget/headers/styles-for-app.html

@@ -3,5 +3,6 @@
   <script src="{{ webpack_asset('styles/style-app.js') }}"></script>
   <script src="{{ webpack_asset('styles/style-app.js') }}"></script>
 {% else %}
 {% else %}
   <script src="{{ webpack_asset('styles/style-commons.js') }}"></script>
   <script src="{{ webpack_asset('styles/style-commons.js') }}"></script>
+  <link rel="stylesheet" href="{{ webpack_asset('styles/style-commons.css') }}">
   <link rel="stylesheet" href="{{ webpack_asset('styles/style-app.css') }}">
   <link rel="stylesheet" href="{{ webpack_asset('styles/style-app.css') }}">
 {% endif %}
 {% endif %}

+ 37 - 0
src/test/config/migrate.test.js

@@ -0,0 +1,37 @@
+describe('config/migrate.js', () => {
+
+  beforeEach(async(done) => {
+    jest.resetModules();
+    done();
+  });
+
+  /* eslint-disable indent */
+  describe.each`
+    MONGO_URI                                         | expectedUrl                                       | expectedDbName
+    ${'mongodb://example.com/growi'}                  | ${'mongodb://example.com/growi'}                  | ${'growi'}
+    ${'mongodb://user:pass@example.com/growi'}        | ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
+    ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
+  `('returns', ({ MONGO_URI, expectedUrl, expectedDbName }) => {
+    test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
+
+      // mock for mongoose-utils
+      jest.doMock('@commons/util/mongoose-utils', () => {
+        return {
+          getMongoUri: () => {
+            return MONGO_URI;
+          },
+        };
+      });
+
+      const { mongoUri, mongodb } = require('@root/config/migrate');
+
+      jest.dontMock('@commons/util/mongoose-utils');
+
+      expect(mongoUri).toBe(MONGO_URI);
+      expect(mongodb.url).toBe(expectedUrl);
+      expect(mongodb.databaseName).toBe(expectedDbName);
+    });
+  });
+  /* eslint-enable indent */
+
+});

+ 56 - 8
yarn.lock

@@ -3863,6 +3863,11 @@ detect-file@^1.0.0:
   version "1.0.0"
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
   resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
 
 
+detect-indent@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
+  integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
+
 detect-libc@^1.0.2:
 detect-libc@^1.0.2:
   version "1.0.3"
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
   resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
@@ -5836,6 +5841,25 @@ har-validator@~5.1.0:
     ajv "^6.5.5"
     ajv "^6.5.5"
     har-schema "^2.0.0"
     har-schema "^2.0.0"
 
 
+hard-source-webpack-plugin@^0.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/hard-source-webpack-plugin/-/hard-source-webpack-plugin-0.13.1.tgz#a99071e25b232f1438a5bc3c99f10a3869e4428e"
+  integrity sha512-r9zf5Wq7IqJHdVAQsZ4OP+dcUSvoHqDMxJlIzaE2J0TZWn3UjMMrHqwDHR8Jr/pzPfG7XxSe36E7Y8QGNdtuAw==
+  dependencies:
+    chalk "^2.4.1"
+    find-cache-dir "^2.0.0"
+    graceful-fs "^4.1.11"
+    lodash "^4.15.0"
+    mkdirp "^0.5.1"
+    node-object-hash "^1.2.0"
+    parse-json "^4.0.0"
+    pkg-dir "^3.0.0"
+    rimraf "^2.6.2"
+    semver "^5.6.0"
+    tapable "^1.0.0-beta.5"
+    webpack-sources "^1.0.1"
+    write-json-file "^2.3.0"
+
 has-ansi@^2.0.0:
 has-ansi@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
   resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -8831,6 +8855,11 @@ node-notifier@^5.2.1, node-notifier@^5.4.0:
     shellwords "^0.1.1"
     shellwords "^0.1.1"
     which "^1.3.0"
     which "^1.3.0"
 
 
+node-object-hash@^1.2.0:
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94"
+  integrity sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==
+
 node-pre-gyp@^0.10.0:
 node-pre-gyp@^0.10.0:
   version "0.10.0"
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.0.tgz#6e4ef5bb5c5203c6552448828c852c40111aac46"
   resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.0.tgz#6e4ef5bb5c5203c6552448828c852c40111aac46"
@@ -11395,6 +11424,13 @@ rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1:
   dependencies:
   dependencies:
     glob "^7.0.5"
     glob "^7.0.5"
 
 
+rimraf@^2.6.2:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+  dependencies:
+    glob "^7.1.3"
+
 rimraf@^3.0.0:
 rimraf@^3.0.0:
   version "3.0.0"
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
@@ -12697,7 +12733,7 @@ tapable@^1.0.0:
   version "1.0.0"
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.0.0.tgz#cbb639d9002eed9c6b5975eb20598d7936f1f9f2"
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.0.0.tgz#cbb639d9002eed9c6b5975eb20598d7936f1f9f2"
 
 
-tapable@^1.1.3:
+tapable@^1.0.0-beta.5, tapable@^1.1.3:
   version "1.1.3"
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
   resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
   integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
   integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
@@ -13625,17 +13661,17 @@ webpack-sources@^1.0.0:
     source-list-map "^2.0.0"
     source-list-map "^2.0.0"
     source-map "~0.6.1"
     source-map "~0.6.1"
 
 
-webpack-sources@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.1.0.tgz#a101ebae59d6507354d71d8013950a3a8b7a5a54"
+webpack-sources@^1.0.1, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933"
+  integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==
   dependencies:
   dependencies:
     source-list-map "^2.0.0"
     source-list-map "^2.0.0"
     source-map "~0.6.1"
     source-map "~0.6.1"
 
 
-webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933"
-  integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==
+webpack-sources@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.1.0.tgz#a101ebae59d6507354d71d8013950a3a8b7a5a54"
   dependencies:
   dependencies:
     source-list-map "^2.0.0"
     source-list-map "^2.0.0"
     source-map "~0.6.1"
     source-map "~0.6.1"
@@ -13820,6 +13856,18 @@ write-file-atomic@^3.0.0:
     signal-exit "^3.0.2"
     signal-exit "^3.0.2"
     typedarray-to-buffer "^3.1.5"
     typedarray-to-buffer "^3.1.5"
 
 
+write-json-file@^2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/write-json-file/-/write-json-file-2.3.0.tgz#2b64c8a33004d54b8698c76d585a77ceb61da32f"
+  integrity sha1-K2TIozAE1UuGmMdtWFp3zrYdoy8=
+  dependencies:
+    detect-indent "^5.0.0"
+    graceful-fs "^4.1.2"
+    make-dir "^1.0.0"
+    pify "^3.0.0"
+    sort-keys "^2.0.0"
+    write-file-atomic "^2.0.0"
+
 write@1.0.3:
 write@1.0.3:
   version "1.0.3"
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
   resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"