Explorar o código

Merge branch 'master' into upgrade-nodejs-to-v14

utsushiiro %!s(int64=5) %!d(string=hai) anos
pai
achega
6a87be3610
Modificáronse 80 ficheiros con 2835 adicións e 655 borrados
  1. 4 0
      .devcontainer/docker-compose.yml
  2. 19 1
      .vscode/launch.json
  3. 30 1
      CHANGES.md
  4. 2 1
      config/env.dev.js
  5. 2 1
      config/logger/config.dev.js
  6. 6 2
      config/webpack.common.js
  7. 5 3
      package.json
  8. 3 0
      resource/locales/en-US/_conf.json
  9. 24 0
      resource/locales/en-US/translation.json
  10. 3 0
      resource/locales/ja/_conf.json
  11. 24 0
      resource/locales/ja/translation.json
  12. 3 0
      resource/locales/zh-CN/_conf.json
  13. 318 0
      resource/locales/zh-CN/admin/admin.json
  14. 14 0
      resource/locales/zh-CN/admin/userInvitation.txt
  15. 21 0
      resource/locales/zh-CN/admin/userWaitingActivation.txt
  16. 9 0
      resource/locales/zh-CN/notifications/comment.txt
  17. 5 0
      resource/locales/zh-CN/notifications/pageCreate.txt
  18. 5 0
      resource/locales/zh-CN/notifications/pageDelete.txt
  19. 5 0
      resource/locales/zh-CN/notifications/pageEdit.txt
  20. 5 0
      resource/locales/zh-CN/notifications/pageLike.txt
  21. 5 0
      resource/locales/zh-CN/notifications/pageMove.txt
  22. 253 0
      resource/locales/zh-CN/sandbox-bootstrap4.md
  23. 7 0
      resource/locales/zh-CN/sandbox-diagrams.md
  24. 71 0
      resource/locales/zh-CN/sandbox-math.md
  25. 457 0
      resource/locales/zh-CN/sandbox.md
  26. 700 0
      resource/locales/zh-CN/translation.json
  27. 27 0
      resource/locales/zh-CN/welcome.md
  28. 9 0
      rs-i18n.env
  29. 7 1
      src/client/js/admin.jsx
  30. 22 9
      src/client/js/app.jsx
  31. 3 4
      src/client/js/base.jsx
  32. 5 0
      src/client/js/boot.js
  33. 51 33
      src/client/js/components/Admin/App/AppSetting.jsx
  34. 18 13
      src/client/js/components/Drawio.jsx
  35. 21 30
      src/client/js/components/InstallerForm.jsx
  36. 9 8
      src/client/js/components/LoginForm.jsx
  37. 18 23
      src/client/js/components/Me/BasicInfoSettings.jsx
  38. 5 5
      src/client/js/components/Navbar/NavbarToggler.jsx
  39. 6 6
      src/client/js/components/Navbar/PageCreateButton.jsx
  40. 38 24
      src/client/js/components/Navbar/PersonalDropdown.jsx
  41. 4 2
      src/client/js/components/Navbar/SearchTop.jsx
  42. 41 0
      src/client/js/components/NotAvailableForGuest.jsx
  43. 11 4
      src/client/js/components/Page.jsx
  44. 6 4
      src/client/js/components/Page/TagLabels.jsx
  45. 10 7
      src/client/js/components/PageComment/CommentEditor.jsx
  46. 1 1
      src/client/js/components/PageComments.jsx
  47. 9 5
      src/client/js/components/PageCreateModal.jsx
  48. 3 3
      src/client/js/components/PageEditorByHackmd.jsx
  49. 9 6
      src/client/js/components/Sidebar.jsx
  50. 1 1
      src/client/js/components/Sidebar/RecentChanges.jsx
  51. 10 5
      src/client/js/legacy/crowi.js
  52. 2 4
      src/client/js/nologin.jsx
  53. 40 203
      src/client/js/services/AppContainer.js
  54. 151 0
      src/client/js/services/NavigationContainer.js
  55. 0 23
      src/client/js/services/NoLoginContainer.js
  56. 21 3
      src/client/js/services/PageContainer.js
  57. 73 0
      src/client/js/util/color-scheme.js
  58. 9 6
      src/client/js/util/interceptor/drawio-interceptor.js
  59. 2 2
      src/client/styles/scss/_mixins.scss
  60. 2 0
      src/client/styles/scss/_wiki.scss
  61. 6 0
      src/client/styles/scss/style-app.scss
  62. 1 1
      src/lib/util/mongoose-utils.js
  63. 5 0
      src/server/crowi/index.js
  64. 3 32
      src/server/models/user.js
  65. 5 61
      src/server/routes/admin.js
  66. 1 1
      src/server/routes/apiv3/app-settings.js
  67. 2 2
      src/server/routes/apiv3/notification-setting.js
  68. 1 1
      src/server/routes/apiv3/personal-setting.js
  69. 1 4
      src/server/routes/apiv3/statistics.js
  70. 0 4
      src/server/routes/index.js
  71. 7 3
      src/server/routes/installer.js
  72. 12 12
      src/server/routes/login-passport.js
  73. 39 42
      src/server/routes/login.js
  74. 6 0
      src/server/service/config-loader.js
  75. 1 1
      src/server/service/search-delegator/elasticsearch.js
  76. 11 17
      src/server/util/mailer.js
  77. 2 2
      src/server/util/middlewares.js
  78. 4 2
      src/server/views/installer.html
  79. 2 0
      src/server/views/layout/layout.html
  80. 82 26
      yarn.lock

+ 4 - 0
.devcontainer/docker-compose.yml

@@ -17,6 +17,10 @@ services:
       context: .
       context: .
       dockerfile: Dockerfile
       dockerfile: Dockerfile
 
 
+    ports:
+      - 3000:3000
+      - 3001:3001 # for browser-sync
+
     volumes:
     volumes:
       - ..:/workspace/growi:cached
       - ..:/workspace/growi:cached
       - /workspace/growi/node_modules
       - /workspace/growi/node_modules

+ 19 - 1
.vscode/launch.json

@@ -29,11 +29,29 @@
         "request": "launch",
         "request": "launch",
         "name": "Debug: Chrome",
         "name": "Debug: Chrome",
         "sourceMaps": true,
         "sourceMaps": true,
-        "webRoot": "${workspaceFolder}/public",
         "sourceMapPathOverrides": {
         "sourceMapPathOverrides": {
           "webpack:///*": "${workspaceFolder}/*"
           "webpack:///*": "${workspaceFolder}/*"
         },
         },
+        "webRoot": "${workspaceFolder}/public",
         "url": "http://localhost:3000"
         "url": "http://localhost:3000"
+      },
+      {
+        "type": "firefox",
+        "request": "launch",
+        "name": "Debug: Firefox",
+        "reAttach": true,
+        "url": "http://localhost:3000",
+        "webRoot": "${workspaceFolder}/public",
+        "pathMappings": [
+          {
+            "url": "webpack:///src",
+            "path": "${workspaceFolder}/src"
+          },
+          {
+            "url": "http://localhost:3000",
+            "path": "${workspaceFolder}/public"
+          }
+        ]
       }
       }
     ]
     ]
 }
 }

+ 30 - 1
CHANGES.md

@@ -1,9 +1,38 @@
 # CHANGES
 # CHANGES
 
 
-## v4.0.5
+## v4.0.8-RC
 
 
 * 
 * 
 
 
+## v4.0.7
+
+* Feature: Set request timeout for Elasticsearch with env var `ELASTICSEARCH_REQUEST_TIMEOUT`
+* Improvement: Apply styles faster on booting client
+* Fix: Styles are not applyed on installer
+* Fix: Remove last-resort `next()`
+* Fix: Enable/disable Notification settings couldn't change when either of the params is undefined
+* Fix: Text overflow
+
+## v4.0.6
+
+* Fix: Avatar images in Recent Changes are not shown
+* Fix: Full screen modal of Handsontable and Draw.io don't work
+* Fix: Shortcut for creating page respond with modifier key wrongly
+    * Introduced by v4.0.5
+
+## v4.0.5
+
+* Improvement: Return pre-defined session id when healthcheck
+* Improvement: Refactor caching for profile image
+* Improvement: Layout for global search help on mobile
+* Improvement: Layout for confidential notation
+* Fix: Shortcut for creating page doesn't work
+* Support: Dev in container
+* Support: Upgrade libs
+    * ldapjs
+    * node-sass
+
+
 ## v4.0.4
 ## v4.0.4
 
 
 * Feature: Drawer/Dock mode selector
 * Feature: Drawer/Dock mode selector

+ 2 - 1
config/env.dev.js

@@ -7,7 +7,8 @@ module.exports = {
   MONGO_URI: 'mongodb://mongo:27017/growi',
   MONGO_URI: 'mongodb://mongo:27017/growi',
   // REDIS_URI: 'http://redis:6379',
   // REDIS_URI: 'http://redis:6379',
   ELASTICSEARCH_URI: 'http://elasticsearch:9200/growi',
   ELASTICSEARCH_URI: 'http://elasticsearch:9200/growi',
-  HACKMD_URI: 'http://hackmd:3010',
+  HACKMD_URI: 'http://localhost:3010',
+  HACKMD_URI_FOR_SERVER: 'http://hackmd:3000',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   // DRAWIO_URI: 'http://localhost:8080/?offline=1&https=0',
   PLUGIN_NAMES_TOBE_LOADED: [
   PLUGIN_NAMES_TOBE_LOADED: [
     // 'growi-plugin-lsx',
     // 'growi-plugin-lsx',

+ 2 - 1
config/logger/config.dev.js

@@ -30,7 +30,8 @@ module.exports = {
   /*
   /*
    * configure level for client
    * configure level for client
    */
    */
-  'growi:app': 'debug',
+  'growi:cli:bootstrap': 'debug',
+  'growi:cli:app': 'debug',
   'growi:services:*': 'debug',
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:StaffCredit': 'debug',
   // 'growi:TableOfContents': 'debug',
   // 'growi:TableOfContents': 'debug',

+ 6 - 2
config/webpack.common.js

@@ -20,6 +20,7 @@ module.exports = (options) => {
   return {
   return {
     mode: options.mode,
     mode: options.mode,
     entry: Object.assign({
     entry: Object.assign({
+      'js/boot':                      './src/client/js/boot',
       'js/app':                       './src/client/js/app',
       'js/app':                       './src/client/js/app',
       'js/admin':                     './src/client/js/admin',
       'js/admin':                     './src/client/js/admin',
       'js/nologin':                   './src/client/js/nologin',
       'js/nologin':                   './src/client/js/nologin',
@@ -165,7 +166,10 @@ module.exports = (options) => {
           },
           },
           commons: {
           commons: {
             test: /(src|resource)[\\/].*\.(js|jsx|json)$/,
             test: /(src|resource)[\\/].*\.(js|jsx|json)$/,
-            chunks: 'initial',
+            chunks: (chunk) => {
+              // ignore patterns
+              return chunk.name != null && !chunk.name.match(/boot/);
+            },
             name: 'js/commons',
             name: 'js/commons',
             minChunks: 2,
             minChunks: 2,
             minSize: 1,
             minSize: 1,
@@ -175,7 +179,7 @@ module.exports = (options) => {
             test: /node_modules[\\/].*\.(js|jsx|json)$/,
             test: /node_modules[\\/].*\.(js|jsx|json)$/,
             chunks: (chunk) => {
             chunks: (chunk) => {
               // ignore patterns
               // ignore patterns
-              return chunk.name != null && !chunk.name.match(/legacy-presentation|ie11-polyfill|hackmd-/);
+              return chunk.name != null && !chunk.name.match(/boot|legacy-presentation|ie11-polyfill|hackmd-/);
             },
             },
             name: 'js/vendors',
             name: 'js/vendors',
             minSize: 1,
             minSize: 1,

+ 5 - 3
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "4.0.5-RC",
+  "version": "4.0.8-RC",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",
@@ -54,6 +54,8 @@
     "preserver:prod": "npm run migrate",
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
     "resource": "node bin/download-cdn-resources.js",
+    "translations": "rs-i18n -lan zh-CN -t",
+    "i18n-json-merge:noTran": "rs-i18n -lan zh-CN",
     "server:nolazy": "env-cmd -f config/env.dev.js node-dev --nolazy --inspect src/server/app.js",
     "server:nolazy": "env-cmd -f config/env.dev.js node-dev --nolazy --inspect src/server/app.js",
     "server:dev": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
     "server:dev": "env-cmd -f config/env.dev.js node-dev --inspect src/server/app.js",
     "server:prod:ci": "npm run server:prod -- --ci",
     "server:prod:ci": "npm run server:prod -- --ci",
@@ -73,7 +75,6 @@
     "JSONStream": "^1.3.5",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
-    "async": "^3.0.1",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
     "aws-sdk": "^2.88.0",
     "axios": "^0.19.0",
     "axios": "^0.19.0",
@@ -211,7 +212,7 @@
     "mini-css-extract-plugin": "^0.9.0",
     "mini-css-extract-plugin": "^0.9.0",
     "morgan": "^1.9.0",
     "morgan": "^1.9.0",
     "node-dev": "^4.0.0",
     "node-dev": "^4.0.0",
-    "node-sass": "^4.12.0",
+    "node-sass": "^4.14.1",
     "normalize-path": "^3.0.0",
     "normalize-path": "^3.0.0",
     "null-loader": "^3.0.0",
     "null-loader": "^3.0.0",
     "on-headers": "^1.0.1",
     "on-headers": "^1.0.1",
@@ -233,6 +234,7 @@
     "reactstrap": "^8.0.1",
     "reactstrap": "^8.0.1",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
     "reveal.js": "^3.5.0",
+    "rs-i18n": "^0.0.9",
     "sass-loader": "^8.0.0",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
     "socket.io-client": "^2.0.3",

+ 3 - 0
resource/locales/en-US/_conf.json

@@ -0,0 +1,3 @@
+{
+  "name": "English"
+}

+ 24 - 0
resource/locales/en-US/translation.json

@@ -689,5 +689,29 @@
     "Sign in error": "Login error",
     "Sign in error": "Login error",
     "Registration successful": "Registration successful",
     "Registration successful": "Registration successful",
     "Setup": "Setup"
     "Setup": "Setup"
+  },
+  "message": {
+    "successfully_connected": "Successfully Connected!",
+    "fail_to_save_access_token": "Failed to save access_token. Please try again.",
+    "fail_to_fetch_access_token": "Failed to fetch access_token. Please do connect again.",
+    "successfully_disconnected": "Successfully Disconnected!",
+    "strategy_has_not_been_set_up": "{{strategy}} has not been set up",
+    "maximum_number_of_users": "Can not register more than the maximum number of users.",
+    "database_error": "Database Server Error occured",
+    "sign_in_failure": "Sign in failure.",
+    "aws_sttings_required": "AWS settings required to use this function. Please ask the administrator.",
+    "application_already_installed": "Application already installed.",
+    "email_address_could_not_be_used": "This email address could not be used. (Make sure the allowed email address)",
+    "user_id_is_not_available.":"This User ID is not available.",
+    "email_address_is_already_registered":"This email address is already registered.",
+    "can_not_register_maximum_number_of_users":"Can not register more than the maximum number of users.",
+    "failed_to_register":"Failed to register.",
+    "successfully_created":"The user {{username}} is successfully created.",
+    "can_not_activate_maximum_number_of_users":"Can not activate more than the maximum number of users.",
+    "failed_to_activate":"Failed to activate.",
+    "unable_to_use_this_user":"Unable to use this user.",
+    "complete_to_install1":"Complete to Install GROWI ! Please login as admin account.",
+    "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
+    "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
   }
   }
 }
 }

+ 3 - 0
resource/locales/ja/_conf.json

@@ -0,0 +1,3 @@
+{
+  "name": "日本語"
+}

+ 24 - 0
resource/locales/ja/translation.json

@@ -681,5 +681,29 @@
     "Sign in error": "ログインエラー",
     "Sign in error": "ログインエラー",
     "Registration successful": "登録完了",
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
     "Setup": "セットアップ"
+  },
+  "message": {
+    "successfully_connected": "接続に成功しました!",
+    "fail_to_save_access_token": "アクセストークンの保存に失敗しました、再度お試しください。",
+    "fail_to_fetch_access_token": "アクセストークンの取得に失敗しました、再度お試しください。",
+    "successfully_disconnected": "切断に成功しました!",
+    "strategy_has_not_been_set_up": "{{strategy}} はセットアップされていません。",
+    "maximum_number_of_users": "ユーザー数が上限を超えたためアクティベートできません。",
+    "database_error":"データベースサーバーに問題があります。",
+    "sign_in_failure": "ログインに失敗しました。",
+    "aws_sttings_required": "この機能にはAWS設定が必要です。管理者に訪ねて下さい。",
+    "application_already_installed": "アプリケーションのインストールが完了しました。",
+    "email_address_could_not_be_used":"このメールアドレスは使用できません。(許可されたメールアドレスを確認してください。)",
+    "user_id_is_not_available":"このユーザーIDは使用できません。",
+    "email_address_is_already_registered":"このメールアドレスは既に登録されています。",
+    "can_not_register_maximum_number_of_users":"ユーザー数が上限を超えたため登録できません。",
+    "failed_to_register":"登録に失敗しました。",
+    "successfully_created":"{{username}} が作成されました。",
+    "can_not_activate_maximum_number_of_users":"ユーザーが上限に達したためアクティベートできません。",
+    "failed_to_activate":"アクティベートに失敗しました。",
+    "unable_to_use_this_user":"利用できないユーザーIDです。",
+    "complete_to_install1":"GROWI のインストールが完了しました!管理者アカウントでログインしてください。",
+    "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
+    "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
   }
   }
 }
 }

+ 3 - 0
resource/locales/zh-CN/_conf.json

@@ -0,0 +1,3 @@
+{
+  "name": "简体中文"
+}

+ 318 - 0
resource/locales/zh-CN/admin/admin.json

@@ -0,0 +1,318 @@
+{
+	"admin_top": {
+		"management_wiki": "管理Wiki",
+		"system_information": "系统信息",
+		"wiki_administrator": "只有wiki管理员可以访问此页",
+		"assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
+		"list_of_installed_plugins": "已安装插件列表",
+		"package_name": "包名称",
+		"specified_version": "指定版本",
+		"installed_version": "已安装版本",
+		"list_of_env_vars": "环境变量列表",
+		"env_var_priority": "对于安全性以外的环境变量,优先获取数据库的值。",
+		"about_security": "检查安全环境变量的<a href='/admin/security'>安全设置</a>。"
+	},
+	"app_setting": {
+		"site_name": "网站名称 ",
+		"sitename_change": "您可以更改用于标题和HTML标题的网站名称。",
+		"header_content": "此处输入的内容将显示在标题等中。",
+		"site_url_desc": "用于网站URL设置。",
+		"site_url_warn": "某些功能不起作用,因为未设置网站URL。",
+		"siteurl_help": "网站完整URL起始于 <code>http://</code> or <code>https://</code>.",
+		"confidential_name": "内部名称",
+		"confidential_example": "ex):仅供内部使用",
+		"default_language": "新用户的默认语言",
+		"file_uploading": "文件上传",
+		"enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
+		"attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
+		"update": "更新",
+		"mail_settings": "邮件设置",
+		"smtp_used": "如果您有SMTP设置,将使用它。",
+		"smtp_but_aws": "如果您没有SMTP设置,但有AWS设置,则电子邮件将由SES发送。",
+		"neihter_of": "如果两者都未选中,则不会发送电子邮件。",
+		"from_e-mail_address": "From e-mail address",
+		"smtp_settings": "SMTP 设置",
+		"host": "服务器",
+		"port": "端口号",
+		"user": "用户名",
+		"aws_settings": "AWS设置",
+		"aws_access": "这是用于AWS设置的。如果您完成了AWS设置,文件上传功能,个人资料图片功能等将被启用。",
+		"no_smtp_setting": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
+		"change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
+		"region": "Region",
+		"bucket_name": "Bucket name",
+		"custom_endpoint": "Custom endpoint",
+		"custom_endpoint_change": "输入对象存储服务(如MinIO)端点的URL,MinIO具有与S3兼容的API。如果为空,则使用Amazon S3。",
+		"plugin_settings": "插件设置",
+		"enable_plugin_loading": "启用插件加载",
+		"load_plugins": "加载插件",
+		"enable": "启用",
+		"disable": "停用",
+		"use_env_var_if_empty": "如果数据库中的值为空,则环境变量的值 <cod>{{variable}}</code> 启用。"
+	},
+	"markdown_setting": {
+		"lineBreak_header": "换行设置",
+		"lineBreak_desc": "您可以更改换行设置。",
+		"lineBreak_options": {
+			"enable_lineBreak": "启用换行符",
+			"enable_lineBreak_desc": "HTML中将文本页中的换行符转换为<code>&lt;br&gt;</code>",
+			"enable_lineBreak_for_comment": "注释中启用换行符",
+			"enable_lineBreak_for_comment_desc": "HTML中将注释中的换行符转换为<code>&lt;br&gt;</code>"
+		},
+		"presentation_header": "演示文稿设置",
+		"presentation_desc": "您可以更改演示文稿设置。",
+		"presentation_options": {
+			"page_break_setting": "分页设置",
+			"preset_one_separator": "预设 1",
+			"preset_one_separator_desc": "3 空行",
+			"preset_one_separator_value": "\\n\\n\\n",
+			"preset_two_separator": "预设 2",
+			"preset_two_separator_desc": "5 连字符",
+			"preset_two_separator_value": "-----",
+			"custom_separator": "自定义",
+			"custom_separator_desc": "正则表达式"
+		},
+		"xss_header": "阻止XSS(跨站点脚本)设置",
+		"xss_desc": "您可以更改标记文本中HTML标记的处理方式。",
+		"xss_options": {
+			"enable_xss_prevention": "启用XSS预防",
+			"remove_all_tags": "删除所有标记",
+			"remove_all_tags_desc": "Stripe all HTML tags and attributes",
+			"recommended_setting": "推荐设置",
+			"custom_whitelist": "自定义白名单",
+			"tag_names": "标记名",
+			"tag_attributes": "标记属性",
+			"import_recommended": "导入建议 {{target}}"
+		}
+	},
+	"customize_setting": {
+		"recommended": "推荐",
+		"layout": "布局",
+		"theme": "主体",
+		"layout_desc": {
+			"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": "行为",
+		"behavior_desc": {
+			"growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
+			"growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
+			"growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
+			"crowi_text1": "<code>/page</code> 显示页面",
+			"crowi_text2": "<code>/page/</code> 显示子页列表",
+			"crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
+			"crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
+			"crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
+		},
+		"theme_desc": {
+			"light_and_dark": "明暗模式",
+			"unique": "只有一种模式"
+		},
+		"function": "功能",
+		"function_desc": "您可以选择函数的有效/无效",
+		"function_options": {
+			"timeline": "时间线函数",
+			"timeline_desc1": "您可以显示子页的时间线。",
+			"timeline_desc2": "如果有许多子页,则在加载页时性能会降低。",
+			"timeline_desc3": "通过使列表页无效,可以加快列表页的显示速度。",
+			"tab_switch": "在浏览器中保存选项卡切换",
+			"tab_switch_desc1": "在浏览器中保存编辑选项卡和历史选项卡切换,并使其成为浏览器的前向/后向命令的对象。",
+			"tab_switch_desc2": "通过失效,您可以将页面转换作为浏览器的前向/后向命令的唯一对象。",
+			"attach_title_header": "自动创建新页面时添加h1节",
+			"attach_title_header_desc": "创建新页面时,将页面路径作为h1节添加到第一行",
+			"recent_created__n_draft_num_desc": "显示最近创建的页数和草稿数",
+			"recently_created_n_draft_num_desc": "用户页上显示的最近创建的页和草稿数",
+			"stale_notification": "在过期页上显示通知",
+			"stale_notification_desc": "显示自上次更新以来超过1年的页面通知。",
+			"show_all_reply_comments": "显示所有回复评论",
+			"show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。"
+		},
+		"code_highlight": "代码突出显示",
+		"nocdn_desc": "当强制应用环境变量<code>NO_CDN=true</code><br>Github样式时,此函数被禁用。",
+		"custom_title": "自定义标题",
+		"custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
+		"custom_title_detail_placeholder1": "<code>&#123;&#123;站点名称&#125;&#125;</code>-此wiki的站点名称。",
+		"custom_title_detail_placeholder2": "<code>&#123;&#123;页名&#125;&#125;</code>-当前页的页名。",
+		"custom_title_detail_placeholder3": "<code>&#123;&#123;页面路径&#125;&#125;</code>-当前页面的页面路径。",
+		"custom_header": "自定义HTML标题",
+		"custom_header_detail": "您可以自定义应用所有页面的HTML标题。您的自定义脚本将插入<code>&lt;header&gt;</code>中,但位于其他<code>&lt;script&gt;</code>标记之上。<br>重新链接页面以查看更改。",
+		"custom_css": "自定义CSS",
+		"write_css": "您可以编写应用于整个系统的CSS。",
+		"ctrl_space": "Ctrl+Space 自动完成",
+		"custom_script": "定制纸条",
+		"write_java": "您可以编写应用于整个系统的Javascript。",
+		"reflect_change": "您需要重新加载页面以反映更改。"
+	},
+	"importer_management": {
+		"beta_warning": "这个函数是Beta。",
+		"import_from": "Import from {{from}}",
+		"import_growi_archive": "Import GROWI archive",
+		"growi_settings": {
+			"description_of_import_mode": {
+				"about": "When you import data with the same name as an existing one, choose from the following three modes below.",
+				"insert": "Insert: Skip importing the data.",
+				"upsert": "Upsert: Overwrite and update the existing data with imported data.",
+				"flash_and_insert": "Flash and Insert: After deleting the existing data completely, import the data"
+			},
+			"growi_archive_file": "GROWI Archive File",
+			"uploaded_data": "Uploaded Data",
+			"extracted_file": "Extracted File",
+			"collection": "Collection",
+			"upload": "Upload",
+			"discard": "Discard uploaded data",
+			"errors": {
+				"at_least_one": "Select one or more collections.",
+				"page_and_revision": "'Pages' and 'Revisions' must be imported both.",
+				"depends": "'{{target}}' must be selected when '{{condition}}' is selected."
+			},
+			"configuration": {
+				"pages": {
+					"overwrite_author": {
+						"label": "Overwrite page's author with the current user",
+						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+					},
+					"set_public_to_page": {
+						"label": "Set 'Public' to the pages that is '{{from}}'",
+						"desc": "Make sure that this configuration makes all <b>'{{from}}'</b> pages readable from <span class=\"text-danger\">ANY users</span>."
+					},
+					"initialize_meta_datas": {
+						"label": "Initialize page's like, read users and comment count",
+						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+					},
+					"initialize_hackmd_related_datas": {
+						"label": "Initialize HackMD related data",
+						"desc": "Recommended to check this unless there is important drafts on HackMD."
+					}
+				},
+				"revisions": {
+					"overwrite_author": {
+						"label": "Overwrite revision's author with the current user",
+						"desc": "Recommended <span class=\"text-danger\">NOT</span> to check this when users will also be restored."
+					}
+				}
+			}
+		},
+		"esa_settings": {
+			"team_name": "Team name",
+			"access_token": "Access token",
+			"test_connection": "Test connection to esa"
+		},
+		"qiita_settings": {
+			"team_name": "Team name",
+			"access_token": "Access token",
+			"test_connection": "Test connection to qiita:team"
+		},
+		"import": "Import",
+		"page_skip": "Pages with a name that already exists on GROWI are not imported",
+		"Directory_hierarchy_tag": "Directory hierarchy tag"
+	},
+	"export_management": {
+		"exporting_collection_list": "正在导出集合列表",
+		"exported_data_list": "导出的存档数据列表",
+		"export_collections": "导出集合",
+		"check_all": "全部检查",
+		"uncheck_all": "全部取消选中",
+		"desc_password_seed": "DO NOT FORGET to set current <code>PASSWORD_SEED</code> to your new GROWI system when restoring user data, or users will NOT be able to login with their password.<br><br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.",
+		"create_new_archive_data": "创建新的存档数据",
+		"export": "导出",
+		"cancel": "取消",
+		"file": "文件",
+		"growi_version": "Growi Version",
+		"collections": "Collections",
+		"exported_at": "Exported At",
+		"export_menu": "导出菜单",
+		"download": "下载",
+		"delete": "删除"
+	},
+	"user_management": {
+		"invite_users": "邀请新用户",
+		"click_twice_same_checkbox": "您应该至少选中一个复选框。",
+		"invite_modal": {
+			"emails": "电子邮件",
+			"invite_thru_email": "发送邀请电子邮件",
+			"valid_email": "需要有效的电子邮件地址",
+			"temporary_password": "创建的用户具有临时密码",
+			"send_new_password": "请将新密码发送给用户。",
+			"send_temporary_password": "请确保复制此屏幕上的临时密码并将其发送给用户。",
+			"existing_email": "以下电子邮件已存在"
+		},
+		"user_table": {
+			"administrator": "管理员",
+			"edit_menu": "编辑菜单",
+			"reset_password": "重置密码",
+			"administrator_menu": "管理员菜单",
+			"accept": "接受",
+			"deactivate_account": "停用帐户",
+			"your_own": "您不能停用自己的帐户",
+			"remove_admin_access": "删除管理员访问权限",
+			"cannot_remove": "您不能从管理员中删除自己",
+			"give_admin_access": "授予管理员访问权限"
+		},
+		"reset_password": "重置密码",
+		"reset_password_modal": {
+			"password_never_seen": "The temporary password can never be retrieved after this screen is closed.",
+			"password_reset_message": "Let the user know the new password below and strongly recommend to change another one immediately.",
+			"send_new_password": "Please send the new password to the user.",
+			"target_user": "Target User",
+			"new_password": "New Password"
+		},
+		"external_account": "外部账户管理",
+		"external_accounts": "外部账户",
+		"create_external_account": "创建外部账户",
+		"external_account_list": "外部账户列表",
+		"invite": "邀请",
+		"invited": "已邀请用户",
+		"back_to_user_management": "返回用户管理",
+		"authentication_provider": "身份认证",
+		"manage": "管理",
+		"password_setting": "密码设置",
+		"password_setting_help": "是否设置了密码?",
+		"set": "是",
+		"unset": "否",
+		"related_username": "相关用户的",
+		"cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
+		"current_users": "当前用户:"
+	},
+	"user_group_management": {
+		"create_group": "创建新组",
+		"deny_create_group": "不能用当前设置创建新组。",
+		"group_name": "组名",
+		"group_example": "e.g.:第1组",
+		"add_modal": {
+			"add_user": "将用户添加到创建的组",
+			"search_option": "搜索选项",
+			"enable_option": "启用{{option}",
+			"forward_match": "Forword匹配",
+			"partial_match": "部分匹配",
+			"backward_match": "向后匹配"
+		},
+		"group_list": "组列表",
+		"back_to_list": "返回组列表",
+		"basic_info": "基本信息",
+		"user_list": "用户列表",
+		"created_group": "已创建组",
+		"is_loading_data": "获取数据。。。",
+		"no_pages": "组没有查看权限的页面。",
+		"remove_from_group": "删除此用户",
+		"delete_modal": {
+			"header": "删除组",
+			"desc": "删除后,将无法检索已删除的组及其私人页。",
+			"dropdown_desc": "为私人页选择操作",
+			"select_group": "选择组",
+			"no_groups": "没有可选择的组",
+			"publish_pages": "全部发布",
+			"delete_pages": "全部删除",
+			"transfer_pages": "转移到另一组"
+		}
+	}
+}

+ 14 - 0
resource/locales/zh-CN/admin/userInvitation.txt

@@ -0,0 +1,14 @@
+Hi, {{ email }}
+
+You are invited to our Wiki, you can log in with following account:
+
+Email: {{ email }}
+Password: {{ password }}
+(This password was auto generated. Update required at the first time you logging in)
+
+We are waiting for you!
+{{ url }}
+
+--
+{{ appTitle }}
+{{ url }}

+ 21 - 0
resource/locales/zh-CN/admin/userWaitingActivation.txt

@@ -0,0 +1,21 @@
+Hi, {{ adminUser.name }}
+
+A user registered to {{ appTitle }}.
+
+
+====
+Created user:
+
+Name: {{ createdUser.name }}
+User Name: {{ createdUser.username }}
+Email: {{ createdUser.email }}
+====
+
+Please do some action with following URL:
+{{ url }}/admin/users
+
+
+--
+{{ appTitle }}
+{{ url }}
+

+ 9 - 0
resource/locales/zh-CN/notifications/comment.txt

@@ -0,0 +1,9 @@
+{{ username }} commented on {{ path }}.
+
+----------------------
+
+{{ comment }}
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
resource/locales/zh-CN/notifications/pageCreate.txt

@@ -0,0 +1,5 @@
+{{ username }} created a new page under {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
resource/locales/zh-CN/notifications/pageDelete.txt

@@ -0,0 +1,5 @@
+{{ username }} deleted the page  {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
resource/locales/zh-CN/notifications/pageEdit.txt

@@ -0,0 +1,5 @@
+{{ username }} edited the page {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
resource/locales/zh-CN/notifications/pageLike.txt

@@ -0,0 +1,5 @@
+{{ username }} liked the page {{ path }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 5 - 0
resource/locales/zh-CN/notifications/pageMove.txt

@@ -0,0 +1,5 @@
+{{ username }} renamed the page {{ oldPath }} to {{ newPath }}.
+
+----------------------
+
+Growi: {{ appTitle }}

+ 253 - 0
resource/locales/zh-CN/sandbox-bootstrap4.md

@@ -0,0 +1,253 @@
+# Labels
+
+<span class="badge badge-primary">Primary</span>
+<span class="badge badge-secondary">Secondary</span>
+<span class="badge badge-success">Success</span>
+<span class="badge badge-info">Info</span>
+<span class="badge badge-warning">Warning</span>
+<span class="badge badge-danger">Danger</span>
+<span class="badge badge-light text-dark">Light</span>
+<span class="badge badge-dark">Dark</span>
+
+<span class="badge badge-blue">Blue</span>
+<span class="badge badge-indigo">Indigo</span>
+<span class="badge badge-purple">Purple</span>
+<span class="badge badge-pink">Pink</span>
+<span class="badge badge-red">Red</span>
+<span class="badge badge-orange">Orange</span>
+<span class="badge badge-yellow">Yellow</span>
+<span class="badge badge-green">Green</span>
+<span class="badge badge-teal">Teal</span>
+<span class="badge badge-cyan">Cyan</span>
+
+
+# Alerts
+
+<div class="alert alert-primary" role="alert">
+  This is a primary alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-secondary" role="alert">
+  This is a secondary alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-success" role="alert">
+  This is a success alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-danger" role="alert">
+  This is a danger alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-warning" role="alert">
+  This is a warning alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-info" role="alert">
+  This is a info alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-light text-dark" role="alert">
+  This is a light alert with <a href="#" class="alert-link text-dark">an example link</a>. Give it a click if you like.
+</div>
+<div class="alert alert-dark" role="alert">
+  This is a dark alert with <a href="#" class="alert-link">an example link</a>. Give it a click if you like.
+</div>
+
+# Cards
+
+<div class="d-flex">
+
+<div class="mr-3">
+<div class="card text-white bg-primary mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Primary card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card text-white bg-secondary mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Secondary card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card text-white bg-success mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Success card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card text-white bg-danger mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Danger card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card text-white bg-warning mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Warning card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card text-white bg-info mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Info card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card bg-light mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Light card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card text-white bg-dark mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Dark card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+</div>
+
+<div>
+<div class="card border-primary mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-primary">
+    <h5 class="card-title">Primary card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-secondary mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-secondary">
+    <h5 class="card-title">Secondary card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-success mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-success">
+    <h5 class="card-title">Success card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-danger mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-danger">
+    <h5 class="card-title">Danger card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-warning mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-warning">
+    <h5 class="card-title">Warning card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-info mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-info">
+    <h5 class="card-title">Info card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-light mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Light card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+<div class="card border-dark mb-3" style="max-width: 18rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body text-dark">
+    <h5 class="card-title">Dark card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+</div>
+
+</div>
+
+# Wells
+
+## Default well
+
+<div class="card card-body">Look, I'm in a well! </div>
+
+## Optional classes
+
+<div class="card card-body bg-primary text-light p-2">Look, I'm in a well! </div>
+
+# Typography
+
+## Lead body copy
+
+<p class="lead">Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Duis mollis, est non commodo luctus.</p>
+
+## Marked text
+
+You can use the mark tag to <mark>highlight</mark> text.
+
+## Small text
+
+<small>This line of text is meant to be treated as fine print.</small>
+
+## Alignment classes
+
+<div class="card">
+  <div class="card-body">
+    <p class="text-left">Left aligned text.</p>
+    <p class="text-center">Center aligned text.</p>
+    <p class="text-right">Right aligned text.</p>
+    <p class="text-justify">Justified text.</p>
+    <p class="text-nowrap">No wrap text.</p>
+  </div>
+</div>
+
+## Transformation classes
+
+<div class="card">
+  <div class="card-body">
+    <p class="text-lowercase">Lowercased text.</p>
+    <p class="text-uppercase">Uppercased text.</p>
+    <p class="text-capitalize">Capitalized text.</p>
+  </div>
+</div>
+
+
+# Helper classes
+
+## Contextual colors
+
+<div class="card">
+  <div class="card-body">
+    <p class="text-muted">Fusce dapibus, tellus ac cursus commodo, tortor mauris nibh.</p>
+    <p class="text-light">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
+    <p class="text-secondary">Sed luctus venenatis tellus, in aliquam ligula scelerisque eget.</p>
+    <p class="text-dark">Ut vel lorem aliquet, rhoncus libero at, condimentum mi. Fusce pellentesque quam nec magna maximus porta.</p>
+    <p class="text-primary">Nullam id dolor id nibh ultricies vehicula ut id elit.</p>
+    <p class="text-success">Duis mollis, est non commodo luctus, nisi erat porttitor ligula.</p>
+    <p class="text-info">Maecenas sed diam eget risus varius blandit sit amet non magna.</p>
+    <p class="text-warning">Etiam porta sem malesuada magna mollis euismod.</p>
+    <p class="text-danger">Donec ullamcorper nulla non metus auctor fringilla.</p>
+  </div>
+</div>
+
+## Contextual backgrounds
+
+<div class="card">
+  <div class="card-body">
+    <p class="bg-light">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
+    <p class="bg-secondary text-white">Sed luctus venenatis tellus, in aliquam ligula scelerisque eget.</p>
+    <p class="bg-dark text-white">Ut vel lorem aliquet, rhoncus libero at, condimentum mi.</p>
+    <p class="bg-primary text-white">Nullam id dolor id nibh ultricies vehicula ut id elit.</p>
+    <p class="bg-success text-white">Duis mollis, est non commodo luctus, nisi erat porttitor ligula.</p>
+    <p class="bg-info text-white">Maecenas sed diam eget risus varius blandit sit amet non magna.</p>
+    <p class="bg-warning text-white">Etiam porta sem malesuada magna mollis euismod.</p>
+    <p class="bg-danger text-white">Donec ullamcorper nulla non metus auctor fringilla.</p>
+  </div>
+</div>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 7 - 0
resource/locales/zh-CN/sandbox-diagrams.md


+ 71 - 0
resource/locales/zh-CN/sandbox-math.md

@@ -0,0 +1,71 @@
+# :pencil: Math
+
+See [MathJax](https://www.mathjax.org/).
+
+## Inline Formula
+
+When $a \ne 0$, there are two solutions to \(ax^2 + bx + c = 0\) and they are
+  $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
+
+## The Lorenz Equations
+
+$$
+\begin{align}
+\dot{x} & = \sigma(y-x) \\
+\dot{y} & = \rho x - y - xz \\
+\dot{z} & = -\beta z + xy
+\end{align}
+$$
+
+
+## The Cauchy-Schwarz Inequality
+
+$$
+\left( \sum_{k=1}^n a_k b_k \right)^{\!\!2} \leq
+ \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)
+$$
+
+## A Cross Product Formula
+
+$$
+\mathbf{V}_1 \times \mathbf{V}_2 =
+ \begin{vmatrix}
+  \mathbf{i} & \mathbf{j} & \mathbf{k} \\
+  \frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
+  \frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0 \\
+ \end{vmatrix}
+$$
+
+
+## The probability of getting $\left(k\right)$ heads when flipping $\left(n\right)$ coins is:
+
+$$
+P(E) = {n \choose k} p^k (1-p)^{ n-k}
+$$
+
+## An Identity of Ramanujan
+
+$$
+\frac{1}{(\sqrt{\phi \sqrt{5}}-\phi) e^{\frac25 \pi}} =
+     1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}}
+      {1+\frac{e^{-8\pi}} {1+\ldots} } } }
+$$
+
+## A Rogers-Ramanujan Identity
+
+$$
+1 +  \frac{q^2}{(1-q)}+\frac{q^6}{(1-q)(1-q^2)}+\cdots =
+    \prod_{j=0}^{\infty}\frac{1}{(1-q^{5j+2})(1-q^{5j+3})},
+     \quad\quad \text{for $|q|<1$}.
+$$
+
+## Maxwell's Equations
+
+$$
+\begin{align}
+  \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \\
+  \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
+  \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
+  \nabla \cdot \vec{\mathbf{B}} & = 0
+\end{align}
+$$

+ 457 - 0
resource/locales/zh-CN/sandbox.md

@@ -0,0 +1,457 @@
+<div class="card">
+  <div class="card-body">
+
+# Table of Contents
+
+```
+@[toc]
+```
+
+@[toc]
+
+  </div>
+</div>
+
+# :pencil: Block Elements
+
+## Headers
+
+Add one `#` per level at the start of the line
+
+```
+# Header 1
+## Header 2
+### Header 3
+#### Header 4
+##### Header 5
+###### Header 6
+```
+
+### Header 3
+
+#### Header 4
+
+##### Header 5
+
+###### Header 6
+
+## Block paragraph
+
+Pararaphs are created by inserting a newline character
+A paragraph can be created by pressing Enter at the end of the previous paragraph.
+
+```
+paragraph1
+(Blank line)
+paragraph2
+```
+
+paragraph1
+
+paragraph2
+
+## Br new line
+
+Add two spaces before break.
+***This behaviour can be modified in the options menu.***
+
+```
+hoge
+fuga(two spaces)
+piyo
+```
+
+hoge
+fuga
+piyo
+
+## Blockquotes
+
+Add one `>` per level at the start of the line
+
+```
+> quote
+> quote
+>> nested quotes
+```
+
+> quote
+> quote
+>> nested quotes
+
+## Code
+
+Wrap code with three back quotes or tildes.
+
+```
+print 'hoge'
+```
+
+### Syntax highlight and file name
+
+- corresponding [highlight.js Demo](https://highlightjs.org/static/demo/) of common category
+
+
+~~~
+```javascript:mersenne-twister.js
+function MersenneTwister(seed) {
+  if (arguments.length == 0) {
+    seed = new Date().getTime();
+  }
+
+  this._mt = new Array(624);
+  this.setSeed(seed);
+}
+```
+~~~
+
+```javascript:mersenne-twister.js
+function MersenneTwister(seed) {
+  if (arguments.length == 0) {
+    seed = new Date().getTime();
+  }
+
+  this._mt = new Array(624);
+  this.setSeed(seed);
+}
+```
+
+### Inline code
+
+Words wrapped by `` `back quotes` `` will be formatted as inline code.
+
+```
+This is `Inline Code`.
+```
+
+This is  `Inline Code`.
+
+## Pre-arranged text
+
+Code blocks should be preceded by four spaces or one tab.
+
+```
+    class Hoge
+        def hoge
+            print 'hoge'
+        end
+    end
+```
+
+    class Hoge
+        def hoge
+            print 'hoge'
+        end
+    end
+
+## Horizontal Line
+
+Write three underscores `_`, or asterisks`*`.
+
+```
+***
+___
+---
+```
+
+***
+___
+---
+
+
+
+# :pencil: Typography
+
+## Strong Text
+
+### Italic
+
+To italicize text, add One asterisk or underscores before and after a word or phrase.
+
+```
+This is *Italic* .
+This is _Italic_ .
+```
+
+This is *Italic* .
+This is _Italic_ .
+
+### Bold
+
+To bold text, add two asterisks or underscores before and after a word or phrase.
+
+```
+This is **bold**.
+This is __bold__.
+```
+
+This is **bold**.
+This is __bold__.
+
+### Bold + Italic
+
+To bold and italicize text, add three asterisks or underscores before and after a word or phrase.
+
+```
+This is ***Italic & Bold***.
+This is ___Italic & Bold___.
+```
+
+This is ***Italic & Bold***.
+This is ___Italic & Bold___.
+
+# :pencil: Images
+
+You can insert `<img>` tag using `![description](URL)`.
+
+```markdown
+![Minion](https://octodex.github.com/images/minion.png)
+![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")
+```
+
+![Minion](https://octodex.github.com/images/minion.png)
+![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat")
+
+The size of the image can be set by using an HTML image tag
+
+```html
+<img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
+```
+
+<img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
+
+
+# :pencil: Link
+
+## Markdown standard
+
+You can create links using `[Display text](URL)`.
+
+```
+[Google](https://www.google.co.jp/)
+```
+
+[Google](https://www.google.co.jp/)
+
+## Crowi compatibility
+
+```
+[/Sandbox]
+&lt;/user/admin1>
+```
+
+[/Sandbox]
+</user/admin1>
+
+## Pukiwiki like linker
+
+(available by [weseek/growi-plugin-pukiwiki-like-linker
+](https://github.com/weseek/growi-plugin-pukiwiki-like-linker) )
+
+This is the most flexible linker.
+Both the page description and link address can be displayed on the page.
+
+```
+[[./Bootstrap4]]
+Example of Bootstrap4 is[[here>./Bootstrap4]]
+```
+
+[[../user]]
+Example of Bootstrap4 is[[here>./Bootstrap4]]
+
+# :pencil: Lists
+
+## Ul Bulleted list
+
+To create an unordered list, add dashes (-), asterisks (*), or plus signs (+) in front of line items. 
+Items can be nested using indentation.
+
+```
+- List1
+    - List1_1
+        - List1_1_1
+        - List1_1_2
+    - List1_2
+- List2
+- List3
+```
+
+- List1
+    - List1_1
+        - List1_1_1
+        - List1_1_2
+    - List1_2
+- List2
+- List3
+
+## Ol Numbered List
+
+To create an ordered list, add line items with numbers followed by periods. 
+The numbers don’t have to be in numerical order, but the list should start with the number one.
+
+```
+1. Number list 1
+    1. Number list 1-1
+    1. Number list 1-2
+1. Number list 2
+1. Number list 3
+```
+
+1. Number list 1
+    1. Number list 1-1
+    1. Number list 1-2
+1. Number list 2
+1. Number list 3
+
+
+## Check list
+
+```
+- [ ] Task 1
+    - [x] Task 1.1
+    - [ ] Task 1.2
+- [x] Task2
+```
+
+- [ ] Task 1
+    - [x] Task 1.1
+    - [ ] Task 1.2
+- [x] Task2
+
+
+# :pencil: Table
+
+## Markdown Standard
+
+```markdown
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+
+OR
+
+Left align | Right align | Center align
+:--|--:|:-:
+This       | This        | This
+column     | column      | column
+will       | will        | will
+be         | be          | be
+left       | right       | center
+aligned    | aligned     | aligned
+```
+
+| Left align | Right align | Center align |
+|:-----------|------------:|:------------:|
+| This       | This        | This         |
+| column     | column      | column       |
+| will       | will        | will         |
+| be         | be          | be           |
+| left       | right       | center       |
+| aligned    | aligned     | aligned      |
+
+## TSV (crowi-plus notation)
+
+```
+::: tsv
+Content Cell  Content Cell
+Content Cell  Content Cell
+:::
+```
+
+::: tsv
+Content Cell Content Cell
+Content Cell Content Cell
+:::
+
+## TSV with header (crowi-plus notation)
+
+```
+::: tsv-h
+First Header Second Header
+Content Cell Content Cell
+Content Cell Content Cell
+:::
+```
+
+::: tsv-h
+First Header Second Header
+Content Cell Content Cell
+Content Cell Content Cell
+:::
+
+## CSV (crowi-plus original notation)
+
+```
+::: csv
+Content Cell,Content Cell
+Content Cell,Content Cell
+:::
+```
+
+::: csv
+Content Cell,Content Cell
+Content Cell,Content Cell
+:::
+
+## CSV with header (crowi-plus original notation)
+
+```
+::: csv-h
+First Header,Second Header
+Content Cell,Content Cell
+Content Cell,Content Cell
+:::
+```
+
+::: csv-h
+First Header,Second Header
+Content Cell,Content Cell
+Content Cell,Content Cell
+:::
+
+
+# :pencil: Footnote
+
+You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
+
+Long footnotes can be written as [^longnote].
+
+[^1]: A_reference_to_the_first_footnote.
+
+[^longnote]: An_example_of_writing_a_footnote_in_multiple_blocks.
+
+    Subsequent paragraphs are indented and belong to the previous footnote.
+
+
+# :pencil: Emoji
+
+See [emojione](https://www.emojione.com/)
+
+:smiley: :smile: :laughing: :innocent: :drooling_face:
+
+:family: :family_man_boy: :family_man_girl: :family_man_girl_girl: :family_woman_girl_girl:
+
+:thumbsup: :thumbsdown: :open_hands: :raised_hands: :point_right:
+
+:apple: :green_apple: :strawberry: :cake: :hamburger:
+
+:basketball: :football: :baseball: :volleyball: :8ball:
+
+:hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
+
+:watch: :gear: :gem: :wrench: :envelope:
+
+
+# :heavy_plus_sign: More..
+
+- Try to attach Bootstrap4 Tags?
+    - :arrow_right: [/Sandbox/Bootstrap4]
+- Try to draw Diagrams?
+    - :arrow_right: [/Sandbox/Diagrams]
+- Try to write Math Formulas?
+    - :arrow_right: [/Sandbox/Math]

+ 700 - 0
resource/locales/zh-CN/translation.json

@@ -0,0 +1,700 @@
+{
+	"Help": "帮助",
+	"Edit": "编辑",
+	"Delete": "删除",
+	"delete_all": "删除所有",
+	"Duplicate": "复制",
+	"Copy": "复制",
+	"Login": "登录",
+	"Click to copy": "点击复制",
+	"Move/Rename": "移动/重命名",
+	"Moved": "移动",
+	"Redirected": "重定向",
+	"Unlinked": "Unlinked",
+	"Like!": "Like!",
+	"Seen by": "Seen by",
+	"Cancel": "取消",
+	"Create": "创建",
+	"Admin": "管理",
+	"administrator": "管理员",
+	"Tag": "标签",
+	"Tags": "Tags",
+	"New": "新建",
+	"Shortcuts": "快捷方式",
+	"eg": "e.g.",
+	"add": "添加",
+	"Undo": "撤销",
+	"Article": "主题",
+	"Page": "页面",
+	"Page Path": "相对路径",
+	"Category": "分类",
+	"User": "用户",
+	"status": "状态",
+	"account_id": "用户Id",
+	"Update": "更新",
+	"Update Page": "更新本页",
+	"Warning": "警告",
+	"Sign in": "登录",
+	"Sign up is here": "注册",
+	"Sign in is here": "登录",
+	"Sign up": "注册",
+	"Sign up with Google Account": "Sign up with Google Account",
+	"Sign in with Google Account": "Sign in with Google Account",
+	"Sign up with this Google Account": "Sign up with this Google Account",
+	"Example": "例如",
+	"Taro Yamada": "John Doe",
+	"List View": "列表",
+	"Timeline View": "时间线",
+	"History": "历史",
+	"Presentation Mode": "演示文稿",
+	"Not available for guest": "Not available for guest",
+	"username": "用户名",
+	"Created": "创建",
+	"Last updated": "上次更新",
+	"Last_Login": "上次登录",
+	"Share": "分享",
+	"Share Link": "分享链接",
+	"Markdown Link": "Markdown链接",
+	"Create/Edit Template": "创建/编辑 模板页面",
+	"Unportalize": "未启动",
+	"Go to this version": "查看此版本",
+	"View diff": "查看差异",
+	"No diff": "无差异",
+	"Shrink versions that have no diffs": "收缩没有差异的版本",
+	"User ID": "用户ID",
+	"Home": "首页",
+	"User Settings": "用户设置",
+	"User Information": "用户信息",
+	"Basic Info": "基础信息",
+	"Name": "姓名",
+	"Email": "邮箱",
+	"Language": "语言",
+	"English": "英语",
+	"Japanese": "日语",
+	"Chinese": "简体中文",
+	"Set Profile Image": "头像",
+	"Upload Image": "上传图片",
+	"Current Image": "当前图片",
+	"Delete Image": "删除图片",
+	"Delete this image?": "删除图片?",
+	"Updated": "更新",
+	"Upload new image": "上传新图像",
+	"Connected": "Connected",
+	"Show": "显示",
+	"Hide": "隐藏",
+	"Reset": "重置",
+	"Disclose E-mail": "显示邮箱",
+	"page exists": "页面已存在",
+	"Error occurred": "Error occurred",
+	"Create today's": "Create today's ...",
+	"Memo": "memo",
+	"Input page name": "Input page name",
+	"Input page name (optional)": "Input page name (optional)",
+	"New Page": "新页面",
+	"Create under": "Create page under below:",
+	"Table of Contents": "Table of Contents",
+	"Wiki Management Home Page": "Wiki管理首页",
+	"App Settings": "系统设置",
+	"Site URL settings": "主页URL设置",
+	"Markdown Settings": "Markdown设置",
+	"Customize": "页面定制",
+	"Notification Settings": "通知设置",
+	"User_Management": "用户管理",
+	"external_account_management": "外部账户管理",
+	"UserGroup Management": "用户组管理",
+	"Full Text Search Management": "全文搜索管理",
+	"Import Data": "导入数据",
+	"Export Archive Data": "导出主题数据",
+	"Basic Settings": "基础设置",
+	"Basic authentication": "基本身份验证",
+	"Register limitation": "注册限制",
+	"The contents entered here will be shown in the header etc": "此处输入的内容将显示在标题等中",
+	"Public": "公共",
+	"Anyone with the link": "任何人",
+	"Specified users only": "仅指定用户",
+	"Only me": "只有我",
+	"Only inside the group": "仅组内",
+	"page_list_and_search_results": "页面列表/搜索结果",
+	"scope_of_page_disclosure": "页面公开范围",
+	"set_point": "设定值",
+	"always_displayed": "始终显示",
+	"always_hidden": "总是隐藏",
+	"displayed_or_hidden": "显示/隐藏",
+	"page_access_and_delete_rights": "页面访问/删除权限",
+	"Reselect the group": "重新选择组",
+	"Shareable link": "可分享链接",
+	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
+	"Add tags for this page": "添加标签",
+	"Edit tags for this page": "编辑标签",
+	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
+	"Show latest": "显示最新",
+	"Load latest": "家在最新",
+	"edited this page": "edited this page.",
+	"List Drafts": "草稿",
+	"Deleted Pages": "已删除页",
+	"Sign out": "退出",
+	"Disassociate": "解除关联",
+	"Recent Created": "最新创建",
+	"Recent Changes": "最新修改",
+	"form_validation": {
+		"error_message": "有些值不正确",
+		"required": "%s 是必需的",
+		"invalid_syntax": "%s的语法无效。"
+	},
+	"installer": {
+		"setup": "安装",
+		"create_initial_account": "创建初始用户",
+		"initial_account_will_be_administrator_automatically": "初始帐户将自动成为管理员。",
+		"unavaliable_user_id": "用户ID不可用"
+	},
+	"breaking_changes": {
+		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
+	},
+	"page_register": {
+		"notice": {
+			"restricted": "需要管理员批准。",
+			"restricted_defail": "一旦管理员批准您的注册,您就可以访问此wiki。"
+		},
+		"form_help": {
+			"email": "您必须有下面列出的电子邮件地址才能注册此wiki。",
+			"password": "密码长度必须至少为6个字符。",
+			"user_id": "您创建的网页的URL将包含您的用户ID。您的用户ID可以由字母、数字和一些符号组成。"
+		}
+	},
+	"Settings": "设置",
+	"page_me": {
+		"form_help": {
+			"profile_image1": "图像上传设置未完成。",
+			"profile_image2": "设置AWS或启用本地上传。"
+		}
+	},
+	"page_me_apitoken": {
+		"notice": {
+			"apitoken_issued": "API token 未发布。",
+			"update_token1": "您可以更新以生成新的API令牌。",
+			"update_token2": "您需要更新任何现有进程中的API令牌。"
+		}
+	},
+	"Password": "密码",
+	"Password Settings": "密码设置",
+	"personal_settings": {
+		"disassociate_external_account": "解除与外部帐户的关联",
+		"disassociate_external_account_desc": "是否确实要解除与<strong>{{providerType}}</strong>帐户<strong>{{providerType}}</strong> 的关联?",
+		"set_new_password": "设置新密码",
+		"update_password": "更新密码",
+		"current_password": "当前密码",
+		"new_password": "新密码",
+		"new_password_confirm": "重复新密码",
+		"password_is_not_set": "密码未设置"
+	},
+	"Security Settings": "安全设置",
+	"API Settings": "API设置",
+	"API Token Settings": "API token 设置",
+	"Current API Token": "当前 API token",
+	"Update API Token": "更新 API token",
+	"header_search_box": {
+		"label": {
+			"All pages": "所有页面",
+			"This tree": "当前分支"
+		},
+		"item_label": {
+			"All pages": "所有页面",
+			"This tree": "当前分支以下内容"
+		}
+	},
+	"copy_to_clipboard": {
+		"Copy to clipboard": "复制到剪贴板",
+		"Page path": "页面路径",
+		"Page URL": "页面Url",
+		"Parmanent link": "参数化链接",
+		"Page path and parmanent link": "页面路径及参数化链接",
+		"Markdown link": "Markdown链接"
+	},
+	"search_help": {
+		"title": "搜索帮助",
+		"and": {
+			"syntax help": "用空格分隔",
+			"desc": "在标题或正文中同时包含{{word1}、{{word2}的搜索页"
+		},
+		"exclude": {
+			"desc": "排除标题或正文中包含{{word}的页"
+		},
+		"phrase": {
+			"syntax help": "用双引号括起来",
+			"desc": "包含短语“{{phrase}”的搜索页"
+		},
+		"prefix": {
+			"desc": "只搜索标题以{{path}开头的页"
+		},
+		"exclude_prefix": {
+			"desc": "排除标题以{{path}开头的页"
+		},
+		"tag": {
+			"desc": "搜索带有{{tag}标记的页面"
+		},
+		"exclude_tag": {
+			"desc": "排除带有{{tag}标记的页"
+		}
+	},
+	"search": {
+		"search page bodies": "按[回车]键进行全文搜索"
+	},
+	"page_page": {
+		"notice": {
+			"version": "这不是当前版本。",
+			"moved": "此页已从<code>%s</code>",
+			"redirected": "您将从<code>%s</code>",
+			"duplicated": "此页来自<code>%s</code>",
+			"unlinked": "将网页重定向到此网页已被删除。",
+			"restricted": "访问此页受到限制",
+			"stale": "自上次更新以来,已超过{{count}年。",
+			"stale_plural": "自上次更新以来已过去{{count}年以上。"
+		}
+	},
+	"page_edit": {
+		"Show active line": "显示活动行",
+		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
+		"notice": {
+			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
+		}
+	},
+	"page_api_error": {
+		"notfound_or_forbidden": "未找到或禁止原始页。",
+		"already_exists": "新建页面已存在",
+		"outdated": "页面已被某人更新,现在已过时。",
+		"user_not_admin": "仅管理员用户可以完全删除"
+	},
+	"modal_rename": {
+		"label": {
+			"Move/Rename page": "页面 移动/重命名",
+			"New page name": "新建页面名称",
+			"Current page name": "当前页面名称",
+			"Recursively": "递归地",
+			"Do not update metadata": "不更新元数据",
+			"Redirect": "重定向"
+		},
+		"help": {
+			"redirect": "Redirect to new page if someone accesses <code>%s</code>",
+			"metadata": "Remains last update user and updated date as is",
+			"recursive": "Move/Rename children of under <code>%s</code> recursively"
+		}
+	},
+	"Put Back": "Put back",
+	"Delete Completely": "Delete completely",
+	"modal_delete": {
+		"delete_page": "Delete page",
+		"deleting_page": "Deleting page",
+		"delete_recursively": "Delete child pages recursively.",
+		"delete_completely": "Delete completely",
+		"delete_completely_restriction": "You don't have the authority to delete pages completely.",
+		"recursively": "Delete children of <code>%s</code> recursively.",
+		"completely": "Delete completely instead of putting it into trash."
+	},
+	"modal_empty": {
+		"empty_the_trash": "Empty The Trash",
+		"notice": "完全删除的页面是不可恢复的。"
+	},
+	"modal_duplicate": {
+		"label": {
+			"Duplicate page": "Duplicate page",
+			"New page name": "New page name",
+			"Current page name": "Current page name"
+		}
+	},
+	"modal_putback": {
+		"label": {
+			"Put Back Page": "Put back page",
+			"recursively": "Put back recursively"
+		},
+		"help": {
+			"recursively": "Put back children of under <code>%s</code> recursively"
+		}
+	},
+	"modal_shortcuts": {
+		"global": {
+			"title": "全局快捷方式",
+			"Open/Close shortcut help": "打开/关闭快捷方式帮助",
+			"Edit Page": "编辑页面",
+			"Create Page": "创建页面",
+			"Show Contributors": "显示参与者",
+			"Konami Code": "Konami Code",
+			"konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
+		},
+		"editor": {
+			"title": "编辑器快捷方式",
+			"Indent": "缩进",
+			"Outdent": "回退缩进",
+			"Save Page": "保存页面",
+			"Delete Line": "删除行"
+		},
+		"commentform": {
+			"title": "注释窗体快捷方式",
+			"Post": "提交"
+		}
+	},
+	"toaster": {
+		"update_successed": "Succeeded to update {{target}}",
+		"give_user_admin": "Succeeded to give {{username}} admin",
+		"remove_user_admin": "Succeeded to remove {{username}} admin ",
+		"activate_user_success": "Succeeded to activating {{username}}",
+		"deactivate_user_success": "Succeeded to deactivate {{username}}",
+		"remove_user_success": "Succeeded to removing {{username}} ",
+		"remove_external_user_success": "Succeeded to remove {{accountId}} "
+	},
+	"template": {
+		"modal_label": {
+			"Create/Edit Template Page": "创建/编辑模板页",
+			"Create template under": "在下面创建模板页:<br/><code><small>%s</small></code>"
+		},
+		"option_label": {
+			"create/edit": "创建/编辑模板页。",
+			"select": "选择模板页面类型"
+		},
+		"children": {
+			"label": "子模板",
+			"desc": "仅应用于模板存在的同一级别页"
+		},
+		"decendants": {
+			"label": "子代模板",
+			"desc": "适用于所有分散页"
+		}
+	},
+	"sandbox": {
+		"header": "标题",
+		"header_x": "标题{{index}",
+		"block": "段落",
+		"block_detail": "写一段",
+		"empty_line": "空行",
+		"line_break": "换行符",
+		"line_break_detail": "(2空格)换行",
+		"typography": "排版",
+		"italics": "斜体",
+		"bold": "加粗",
+		"italic_bold": "斜体加粗",
+		"strikethrough": "删除线",
+		"link": "链接",
+		"code_highlight": "代码突出显示",
+		"list": "列表",
+		"unordered_list_x": "无序列表{{index}}",
+		"ordered_list_x": "有序列表{{index}}",
+		"task": "任务",
+		"task_checked": "选中的",
+		"task_unchecked": "未选中的",
+		"quote": "引用",
+		"quote1": "你可以写",
+		"quote2": "多行引用",
+		"quote_nested": "嵌套引用",
+		"table": "表格",
+		"image": "图片",
+		"alt_text": "Alt文本",
+		"insert_image": "插入图像",
+		"open_sandbox": "开放式沙箱"
+	},
+	"hackmd": {
+		"not_set_up": "HackMD is not set up.",
+		"start_to_edit": "Start to edit with HackMD",
+		"clone_page_content": "Click to clone page content and start to edit.",
+		"unsaved_draft": "HackMD has unsaved draft.",
+		"draft_outdated": "DRAFT MAY BE OUTDATED",
+		"based_on_revision": "The current draft on HackMD is based on",
+		"view_outdated_draft": "View the outdated draft on HackMD",
+		"resume_to_edit": "Resume to edit with HackMD",
+		"discard_changes": "Discard changes of HackMD",
+		"integration_failed": "HackMD Integration failed",
+		"fail_to_connect": "GROWI client failed to connect to GROWI agent for HackMD.",
+		"check_configuration": "Check your configuration following <a href='https://docs.growi.org/guide/admin-cookbook/integrate-with-hackmd.html'>the manual</a>.",
+		"not_initialized": "HackmdEditor component has not initialized",
+		"someone_editing": "Someone editing this page on HackMD",
+		"this_page_has_draft": "This page has a draft on HackMD"
+	},
+	"security_settings": "安全设置",
+	"security_setting": {
+		"Security settings": "安全设置",
+		"Guest Users Access": "来宾用户访问",
+		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
+		"Register limitation": "注册限制",
+		"Register limitation desc": "限制新用户注册",
+		"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
+		"users_without_account": "无法访问没有帐户的用户",
+		"example": "例子",
+		"restrict_emails": "您可以通过编写电子邮件域(以@开头)将电子邮件注册限制为wiki。",
+		"for_example": " 例如,如果要将注册限制为growi.org网站域,你可以写",
+		"in_this_case": ";在这种情况下,只有growi.org网站域将能够注册,所有其他用户将被拒绝。",
+		"insert_single": "请每行插入一个电子邮件地址。",
+		"page_listing_1": "页面列表/搜索<br>受“仅限我”限制",
+		"page_listing_1_desc": "列出/搜索时显示受“仅限我”选项限制的页面",
+		"page_listing_2": "页面列表/搜索<br>受用户组限制",
+		"page_listing_2_desc": "显示列出/搜索时受用户组限制的页面",
+		"complete_deletion": "限制完全删除页面",
+		"complete_deletion_explain": "限制可以完全删除页面的用户。",
+		"admin_only": "仅管理员",
+		"admin_and_author": "管理员|作者",
+		"anyone": "任何人",
+		"Authentication mechanism settings": "身份验证机制设置",
+		"setup_is_not_yet_complete": "安装尚未完成",
+		"alert_siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
+		"xss_prevent_setting": "阻止XSS(跨站点脚本)",
+		"xss_prevent_setting_link": "转到Markdown设置",
+		"callback_URL": "回调URL",
+		"providerName": "提供程序名称",
+		"issuerHost": "发行者主机",
+		"scope": "Scope",
+		"desc_of_callback_URL": "在{{AuthName}}身份提供程序的设置中使用它",
+		"clientID": "Client ID",
+		"client_secret": "客户机密",
+		"updated_general_security_setting": "更新安全设置成功",
+		"setup_not_completed_yet": "安装尚未完成",
+		"guest_mode": {
+			"deny": "拒绝(仅限注册用户)",
+			"readonly": "接受(来宾可以只读)"
+		},
+		"registration_mode": {
+			"open": "打开(任何人都可以注册)",
+			"restricted": "受限(需要管理员批准)",
+			"closed": "已关闭(仅限邀请)"
+		},
+		"configuration": " 配置",
+		"optional": "可选的",
+		"Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
+		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
+		"Treat email matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>email</code> match",
+		"Treat email matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>email</code>.",
+		"Use env var if empty": "Use env var <code>{{env}}</code> if empty",
+		"Use default if both are empty": "If both ​​are empty, the default value <code>{{target}}</code> is used.",
+		"missing mandatory configs": "The following mandatory items are not set in either database nor environment variables.",
+		"Local": {
+			"name": "ID/Password",
+			"note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}/code> .",
+			"enable_local": "Enable ID/Password"
+		},
+		"ldap": {
+			"enable_ldap": "Enable LDAP",
+			"server_url_detail": "The LDAP URL of the directory service in the format <code>ldap://host:port/DN</code> or <code>ldaps://host:port/DN</code>.",
+			"bind_mode": "Binding Mode",
+			"bind_manager": "Manager Bind",
+			"bind_user": "User Bind",
+			"bind_DN_manager_detail": "The DN of the account that authenticates and queries the directory service",
+			"bind_DN_user_detail1": "The query used to bind with the directory service.",
+			"bind_DN_user_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
+			"bind_DN_password": "Bind DN Password",
+			"bind_DN_password_manager_detail": "The password for the Bind DN account.",
+			"bind_DN_password_user_detail": "The password that is entered in the login page will be used to bind.",
+			"search_filter": "Search Filter",
+			"search_filter_detail1": "The query used to locate the authenticated user.",
+			"search_filter_detail2": "Use <code>&#123;&#123;username&#125;&#125;</code> to reference the username entered in the login page.",
+			"search_filter_detail3": "If empty, the filter <code>(uid=&#123;&#123;username&#125;&#125;)</code> is used.",
+			"search_filter_example1": "Match with 'uid' or 'mail'",
+			"search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
+			"username_detail": "Specification of mappings for <code>username</code> when creating new users",
+			"name_detail": "Specification of mappings for full name when creating new users",
+			"mail_detail": "Specification of mappings for mail address when creating new users",
+			"group_search_base_DN": "Group Search Base DN",
+			"group_search_base_DN_detail": "The base DN from which to search for groups. If defined, also <code>Group Search Filter</code> must be defined for the search to work.",
+			"group_search_filter": "Group Search Filter",
+			"group_search_filter_detail1": "The query used to filter for groups.",
+			"group_search_filter_detail2": "Login via LDAP is accepted only when this query hits one or more groups.",
+			"group_search_filter_detail3": "Use <code>&#123;&#123;dn&#125;&#125;</code> to have it replaced of the found user object.",
+			"group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
+			"group_search_user_DN_property": "User DN Property",
+			"group_search_user_DN_property_detail": "The property of user object to use in <code>&#123;&#123;dn&#125;&#125;</code> interpolation of <code>Group Search Filter</code>.",
+			"test_config": "Test Saved Configuration",
+			"updated_ldap": "Succeeded to update LDAP setting"
+		},
+		"SAML": {
+			"name": "SAML",
+			"enable_saml": "Enable SAML",
+			"id_detail": "Specification of the name of attribute which can identify the user in SAML Identity Provider",
+			"username_detail": "Specification of mappings for <code>username</code> when creating new users",
+			"mapping_detail": "Specification of mappings for {{target}} when creating new users",
+			"cert_detail": "PEM-encoded X.509 signing certificate to validate the response from IdP",
+			"Use env var if empty": "If the value in the database is empty, the value of the environment variable <code>{{env}}</code> is used.",
+			"note for the only env option": "The setting item that enables or disables the SAML authentication and the highlighted setting items use only the value of environment variables.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+			"attr_based_login_control_detail": "Limit who can sign up by using <code>&lt;saml: Attribute&gt;</code> element included in <code>&lt;saml: AttributeStatement&gt;</code> element and its child element <code>&lt;saml: AttributeValue&gt;</code>.",
+			"attr_based_login_control_rule_detail": "See <a href=\"https://lucene.apache.org/core/2_9_4/queryparsersyntax.html\" target=\"_blank\">Apache Lucene - Query Parser Syntax</a>.<h6>Supported Queries:</h6><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h6>Unsupported Queries:</h6><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul>",
+			"attr_based_login_control_rule_example": "<h6>Example</h6>If a rule is <code>(Department: A || Department: B) && Position: Leader</code>, users who have either <code>Department: A</code> or <code>Department: B</code> and have <code>Position: Leader</code> <strong>can</strong> sign in.",
+			"updated_saml": "Succeeded to update SAML setting"
+		},
+		"Basic": {
+			"enable_basic": "Enable Basic",
+			"name": "Basic Authentication",
+			"desc_1": "Login with <code>username</code> in Authorization header.",
+			"desc_2": "User will be automatically generated if not exist.",
+			"updated_basic": "Succeeded to update Basic setting"
+		},
+		"OAuth": {
+			"enable_oidc": "Enable OIDC",
+			"register": "Register for %s",
+			"change_redirect_url": "Enter <code>%s</code> <br>(where <code>%s</code> is your host name) for \"Authorized redirect URIs\".",
+			"Google": {
+				"enable_google": "Enable Google OAuth",
+				"name": "Google OAuth",
+				"register_1": "Access {{link}}",
+				"register_2": "Create Project if no projects exist",
+				"register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+				"register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
+				"register_5": "Copy and paste your ClientID and Client Secret above",
+				"updated_google": "Succeeded to update Google OAuth setting"
+			},
+			"Facebook": {
+				"name": "Facebook OAuth"
+			},
+			"Twitter": {
+				"enable_twitter": "Enable Twitter OAuth",
+				"name": "Twitter OAuth",
+				"register_1": "Access {{link}}",
+				"register_2": "Sign in Twitter",
+				"register_3": "Create Credentials &rightarrow; OAuth client ID &rightarrow; Select \"Web application\"",
+				"register_4": "Register your OAuth App with one of Authorized redirect URIs as <code>{{url}}</code>",
+				"register_5": "Copy and paste your ClientID and Client Secret above",
+				"updated_twitter": "Succeeded to update Twitter OAuth setting"
+			},
+			"GitHub": {
+				"enable_github": "Enable GitHub OAuth",
+				"name": "GitHub OAuth",
+				"register_1": "Access {{link}}",
+				"register_2": "Register your OAuth App with \"Authorization callback URL\" as <code>{{url}}</code>",
+				"register_3": "Copy and paste your ClientID and Client Secret above",
+				"updated_github": "Succeeded to update GitHub OAuth setting"
+			},
+			"OIDC": {
+				"name": "OpenID Connect",
+				"id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
+				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
+				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
+				"mapping_detail": "Specification of mappings for %s when creating new users",
+				"register_1": "Contant to OIDC IdP Administrator",
+				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
+				"register_3": "Copy and paste your ClientID and Client Secret above",
+				"updated_oidc": "Succeeded to update OpenID Connect"
+			},
+			"how_to": {
+				"google": "How to configure Google OAuth?",
+				"github": "How to configure GitHub OAuth?",
+				"twitter": "How to configure Twitter OAuth?",
+				"oidc": "How to configure OIDC?"
+			}
+		},
+		"form_item_name": {
+			"entryPoint": "Entry point",
+			"issuer": "Issuer",
+			"cert": "Certificate",
+			"attrMapId": "ID",
+			"attrMapUsername": "Username",
+			"attrMapMail": "Mail Address",
+			"attrMapFirstName": "First Name",
+			"attrMapLastName": "Last Name",
+			"ABLCRule": "Rule"
+		}
+	},
+	"notification_setting": {
+		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
+		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
+		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
+		"slack_app_configuration": "Slack app configuration",
+		"slack_app_configuration_desc": "This is the way that compatible with Crowi,<br /> but not recommended in GROWI because it is <strong>too complex</strong>.",
+		"use_instead": "Please use Slack Incoming Webhooks Configuration instead.",
+		"how_to": {
+			"header": "How to configure Incoming Webhooks?",
+			"workspace": "(At Workspace) Add a hook",
+			"workspace_desc1": "Go to <a href='https: //slack.com/services/new/incoming-webhook'>Incoming Webhooks configuration page</a>.",
+			"workspace_desc2": "Choose the default channel to post.",
+			"workspace_desc3": "Add.",
+			"at_growi": "(At GROWI admin page) Set Webhook URL",
+			"at_growi_desc": "Input &rdquo;Webhook URL&rdquo; and submit on this page."
+		},
+		"user_trigger_notification_header": "Default notification settings for patterns",
+		"pattern": "Pattern",
+		"channel": "Channel",
+		"pattern_desc": "Path name of wiki. Pattern expression with <code>*</code> can be used.",
+		"channel_desc": "Slack channel name. Without <code>#</code>.",
+		"valid_page": "启用/禁用通知",
+		"link_notification_help": "<strong>只有那些知道“链接的任何人”链接的人才能查看的页面并不总是得到通知。</strong> ",
+		"just_me_notification_help": "<strong>被“仅限我”限制的页在编辑时被通知。</strong>",
+		"group_notification_help": "<strong>被“用户组”限制的页面在编辑时被通知。</strong>",
+		"notification_list": "List of notification settings",
+		"add_notification": "Add new",
+		"trigger_path": "Trigger path",
+		"trigger_path_help": "(expression with <code>*</code> is supported)",
+		"trigger_events": "Trigger events",
+		"notify_to": "Notify to",
+		"back_to_list": "Go back to list",
+		"notification_detail": "Notification Setting Details",
+		"event_pageCreate": "When new page is \"CREATED\"",
+		"event_pageEdit": "When page is \"EDITED\"",
+		"event_pageDelete": "When page is \"DELETED\"",
+		"event_pageMove": "When page is \"MOVED\" (renamed)",
+		"event_pageLike": "When someone \"LIKES\" page",
+		"event_comment": "When someone \"COMMENTS\" on page",
+		"email": {
+			"ifttt_link": "Create a new IFTTT applet with Email trigger"
+		},
+		"updated_slackApp": "Succeeded to update Slack App Configuration setting",
+		"add_notification_pattern": "Add user trigger notification patterns",
+		"delete_notification_pattern": "Delete notification pattern",
+		"delete_notification_pattern_desc1": "Delete Path: {{path}}",
+		"delete_notification_pattern_desc2": "Once deleted, it cannot be recovered",
+		"toggle_notification": "Updated setting of {{path}}"
+	},
+	"full_text_search_management": {
+		"elasticsearch_management": "Elasticsearch管理",
+		"connection_status": "连接状态",
+		"connection_status_label_unconfigured": "未配置",
+		"connection_status_label_connected": "已连接",
+		"connection_status_label_disconnected": "断开的",
+		"connection_status_label_erroroccured": "搜索服务出错",
+		"indices_status": "索引状态",
+		"indices_status_label_normalized": "标准化",
+		"indices_status_label_unnormalized": "重建或损坏",
+		"indices_summary": "索引摘要",
+		"reconnect": "重新连接",
+		"reconnect_button": "尝试重新连接",
+		"reconnect_description": "单击按钮尝试重新连接到Elasticsearch。",
+		"normalize": "规范化",
+		"normalize_button": "规范化索引",
+		"normalize_description": "单击按钮修复损坏的索引。",
+		"rebuild": "重建",
+		"rebuild_button": "重建索引",
+		"rebuild_description_1": "单击按钮以重新生成索引并添加所有页面数据。",
+		"rebuild_description_2": "这可能需要一段时间。"
+	},
+	"export_management": {
+		"exporting_collection_list": "正在导出集合列表",
+		"exported_data_list": "导出的存档数据列表",
+		"export_collections": "导出集合",
+		"check_all": "全部选中",
+		"uncheck_all": "全部取消选中",
+		"desc_password_seed": "还原用户数据时,不要忘记将当前的<code>密码种子设置到新的GROWI系统,否则用户将无法使用其密码登录。<br><br><strong>提示:</strong><br>当前的<code>密码种子将存储在<code>meta.json格式</code>在导出的zip压缩包中。",
+		"create_new_archive_data": "创建新的存档数据",
+		"export": "导出",
+		"cancel": "取消",
+		"file": "文件",
+		"growi_version": "Growi 版本号",
+		"collections": "收藏",
+		"exported_at": "导出在",
+		"export_menu": "导出菜单",
+		"download": "下载",
+		"delete": "删除"
+	},
+	"personal_dropdown": {
+		"home": "家",
+		"settings": "设置",
+		"color_mode": "颜色模式",
+		"sidebar_mode": "边栏模式",
+		"sidebar_mode_editor": "编辑器上的边栏模式",
+		"use_os_settings": "使用操作系统设置"
+	},
+	"search_result": {
+		"result_meta": "在{{total}中找到了{{keyword}。",
+		"deletion_mode_btn_lavel": "选择并删除页面",
+		"cancel": "取消",
+		"delete": "删除",
+		"check_all": "全部检查",
+		"deletion_modal_header": "删除页",
+		"delete_completely": "完全删除"
+	},
+	"login": {
+		"Sign in error": "登录错误",
+		"Registration successful": "注册成功",
+		"Setup": "安装程序"
+	}
+}

+ 27 - 0
resource/locales/zh-CN/welcome.md

@@ -0,0 +1,27 @@
+# 欢迎来到GROWI :anchor:
+
+[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
+[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
+
+<div class="card border-primary">
+  <div class="card-header bg-primary text-light">提示</div>
+  <div class="card-body"><ul>
+    <li>(按Ctrl>)+“/”to show quick help</li>
+    <li>>你可以写HTML与</a href=”https://getbootstrap.com docs/4.5 components/“Bootstrap 4</a></li>
+  </ul></div>
+</div>
+
+Contents
+=========
+
+|All Pages|[/Sandbox]|
+| --- | --- |
+| $lsx(/) | <div class="alert alert-success"><span style="font-size: x-large;"><i class="icon-check"></i> [Go to Sandbox](/Sandbox)</span></div> $lsx(/Sandbox)|
+
+Slack 
+=====
+
+<a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
+
+让我们加入我们所有人的休闲渠道,帮助成长。
+除了讨论发展,我们在介绍时也接受提问。

+ 9 - 0
rs-i18n.env

@@ -0,0 +1,9 @@
+RS_I18N_BAIDU_APP_ID=
+RS_I18N_BAIDU_APP_SECRET=
+RS_I18N_LOCALES_DIR=./resource/locales
+RS_I18N_BASE_LANGUAGE=en-US
+RS_I18N_TARGET_FILE_SUFFIX= '.json'
+RS_I18N_DEBUG_FORMAT=__{%s}__
+
+RS_I18N_LAN_zh-CN=zh
+RS_I18N_LAN_ja=jp

+ 7 - 1
src/client/js/admin.jsx

@@ -23,6 +23,8 @@ import ExportArchiveDataPage from './components/Admin/ExportArchiveDataPage';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement';
 import FullTextSearchManagement from './components/Admin/FullTextSearchManagement';
 import AdminNavigation from './components/Admin/Common/AdminNavigation';
 import AdminNavigation from './components/Admin/Common/AdminNavigation';
 
 
+import NavigationContainer from './services/NavigationContainer';
+
 import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminHomeContainer from './services/AdminHomeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminCustomizeContainer from './services/AdminCustomizeContainer';
 import AdminUserGroupDetailContainer from './services/AdminUserGroupDetailContainer';
 import AdminUserGroupDetailContainer from './services/AdminUserGroupDetailContainer';
@@ -41,14 +43,17 @@ import AdminGitHubSecurityContainer from './services/AdminGitHubSecurityContaine
 import AdminTwitterSecurityContainer from './services/AdminTwitterSecurityContainer';
 import AdminTwitterSecurityContainer from './services/AdminTwitterSecurityContainer';
 import AdminNotificationContainer from './services/AdminNotificationContainer';
 import AdminNotificationContainer from './services/AdminNotificationContainer';
 
 
-import { appContainer, componentMappings } from './bootstrap';
+import { appContainer, componentMappings } from './base';
 
 
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
 
 
+appContainer.initContents();
+
 const { i18n } = appContainer;
 const { i18n } = appContainer;
 const websocketContainer = appContainer.getContainer('WebsocketContainer');
 const websocketContainer = appContainer.getContainer('WebsocketContainer');
 
 
 // create unstated container instance
 // create unstated container instance
+const navigationContainer = new NavigationContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminAppContainer = new AdminAppContainer(appContainer);
 const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminHomeContainer = new AdminHomeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
 const adminCustomizeContainer = new AdminCustomizeContainer(appContainer);
@@ -60,6 +65,7 @@ const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appConta
 const injectableContainers = [
 const injectableContainers = [
   appContainer,
   appContainer,
   websocketContainer,
   websocketContainer,
+  navigationContainer,
   adminAppContainer,
   adminAppContainer,
   adminHomeContainer,
   adminHomeContainer,
   adminCustomizeContainer,
   adminCustomizeContainer,

+ 22 - 9
src/client/js/app.jsx

@@ -32,6 +32,7 @@ import LikerList from './components/User/LikerList';
 import TableOfContents from './components/TableOfContents';
 import TableOfContents from './components/TableOfContents';
 
 
 import PersonalSettings from './components/Me/PersonalSettings';
 import PersonalSettings from './components/Me/PersonalSettings';
+import NavigationContainer from './services/NavigationContainer';
 import PageContainer from './services/PageContainer';
 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';
@@ -40,21 +41,24 @@ import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationForUserPage from './components/Navbar/GrowiSubNavigationForUserPage';
 import GrowiSubNavigationForUserPage from './components/Navbar/GrowiSubNavigationForUserPage';
 import PersonalContainer from './services/PersonalContainer';
 import PersonalContainer from './services/PersonalContainer';
 
 
-import { appContainer, componentMappings } from './bootstrap';
+import { appContainer, componentMappings } from './base';
 
 
-const logger = loggerFactory('growi:app');
+const logger = loggerFactory('growi:cli:app');
+
+appContainer.initContents();
 
 
 const { i18n } = appContainer;
 const { i18n } = appContainer;
 const websocketContainer = appContainer.getContainer('WebsocketContainer');
 const websocketContainer = appContainer.getContainer('WebsocketContainer');
 
 
 // create unstated container instance
 // create unstated container instance
+const navigationContainer = new NavigationContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const editorContainer = new EditorContainer(appContainer, defaultEditorOptions, defaultPreviewOptions);
 const tagContainer = new TagContainer(appContainer);
 const tagContainer = new TagContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, websocketContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
+  appContainer, websocketContainer, navigationContainer, pageContainer, commentContainer, editorContainer, tagContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');
@@ -70,11 +74,7 @@ Object.assign(componentMappings, {
   // 'revision-history': <PageHistory pageId={pageId} />,
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
   'tags-page': <TagsList crowi={appContainer} />,
 
 
-  'page-editor': <PageEditor />,
-  'page-editor-path-nav': <PagePathNavForEditor />,
-  'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
   'page-status-alert': <PageStatusAlert />,
   'page-status-alert': <PageStatusAlert />,
-  'save-page-controls': <SavePageControls />,
 
 
   'trash-page-alert': <TrashPageAlert />,
   'trash-page-alert': <TrashPageAlert />,
 
 
@@ -86,10 +86,9 @@ Object.assign(componentMappings, {
 // additional definitions if data exists
 // additional definitions if data exists
 if (pageContainer.state.pageId != null) {
 if (pageContainer.state.pageId != null) {
   Object.assign(componentMappings, {
   Object.assign(componentMappings, {
-    'page-editor-with-hackmd': <PageEditorByHackmd />,
     'page-comments-list': <PageComments />,
     'page-comments-list': <PageComments />,
-    'page-attachment': <PageAttachment />,
     'page-comment-write': <CommentEditorLazyRenderer />,
     'page-comment-write': <CommentEditorLazyRenderer />,
+    'page-attachment': <PageAttachment />,
     'page-management': <PageManagement />,
     'page-management': <PageManagement />,
 
 
     'revision-toc': <TableOfContents />,
     'revision-toc': <TableOfContents />,
@@ -108,6 +107,20 @@ if (pageContainer.state.path != null) {
     'grw-subnav-for-user-page': <GrowiSubNavigationForUserPage />,
     'grw-subnav-for-user-page': <GrowiSubNavigationForUserPage />,
   });
   });
 }
 }
+// additional definitions if user is logged in
+if (appContainer.currentUser != null) {
+  Object.assign(componentMappings, {
+    'page-editor': <PageEditor />,
+    'page-editor-path-nav': <PagePathNavForEditor />,
+    'page-editor-options-selector': <OptionsSelector crowi={appContainer} />,
+    'save-page-controls': <SavePageControls />,
+  });
+  if (pageContainer.state.pageId != null) {
+    Object.assign(componentMappings, {
+      'page-editor-with-hackmd': <PageEditorByHackmd />,
+    });
+  }
+}
 
 
 Object.keys(componentMappings).forEach((key) => {
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   const elem = document.getElementById(key);

+ 3 - 4
src/client/js/bootstrap.jsx → src/client/js/base.jsx

@@ -14,7 +14,7 @@ import WebsocketContainer from './services/WebsocketContainer';
 import PageCreateButton from './components/Navbar/PageCreateButton';
 import PageCreateButton from './components/Navbar/PageCreateButton';
 import PageCreateModal from './components/PageCreateModal';
 import PageCreateModal from './components/PageCreateModal';
 
 
-const logger = loggerFactory('growi:app');
+const logger = loggerFactory('growi:cli:app');
 
 
 if (!window) {
 if (!window) {
   window = {};
   window = {};
@@ -29,10 +29,9 @@ const appContainer = new AppContainer();
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const websocketContainer = new WebsocketContainer(appContainer);
 const websocketContainer = new WebsocketContainer(appContainer);
 
 
-logger.info('unstated containers have been initialized');
+appContainer.initApp();
 
 
-appContainer.init();
-appContainer.injectToWindow();
+logger.info('AppContainer has been initialized');
 
 
 /**
 /**
  * define components
  * define components

+ 5 - 0
src/client/js/boot.js

@@ -0,0 +1,5 @@
+import {
+  applyColorScheme,
+} from './util/color-scheme';
+
+applyColorScheme();

+ 51 - 33
src/client/js/components/Admin/App/AppSetting.jsx

@@ -34,8 +34,9 @@ class AppSetting extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, adminAppContainer } = this.props;
-
+    const { t, adminAppContainer, appContainer } = this.props;
+    const locales = appContainer.locales;
+    const languages = Object.keys(locales);
     return (
     return (
       <React.Fragment>
       <React.Fragment>
         <div className="form-group row">
         <div className="form-group row">
@@ -45,7 +46,9 @@ class AppSetting extends React.Component {
               className="form-control"
               className="form-control"
               type="text"
               type="text"
               defaultValue={adminAppContainer.state.title || ''}
               defaultValue={adminAppContainer.state.title || ''}
-              onChange={(e) => { adminAppContainer.changeTitle(e.target.value) }}
+              onChange={(e) => {
+                adminAppContainer.changeTitle(e.target.value);
+              }}
               placeholder="GROWI"
               placeholder="GROWI"
             />
             />
             <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
             <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
@@ -53,13 +56,19 @@ class AppSetting extends React.Component {
         </div>
         </div>
 
 
         <div className="row form-group mb-5">
         <div className="row form-group mb-5">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('admin:app_setting.confidential_name')}</label>
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('admin:app_setting.confidential_name')}
+          </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <input
             <input
               className="form-control"
               className="form-control"
               type="text"
               type="text"
               defaultValue={adminAppContainer.state.confidential || ''}
               defaultValue={adminAppContainer.state.confidential || ''}
-              onChange={(e) => { adminAppContainer.changeConfidential(e.target.value) }}
+              onChange={(e) => {
+                adminAppContainer.changeConfidential(e.target.value);
+              }}
               placeholder={t('admin:app_setting.confidential_example')}
               placeholder={t('admin:app_setting.confidential_example')}
             />
             />
             <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
             <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
@@ -67,37 +76,39 @@ class AppSetting extends React.Component {
         </div>
         </div>
 
 
         <div className="row form-group mb-5">
         <div className="row form-group mb-5">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('admin:app_setting.default_language')}</label>
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('admin:app_setting.default_language')}
+          </label>
           <div className="col-md-6">
           <div className="col-md-6">
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioLangEn"
-                className="custom-control-input"
-                name="globalLang"
-                value="en-US"
-                checked={adminAppContainer.state.globalLang === 'en-US'}
-                onChange={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
-              />
-              <label className="custom-control-label" htmlFor="radioLangEn">{t('English')}</label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioLangJa"
-                className="custom-control-input"
-                name="globalLang"
-                value="ja"
-                checked={adminAppContainer.state.globalLang === 'ja'}
-                onChange={(e) => { adminAppContainer.changeGlobalLang(e.target.value) }}
-              />
-              <label className="custom-control-label" htmlFor="radioLangJa">{t('Japanese')}</label>
-            </div>
+            {
+              languages.map(lan => (
+                <div key={lan} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    id={`radioLang${lan}`}
+                    className="custom-control-input"
+                    name="globalLang"
+                    value={lan}
+                    checked={adminAppContainer.state.globalLang === lan}
+                    onChange={(e) => {
+                      adminAppContainer.changeGlobalLang(e.target.value);
+                    }}
+                  />
+                  <label className="custom-control-label" htmlFor={`radioLang${lan}`}>{locales[lan]._conf.name}</label>
+                </div>
+              ))
+            }
           </div>
           </div>
         </div>
         </div>
 
 
         <div className="row form-group mb-5">
         <div className="row form-group mb-5">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('admin:app_setting.file_uploading')}</label>
+          <label
+            className="text-left text-md-right col-md-3 col-form-label"
+          >
+            {t('admin:app_setting.file_uploading')}
+          </label>
           <div className="col-md-6">
           <div className="col-md-6">
             <div className="custom-control custom-checkbox custom-checkbox-info">
             <div className="custom-control custom-checkbox custom-checkbox-info">
               <input
               <input
@@ -106,9 +117,16 @@ class AppSetting extends React.Component {
                 className="custom-control-input"
                 className="custom-control-input"
                 name="fileUpload"
                 name="fileUpload"
                 checked={adminAppContainer.state.fileUpload}
                 checked={adminAppContainer.state.fileUpload}
-                onChange={(e) => { adminAppContainer.changeFileUpload(e.target.checked) }}
+                onChange={(e) => {
+                  adminAppContainer.changeFileUpload(e.target.checked);
+                }}
               />
               />
-              <label className="custom-control-label" htmlFor="cbFileUpload">{t('admin:app_setting.enable_files_except_image')}</label>
+              <label
+                className="custom-control-label"
+                htmlFor="cbFileUpload"
+              >
+                {t('admin:app_setting.enable_files_except_image')}
+              </label>
             </div>
             </div>
 
 
             <p className="form-text text-muted">
             <p className="form-text text-muted">

+ 18 - 13
src/client/js/components/Drawio.jsx

@@ -3,6 +3,12 @@ import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import AppContainer from '../services/AppContainer';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import NotAvailableForGuest from './NotAvailableForGuest';
+
 class Drawio extends React.Component {
 class Drawio extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
@@ -23,11 +29,9 @@ class Drawio extends React.Component {
   }
   }
 
 
   onEdit() {
   onEdit() {
-    if (window.crowi != null) {
-      window.crowi.launchDrawioModal('page',
-        this.props.rangeLineNumberOfMarkdown.beginLineNumber,
-        this.props.rangeLineNumberOfMarkdown.endLineNumber);
-    }
+    const { appContainer, rangeLineNumberOfMarkdown } = this.props;
+    const { beginLineNumber, endLineNumber } = rangeLineNumberOfMarkdown;
+    appContainer.launchDrawioModal('page', beginLineNumber, endLineNumber);
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
@@ -53,13 +57,13 @@ class Drawio extends React.Component {
   render() {
   render() {
     return (
     return (
       <div className="editable-with-drawio position-relative">
       <div className="editable-with-drawio position-relative">
-        { !this.isPreview
-          && (
-          <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={this.onEdit}>
-            <i className="icon-note mr-1"></i>{this.props.t('Edit')}
-          </button>
-          )
-        }
+        { !this.isPreview && (
+          <NotAvailableForGuest>
+            <button type="button" className="drawio-iframe-trigger position-absolute btn btn-outline-secondary" onClick={this.onEdit}>
+              <i className="icon-note mr-1"></i>{this.props.t('Edit')}
+            </button>
+          </NotAvailableForGuest>
+        ) }
         <div
         <div
           className="drawio"
           className="drawio"
           style={this.style}
           style={this.style}
@@ -77,9 +81,10 @@ class Drawio extends React.Component {
 Drawio.propTypes = {
 Drawio.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.object.isRequired,
   appContainer: PropTypes.object.isRequired,
+
   drawioContent: PropTypes.any.isRequired,
   drawioContent: PropTypes.any.isRequired,
   isPreview: PropTypes.bool,
   isPreview: PropTypes.bool,
   rangeLineNumberOfMarkdown: PropTypes.object.isRequired,
   rangeLineNumberOfMarkdown: PropTypes.object.isRequired,
 };
 };
 
 
-export default withTranslation()(Drawio);
+export default withTranslation()(withUnstatedContainers(Drawio, [AppContainer]));

+ 21 - 30
src/client/js/components/InstallerForm.jsx

@@ -44,6 +44,9 @@ class InstallerForm extends React.Component {
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
 
 
     const checkedBtn = this.state.checkedBtn;
     const checkedBtn = this.state.checkedBtn;
+    const { i18n } = this.props;
+    const locales = i18n.options.resources;
+    const languages = Object.keys(locales);
 
 
     return (
     return (
       <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
       <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
@@ -58,36 +61,24 @@ class InstallerForm extends React.Component {
         <div className="row">
         <div className="row">
           <form role="form" action="/installer" method="post" id="register-form" className="col-md-12">
           <form role="form" action="/installer" method="post" id="register-form" className="col-md-12">
             <div className="form-group text-center">
             <div className="form-group text-center">
-              <div className="custom-control custom-radio custom-control-inline">
-                <input
-                  type="radio"
-                  className="custom-control-input"
-                  id="register-form-check-en"
-                  name="registerForm[app:globalLang]"
-                  value="en-US"
-                  checked={checkedBtn === 'en-US'}
-                  inline
-                  onChange={(e) => { if (e.target.checked) { this.changeLanguage('en-US') } }}
-                />
-                <label className="custom-control-label" htmlFor="register-form-check-en">
-                  English
-                </label>
-              </div>
-              <div className="custom-control custom-radio custom-control-inline">
-                <input
-                  type="radio"
-                  className="custom-control-input"
-                  id="register-form-check-jp"
-                  name="registerForm[app:globalLang]"
-                  value="ja"
-                  checked={checkedBtn === 'ja'}
-                  inline
-                  onChange={(e) => { if (e.target.checked) { this.changeLanguage('ja') } }}
-                />
-                <label className="custom-control-label" htmlFor="register-form-check-jp">
-                  日本語
-                </label>
-              </div>
+              {
+                languages.map(lan => (
+                  <div key={lan} className="custom-control custom-radio custom-control-inline">
+                    <input
+                      type="radio"
+                      className="custom-control-input"
+                      id={`register-form-check-${lan}`}
+                      name="registerForm[app:globalLang]"
+                      value={lan}
+                      checked={checkedBtn === lan}
+                      onChange={(e) => { if (e.target.checked) { this.changeLanguage(lan) } }}
+                    />
+                    <label className="custom-control-label" htmlFor={`register-form-check-${lan}`}>
+                      {locales[lan]._conf.name}
+                    </label>
+                  </div>
+                ))
+              }
             </div>
             </div>
 
 
             <div className={`input-group mb-3${hasErrorClass}`}>
             <div className={`input-group mb-3${hasErrorClass}`}>

+ 9 - 8
src/client/js/components/LoginForm.jsx

@@ -4,8 +4,8 @@ import ReactCardFlip from 'react-card-flip';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
+import AppContainer from '../services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
-import NoLoginContainer from '../services/NoLoginContainer';
 
 
 class LoginForm extends React.Component {
 class LoginForm extends React.Component {
 
 
@@ -35,12 +35,12 @@ class LoginForm extends React.Component {
 
 
   handleLoginWithExternalAuth(e) {
   handleLoginWithExternalAuth(e) {
     const auth = e.currentTarget.id;
     const auth = e.currentTarget.id;
-    const csrf = this.props.noLoginContainer.csrfToken;
+    const { csrf } = this.props.appContainer;
     window.location.href = `/passport/${auth}?_csrf=${csrf}`;
     window.location.href = `/passport/${auth}?_csrf=${csrf}`;
   }
   }
 
 
   renderLocalOrLdapLoginForm() {
   renderLocalOrLdapLoginForm() {
-    const { t, noLoginContainer, isLdapStrategySetup } = this.props;
+    const { t, appContainer, isLdapStrategySetup } = this.props;
 
 
     return (
     return (
       <form role="form" action="/login" method="post">
       <form role="form" action="/login" method="post">
@@ -70,7 +70,7 @@ class LoginForm extends React.Component {
         </div>
         </div>
 
 
         <div className="input-group my-4">
         <div className="input-group my-4">
-          <input type="hidden" name="_csrf" value={noLoginContainer.csrfToken} />
+          <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto">
           <button type="submit" id="login" className="btn btn-fill rounded-0 login mx-auto">
             <div className="eff"></div>
             <div className="eff"></div>
             <span className="btn-label">
             <span className="btn-label">
@@ -147,10 +147,10 @@ class LoginForm extends React.Component {
   renderRegisterForm() {
   renderRegisterForm() {
     const {
     const {
       t,
       t,
+      appContainer,
       username,
       username,
       name,
       name,
       email,
       email,
-      noLoginContainer,
       registrationMode,
       registrationMode,
       registrationWhiteList,
       registrationWhiteList,
     } = this.props;
     } = this.props;
@@ -220,7 +220,7 @@ class LoginForm extends React.Component {
           </div>
           </div>
 
 
           <div className="input-group justify-content-center my-4">
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={noLoginContainer.csrfToken} />
+            <input type="hidden" name="_csrf" value={appContainer.csrfToken} />
             <button type="submit" className="btn btn-fill rounded-0" id="register">
             <button type="submit" className="btn btn-fill rounded-0" id="register">
               <div className="eff"></div>
               <div className="eff"></div>
               <span className="btn-label">
               <span className="btn-label">
@@ -293,12 +293,13 @@ class LoginForm extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const LoginFormWrapper = withUnstatedContainers(LoginForm, [NoLoginContainer]);
+const LoginFormWrapper = withUnstatedContainers(LoginForm, [AppContainer]);
 
 
 LoginForm.propTypes = {
 LoginForm.propTypes = {
   // i18next
   // i18next
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,
-  noLoginContainer: PropTypes.instanceOf(NoLoginContainer).isRequired,
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+
   isRegistering: PropTypes.bool,
   isRegistering: PropTypes.bool,
   username: PropTypes.string,
   username: PropTypes.string,
   name: PropTypes.string,
   name: PropTypes.string,

+ 18 - 23
src/client/js/components/Me/BasicInfoSettings.jsx

@@ -39,8 +39,10 @@ class BasicInfoSettings extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, personalContainer } = this.props;
+    const { t, personalContainer, appContainer } = this.props;
     const { registrationWhiteList } = personalContainer.state;
     const { registrationWhiteList } = personalContainer.state;
+    const locales = appContainer.locales;
+    const languages = Object.keys(locales);
 
 
     return (
     return (
       <Fragment>
       <Fragment>
@@ -110,28 +112,21 @@ class BasicInfoSettings extends React.Component {
         <div className="form-group row">
         <div className="form-group row">
           <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
           <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
           <div className="col-md-6">
           <div className="col-md-6">
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioLangEn"
-                className="custom-control-input"
-                name="userForm[lang]"
-                checked={personalContainer.state.lang === 'en-US'}
-                onChange={() => { personalContainer.changeLang('en-US') }}
-              />
-              <label className="custom-control-label" htmlFor="radioLangEn">{t('English')}</label>
-            </div>
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radioLangJa"
-                className="custom-control-input"
-                name="userForm[lang]"
-                checked={personalContainer.state.lang === 'ja'}
-                onChange={() => { personalContainer.changeLang('ja') }}
-              />
-              <label className="custom-control-label" htmlFor="radioLangJa">{t('Japanese')}</label>
-            </div>
+            {
+              languages.map(lan => (
+                <div key={lan} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    id={`radioLang${lan}`}
+                    className="custom-control-input"
+                    name="userForm[lang]"
+                    checked={personalContainer.state.lang === lan}
+                    onChange={() => { personalContainer.changeLang(lan) }}
+                  />
+                  <label className="custom-control-label" htmlFor={`radioLang${lan}`}>{locales[lan]._conf.name}</label>
+                </div>
+              ))
+            }
           </div>
           </div>
         </div>
         </div>
 
 

+ 5 - 5
src/client/js/components/Navbar/NavbarToggler.jsx

@@ -4,14 +4,14 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 
 
 const NavbarToggler = (props) => {
 const NavbarToggler = (props) => {
 
 
-  const { appContainer } = props;
+  const { navigationContainer } = props;
 
 
   const clickHandler = () => {
   const clickHandler = () => {
-    appContainer.toggleDrawer();
+    navigationContainer.toggleDrawer();
   };
   };
 
 
   return (
   return (
@@ -31,12 +31,12 @@ const NavbarToggler = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const NavbarTogglerWrapper = withUnstatedContainers(NavbarToggler, [AppContainer]);
+const NavbarTogglerWrapper = withUnstatedContainers(NavbarToggler, [NavigationContainer]);
 
 
 
 
 NavbarToggler.propTypes = {
 NavbarToggler.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(NavbarTogglerWrapper);
 export default withTranslation()(NavbarTogglerWrapper);

+ 6 - 6
src/client/js/components/Navbar/PageCreateButton.jsx

@@ -4,21 +4,21 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 
 
 const PageCreateButton = (props) => {
 const PageCreateButton = (props) => {
-  const { t, appContainer, isIcon } = props;
+  const { t, navigationContainer, isIcon } = props;
 
 
   if (isIcon) {
   if (isIcon) {
     return (
     return (
-      <button className="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" onClick={appContainer.openPageCreateModal}>
+      <button className="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" onClick={navigationContainer.openPageCreateModal}>
         <i className="icon-pencil"></i>
         <i className="icon-pencil"></i>
       </button>
       </button>
     );
     );
   }
   }
 
 
   return (
   return (
-    <button className="px-md-2 nav-link create-page border-0 bg-transparent" type="button" onClick={appContainer.openPageCreateModal}>
+    <button className="px-md-2 nav-link create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
       <i className="icon-pencil mr-2"></i>
       <i className="icon-pencil mr-2"></i>
       <span className="d-none d-lg-block">{ t('New') }</span>
       <span className="d-none d-lg-block">{ t('New') }</span>
     </button>
     </button>
@@ -28,12 +28,12 @@ const PageCreateButton = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PageCreateButtonWrapper = withUnstatedContainers(PageCreateButton, [AppContainer]);
+const PageCreateButtonWrapper = withUnstatedContainers(PageCreateButton, [NavigationContainer]);
 
 
 
 
 PageCreateButton.propTypes = {
 PageCreateButton.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 
 
   isIcon: PropTypes.bool,
   isIcon: PropTypes.bool,
 };
 };

+ 38 - 24
src/client/js/components/Navbar/PersonalDropdown.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState } from 'react';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
@@ -7,14 +7,27 @@ import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
+
+import {
+  isUserPreferenceExists,
+  isDarkMode as isDarkModeByUtil,
+  applyColorScheme,
+  removeUserPreference,
+  updateUserPreference,
+  updateUserPreferenceWithOsSettings,
+} from '../../util/color-scheme';
 
 
 import UserPicture from '../User/UserPicture';
 import UserPicture from '../User/UserPicture';
 
 
 const PersonalDropdown = (props) => {
 const PersonalDropdown = (props) => {
 
 
-  const { t, appContainer } = props;
+  const { t, appContainer, navigationContainer } = props;
   const user = appContainer.currentUser || {};
   const user = appContainer.currentUser || {};
 
 
+  const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
+  const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
+
   const logoutHandler = () => {
   const logoutHandler = () => {
     const { interceptorManager } = appContainer;
     const { interceptorManager } = appContainer;
 
 
@@ -28,26 +41,33 @@ const PersonalDropdown = (props) => {
   };
   };
 
 
   const preferDrawerModeSwitchModifiedHandler = (bool) => {
   const preferDrawerModeSwitchModifiedHandler = (bool) => {
-    appContainer.setDrawerModePreference(bool);
+    navigationContainer.setDrawerModePreference(bool);
   };
   };
 
 
   const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
   const preferDrawerModeOnEditSwitchModifiedHandler = (bool) => {
-    appContainer.setDrawerModePreferenceOnEdit(bool);
+    navigationContainer.setDrawerModePreferenceOnEdit(bool);
   };
   };
 
 
   const followOsCheckboxModifiedHandler = (bool) => {
   const followOsCheckboxModifiedHandler = (bool) => {
-    // reset user preference
     if (bool) {
     if (bool) {
-      appContainer.setColorSchemePreference(null);
+      removeUserPreference();
     }
     }
-    // set preferDarkModeByMediaQuery as users preference
     else {
     else {
-      appContainer.setColorSchemePreference(appContainer.state.preferDarkModeByMediaQuery);
+      updateUserPreferenceWithOsSettings();
     }
     }
+    applyColorScheme();
+
+    // update states
+    setOsSettings(bool);
+    setIsDarkMode(isDarkModeByUtil());
   };
   };
 
 
   const userPreferenceSwitchModifiedHandler = (bool) => {
   const userPreferenceSwitchModifiedHandler = (bool) => {
-    appContainer.setColorSchemePreference(bool);
+    updateUserPreference(bool);
+    applyColorScheme();
+
+    // update state
+    setIsDarkMode(isDarkModeByUtil());
   };
   };
 
 
 
 
@@ -55,15 +75,8 @@ const PersonalDropdown = (props) => {
    * render
    * render
    */
    */
   const {
   const {
-    preferDarkModeByMediaQuery, preferDarkModeByUser, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-  } = appContainer.state;
-  const isUserPreferenceExists = preferDarkModeByUser != null;
-  const isDarkMode = () => {
-    if (isUserPreferenceExists) {
-      return preferDarkModeByUser;
-    }
-    return preferDarkModeByMediaQuery;
-  };
+    preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+  } = navigationContainer.state;
 
 
   /* eslint-disable react/prop-types */
   /* eslint-disable react/prop-types */
   const DrawerIcon = props => (
   const DrawerIcon = props => (
@@ -166,7 +179,7 @@ const PersonalDropdown = (props) => {
                   id="cbFollowOs"
                   id="cbFollowOs"
                   className="custom-control-input"
                   className="custom-control-input"
                   type="checkbox"
                   type="checkbox"
-                  checked={!isUserPreferenceExists}
+                  checked={useOsSettings}
                   onChange={e => followOsCheckboxModifiedHandler(e.target.checked)}
                   onChange={e => followOsCheckboxModifiedHandler(e.target.checked)}
                 />
                 />
                 <label className="custom-control-label text-nowrap" htmlFor="cbFollowOs">{t('personal_dropdown.use_os_settings')}</label>
                 <label className="custom-control-label text-nowrap" htmlFor="cbFollowOs">{t('personal_dropdown.use_os_settings')}</label>
@@ -175,19 +188,19 @@ const PersonalDropdown = (props) => {
           </div>
           </div>
           <div className="form-row justify-content-center">
           <div className="form-row justify-content-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
             <div className="form-group col-auto mb-0 d-flex align-items-center">
-              <span className={isUserPreferenceExists ? '' : 'text-muted'}>Light</span>
+              <span className={useOsSettings ? '' : 'text-muted'}>Light</span>
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
               <div className="custom-control custom-switch custom-checkbox-secondary ml-2">
                 <input
                 <input
                   id="swUserPreference"
                   id="swUserPreference"
                   className="custom-control-input"
                   className="custom-control-input"
                   type="checkbox"
                   type="checkbox"
-                  checked={isDarkMode()}
-                  disabled={!isUserPreferenceExists}
+                  checked={isDarkMode}
+                  disabled={useOsSettings}
                   onChange={e => userPreferenceSwitchModifiedHandler(e.target.checked)}
                   onChange={e => userPreferenceSwitchModifiedHandler(e.target.checked)}
                 />
                 />
                 <label className="custom-control-label" htmlFor="swUserPreference"></label>
                 <label className="custom-control-label" htmlFor="swUserPreference"></label>
               </div>
               </div>
-              <span className={isUserPreferenceExists ? '' : 'text-muted'}>Dark</span>
+              <span className={useOsSettings ? '' : 'text-muted'}>Dark</span>
             </div>
             </div>
           </div>
           </div>
         </form>
         </form>
@@ -205,12 +218,13 @@ const PersonalDropdown = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
+const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer, NavigationContainer]);
 
 
 
 
 PersonalDropdown.propTypes = {
 PersonalDropdown.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(PersonalDropdownWrapper);
 export default withTranslation()(PersonalDropdownWrapper);

+ 4 - 2
src/client/js/components/Navbar/SearchTop.jsx

@@ -4,6 +4,7 @@ import { withTranslation } from 'react-i18next';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 
 
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
 
 
@@ -51,7 +52,7 @@ class SearchTop extends React.Component {
   }
   }
 
 
   Root = ({ children }) => {
   Root = ({ children }) => {
-    const { isDeviceSmallerThanMd: isCollapsed } = this.props.appContainer.state;
+    const { isDeviceSmallerThanMd: isCollapsed } = this.props.navigationContainer.state;
 
 
     return isCollapsed
     return isCollapsed
       ? (
       ? (
@@ -116,11 +117,12 @@ class SearchTop extends React.Component {
 SearchTop.propTypes = {
 SearchTop.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SearchTopWrapper = withUnstatedContainers(SearchTop, [AppContainer]);
+const SearchTopWrapper = withUnstatedContainers(SearchTop, [AppContainer, NavigationContainer]);
 
 
 export default withTranslation()(SearchTopWrapper);
 export default withTranslation()(SearchTopWrapper);

+ 41 - 0
src/client/js/components/NotAvailableForGuest.jsx

@@ -0,0 +1,41 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { UncontrolledTooltip } from 'reactstrap';
+
+import AppContainer from '../services/AppContainer';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const NotAvailableForGuest = (props) => {
+  const { appContainer, children } = props;
+  const isLoggedin = appContainer.currentUser != null;
+
+  if (isLoggedin) {
+    return props.children;
+  }
+
+  const id = children.props.id || `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
+
+  // clone and add className
+  const clonedChild = React.cloneElement(children, {
+    id,
+    className: `${children.props.className} grw-not-available-for-guest`,
+    onClick: () => { /* do nothing */ },
+  });
+
+  return (
+    <>
+      { clonedChild }
+      <UncontrolledTooltip placement="top" target={id}>Not available for guest</UncontrolledTooltip>
+    </>
+  );
+
+};
+
+NotAvailableForGuest.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  children: PropTypes.node.isRequired,
+};
+
+export default withUnstatedContainers(NotAvailableForGuest, [AppContainer]);

+ 11 - 4
src/client/js/components/Page.jsx

@@ -126,14 +126,21 @@ class Page extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const isMobile = this.props.appContainer.isMobile;
-    const { markdown } = this.props.pageContainer.state;
+    const { appContainer, pageContainer } = this.props;
+    const isMobile = appContainer.isMobile;
+    const isLoggedIn = appContainer.currentUser != null;
+    const { markdown } = pageContainer.state;
 
 
     return (
     return (
       <div className={isMobile ? 'page-mobile' : ''}>
       <div className={isMobile ? 'page-mobile' : ''}>
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
         <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} />
-        <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
-        <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
+
+        { isLoggedIn && (
+          <>
+            <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
+            <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />
+          </>
+        )}
       </div>
       </div>
     );
     );
   }
   }

+ 6 - 4
src/client/js/components/Page/TagLabels.jsx

@@ -6,6 +6,7 @@ import * as toastr from 'toastr';
 
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
 import PageContainer from '../../services/PageContainer';
 import PageContainer from '../../services/PageContainer';
 import EditorContainer from '../../services/EditorContainer';
 import EditorContainer from '../../services/EditorContainer';
 
 
@@ -30,7 +31,7 @@ class TagLabels extends React.Component {
    *   2. editorContainer.state.tags if editorMode is not null
    *   2. editorContainer.state.tags if editorMode is not null
    */
    */
   getEditTargetData() {
   getEditTargetData() {
-    const { editorMode } = this.props.appContainer.state;
+    const { editorMode } = this.props.navigationContainer.state;
     return (editorMode == null)
     return (editorMode == null)
       ? this.props.pageContainer.state.tags
       ? this.props.pageContainer.state.tags
       : this.props.editorContainer.state.tags;
       : this.props.editorContainer.state.tags;
@@ -41,8 +42,8 @@ class TagLabels extends React.Component {
   }
   }
 
 
   async tagsUpdatedHandler(tags) {
   async tagsUpdatedHandler(tags) {
-    const { appContainer, editorContainer } = this.props;
-    const { editorMode } = appContainer.state;
+    const { appContainer, navigationContainer, editorContainer } = this.props;
+    const { editorMode } = navigationContainer.state;
 
 
     // post api request and update tags
     // post api request and update tags
     if (editorMode == null) {
     if (editorMode == null) {
@@ -137,12 +138,13 @@ class TagLabels extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
+const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, NavigationContainer, PageContainer, EditorContainer]);
 
 
 
 
 TagLabels.propTypes = {
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };

+ 10 - 7
src/client/js/components/PageComment/CommentEditor.jsx

@@ -20,6 +20,7 @@ import Editor from '../PageEditor/Editor';
 import SlackNotification from '../SlackNotification';
 import SlackNotification from '../SlackNotification';
 
 
 import CommentPreview from './CommentPreview';
 import CommentPreview from './CommentPreview';
+import NotAvailableForGuest from '../NotAvailableForGuest';
 
 
 /**
 /**
  *
  *
@@ -242,13 +243,15 @@ class CommentEditor extends React.Component {
   renderBeforeReady() {
   renderBeforeReady() {
     return (
     return (
       <div className="text-center">
       <div className="text-center">
-        <button
-          type="button"
-          className="btn btn-lg btn-link"
-          onClick={() => this.setState({ isReadyToUse: true })}
-        >
-          <i className="icon-bubble"></i> Add Comment
-        </button>
+        <NotAvailableForGuest>
+          <button
+            type="button"
+            className="btn btn-lg btn-link"
+            onClick={() => this.setState({ isReadyToUse: true })}
+          >
+            <i className="icon-bubble"></i> Add Comment
+          </button>
+        </NotAvailableForGuest>
       </div>
       </div>
     );
     );
   }
   }

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

@@ -174,7 +174,7 @@ class PageComments extends React.Component {
             </Button>
             </Button>
           </div>
           </div>
         )}
         )}
-        { showEditor && isLoggedIn && (
+        { showEditor && (
           <div className="page-comment-reply-form ml-4 ml-sm-5 mr-3">
           <div className="page-comment-reply-form ml-4 ml-sm-5 mr-3">
             <CommentEditor
             <CommentEditor
               growiRenderer={this.growiRenderer}
               growiRenderer={this.growiRenderer}

+ 9 - 5
src/client/js/components/PageCreateModal.jsx

@@ -10,13 +10,15 @@ import urljoin from 'url-join';
 
 
 import { userPageRoot } from '@commons/util/path-utils';
 import { userPageRoot } from '@commons/util/path-utils';
 import { pathUtils } from 'growi-commons';
 import { pathUtils } from 'growi-commons';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
+import NavigationContainer from '../services/NavigationContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
 const PageCreateModal = (props) => {
 const PageCreateModal = (props) => {
-  const { t, appContainer } = props;
+  const { t, appContainer, navigationContainer } = props;
 
 
   const config = appContainer.getConfig();
   const config = appContainer.getConfig();
   const isReachable = config.isSearchServiceReachable;
   const isReachable = config.isSearchServiceReachable;
@@ -240,9 +242,10 @@ const PageCreateModal = (props) => {
       </div>
       </div>
     );
     );
   }
   }
+
   return (
   return (
-    <Modal size="lg" isOpen={appContainer.state.isPageCreateModalShown} toggle={appContainer.closePageCreateModal} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={appContainer.closePageCreateModal} className="bg-primary text-light">
+    <Modal size="lg" isOpen={navigationContainer.state.isPageCreateModalShown} toggle={navigationContainer.closePageCreateModal} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
         { t('New Page') }
         { t('New Page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
@@ -259,12 +262,13 @@ const PageCreateModal = (props) => {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer]);
+const ModalControlWrapper = withUnstatedContainers(PageCreateModal, [AppContainer, NavigationContainer]);
 
 
 
 
 PageCreateModal.propTypes = {
 PageCreateModal.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
 export default withTranslation()(ModalControlWrapper);
 export default withTranslation()(ModalControlWrapper);

+ 3 - 3
src/client/js/components/PageEditorByHackmd.jsx

@@ -286,7 +286,7 @@ class PageEditorByHackmd extends React.Component {
                 disabled={this.state.isInitializing}
                 disabled={this.state.isInitializing}
                 onClick={() => { return this.resumeToEdit() }}
                 onClick={() => { return this.resumeToEdit() }}
               >
               >
-                <span className="btn-label"><i className="icon-control-end"></i></span>
+                <span className="btn-label"><i className="icon-fw icon-control-end"></i></span>
                 <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
                 <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
               </button>
               </button>
             </div>
             </div>
@@ -298,7 +298,7 @@ class PageEditorByHackmd extends React.Component {
               type="button"
               type="button"
               onClick={() => { return this.discardChanges() }}
               onClick={() => { return this.discardChanges() }}
             >
             >
-              <span className="btn-label"><i className="icon-control-start"></i></span>
+              <span className="btn-label"><i className="icon-fw icon-control-start"></i></span>
               <span className="btn-text">{t('hackmd.discard_changes')}</span>
               <span className="btn-text">{t('hackmd.discard_changes')}</span>
             </button>
             </button>
           </div>
           </div>
@@ -322,7 +322,7 @@ class PageEditorByHackmd extends React.Component {
               disabled={isRevisionOutdated || this.state.isInitializing}
               disabled={isRevisionOutdated || this.state.isInitializing}
               onClick={() => { return this.startToEdit() }}
               onClick={() => { return this.startToEdit() }}
             >
             >
-              <span className="btn-label"><i className="icon-paper-plane"></i></span>
+              <span className="btn-label"><i className="icon-fw icon-paper-plane"></i></span>
               {t('hackmd.start_to_edit')}
               {t('hackmd.start_to_edit')}
             </button>
             </button>
           </div>
           </div>

+ 9 - 6
src/client/js/components/Sidebar.jsx

@@ -10,6 +10,7 @@ import {
 
 
 import { withUnstatedContainers } from './UnstatedUtils';
 import { withUnstatedContainers } from './UnstatedUtils';
 import AppContainer from '../services/AppContainer';
 import AppContainer from '../services/AppContainer';
+import NavigationContainer from '../services/NavigationContainer';
 
 
 import SidebarNav from './Sidebar/SidebarNav';
 import SidebarNav from './Sidebar/SidebarNav';
 import RecentChanges from './Sidebar/RecentChanges';
 import RecentChanges from './Sidebar/RecentChanges';
@@ -22,6 +23,7 @@ class Sidebar extends React.Component {
 
 
   static propTypes = {
   static propTypes = {
     appContainer: PropTypes.instanceOf(AppContainer).isRequired,
     appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+    navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
     navigationUIController: PropTypes.any.isRequired,
     navigationUIController: PropTypes.any.isRequired,
     isDrawerModeOnInit: PropTypes.bool,
     isDrawerModeOnInit: PropTypes.bool,
   };
   };
@@ -58,7 +60,7 @@ class Sidebar extends React.Component {
    * return whether drawer mode or not
    * return whether drawer mode or not
    */
    */
   get isDrawerMode() {
   get isDrawerMode() {
-    let isDrawerMode = this.props.appContainer.state.isDrawerMode;
+    let isDrawerMode = this.props.navigationContainer.state.isDrawerMode;
     if (isDrawerMode == null) {
     if (isDrawerMode == null) {
       isDrawerMode = this.props.isDrawerModeOnInit;
       isDrawerMode = this.props.isDrawerModeOnInit;
     }
     }
@@ -120,8 +122,8 @@ class Sidebar extends React.Component {
   }
   }
 
 
   backdropClickedHandler = () => {
   backdropClickedHandler = () => {
-    const { appContainer } = this.props;
-    appContainer.setState({ isDrawerOpened: false });
+    const { navigationContainer } = this.props;
+    navigationContainer.setState({ isDrawerOpened: false });
   }
   }
 
 
   itemSelectedHandler = (contentsId) => {
   itemSelectedHandler = (contentsId) => {
@@ -156,7 +158,7 @@ class Sidebar extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { isDrawerOpened } = this.props.appContainer.state;
+    const { isDrawerOpened } = this.props.navigationContainer.state;
 
 
     return (
     return (
       <>
       <>
@@ -199,7 +201,7 @@ const SidebarWithNavigationUIController = withNavigationUIController(Sidebar);
  */
  */
 
 
 const SidebarWithNavigation = (props) => {
 const SidebarWithNavigation = (props) => {
-  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.appContainer.state;
+  const { preferDrawerModeByUser: isDrawerModeOnInit } = props.navigationContainer.state;
 
 
   const initUICForDrawerMode = isDrawerModeOnInit
   const initUICForDrawerMode = isDrawerModeOnInit
     // generate initialUIController for Drawer mode
     // generate initialUIController for Drawer mode
@@ -220,6 +222,7 @@ const SidebarWithNavigation = (props) => {
 
 
 SidebarWithNavigation.propTypes = {
 SidebarWithNavigation.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(SidebarWithNavigation, [AppContainer]);
+export default withUnstatedContainers(SidebarWithNavigation, [AppContainer, NavigationContainer]);

+ 1 - 1
src/client/js/components/Sidebar/RecentChanges.jsx

@@ -59,7 +59,7 @@ class RecentChanges extends React.Component {
     return (
     return (
       <li className="list-group-item p-2">
       <li className="list-group-item p-2">
         <div className="d-flex w-100">
         <div className="d-flex w-100">
-          <UserPicture user={page.lastUpdatedUser} size="md" />
+          <UserPicture user={page.lastUpdateUser} size="md" />
           <div className="flex-grow-1 ml-2">
           <div className="flex-grow-1 ml-2">
             { !dPagePath.isRoot && <FormerLink /> }
             { !dPagePath.isRoot && <FormerLink /> }
             <h5 className="mb-1">
             <h5 className="mb-1">

+ 10 - 5
src/client/js/legacy/crowi.js

@@ -240,10 +240,12 @@ $(() => {
 
 
   // tab changing handling
   // tab changing handling
   $('a[href="#revision-body"]').on('show.bs.tab', () => {
   $('a[href="#revision-body"]').on('show.bs.tab', () => {
-    appContainer.setEditorMode(null);
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+    navigationContainer.setEditorMode(null);
   });
   });
   $('a[href="#edit"]').on('show.bs.tab', () => {
   $('a[href="#edit"]').on('show.bs.tab', () => {
-    appContainer.setEditorMode('builtin');
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+    navigationContainer.setEditorMode('builtin');
     $('body').addClass('on-edit');
     $('body').addClass('on-edit');
     $('body').addClass('builtin-editor');
     $('body').addClass('builtin-editor');
   });
   });
@@ -252,7 +254,8 @@ $(() => {
     $('body').removeClass('builtin-editor');
     $('body').removeClass('builtin-editor');
   });
   });
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
   $('a[href="#hackmd"]').on('show.bs.tab', () => {
-    appContainer.setEditorMode('hackmd');
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+    navigationContainer.setEditorMode('hackmd');
     $('body').addClass('on-edit');
     $('body').addClass('on-edit');
     $('body').addClass('hackmd');
     $('body').addClass('hackmd');
   });
   });
@@ -317,8 +320,10 @@ window.addEventListener('load', (e) => {
 
 
   // hash on page
   // hash on page
   if (window.location.hash) {
   if (window.location.hash) {
+    const navigationContainer = appContainer.getContainer('NavigationContainer');
+
     if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
     if ((window.location.hash === '#edit' || window.location.hash === '#edit-form') && $('.tab-pane#edit').length > 0) {
-      appContainer.setState({ editorMode: 'builtin' });
+      navigationContainer.setEditorMode('builtin');
 
 
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('a[data-toggle="tab"][href="#edit"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('on-edit');
@@ -328,7 +333,7 @@ window.addEventListener('load', (e) => {
       Crowi.setCaretLineAndFocusToEditor();
       Crowi.setCaretLineAndFocusToEditor();
     }
     }
     else if (window.location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
     else if (window.location.hash === '#hackmd' && $('.tab-pane#hackmd').length > 0) {
-      appContainer.setState({ editorMode: 'hackmd' });
+      navigationContainer.setEditorMode('hackmd');
 
 
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('a[data-toggle="tab"][href="#hackmd"]').tab('show');
       $('body').addClass('on-edit');
       $('body').addClass('on-edit');

+ 2 - 4
src/client/js/nologin.jsx

@@ -5,7 +5,6 @@ import { I18nextProvider } from 'react-i18next';
 
 
 import i18nFactory from './util/i18n';
 import i18nFactory from './util/i18n';
 
 
-import NoLoginContainer from './services/NoLoginContainer';
 import AppContainer from './services/AppContainer';
 import AppContainer from './services/AppContainer';
 
 
 import InstallerForm from './components/InstallerForm';
 import InstallerForm from './components/InstallerForm';
@@ -31,9 +30,8 @@ if (installerFormElem) {
 // render loginForm
 // render loginForm
 const loginFormElem = document.getElementById('login-form');
 const loginFormElem = document.getElementById('login-form');
 if (loginFormElem) {
 if (loginFormElem) {
-  const noLoginContainer = new NoLoginContainer();
   const appContainer = new AppContainer();
   const appContainer = new AppContainer();
-  appContainer.init();
+  appContainer.initApp();
 
 
   const username = loginFormElem.dataset.username;
   const username = loginFormElem.dataset.username;
   const name = loginFormElem.dataset.name;
   const name = loginFormElem.dataset.name;
@@ -62,7 +60,7 @@ if (loginFormElem) {
 
 
   ReactDOM.render(
   ReactDOM.render(
     <I18nextProvider i18n={i18n}>
     <I18nextProvider i18n={i18n}>
-      <Provider inject={[noLoginContainer, appContainer]}>
+      <Provider inject={[appContainer]}>
         <LoginForm
         <LoginForm
           username={username}
           username={username}
           name={name}
           name={name}

+ 40 - 203
src/client/js/services/AppContainer.js

@@ -8,16 +8,11 @@ import InterceptorManager from '@commons/service/interceptor-manager';
 import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
 import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
 import GrowiRenderer from '../util/GrowiRenderer';
 import GrowiRenderer from '../util/GrowiRenderer';
 
 
-import Apiv1ErrorHandler from '../util/apiv1ErrorHandler';
-
-import {
-  DetachCodeBlockInterceptor,
-  RestoreCodeBlockInterceptor,
-} from '../util/interceptor/detach-code-blocks';
-
 import {
 import {
-  DrawioInterceptor,
-} from '../util/interceptor/drawio-interceptor';
+  mediaQueryListForDarkMode,
+  applyColorScheme,
+} from '../util/color-scheme';
+import Apiv1ErrorHandler from '../util/apiv1ErrorHandler';
 
 
 import i18nFactory from '../util/i18n';
 import i18nFactory from '../util/i18n';
 import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
 import apiv3ErrorHandler from '../util/apiv3ErrorHandler';
@@ -31,63 +26,29 @@ export default class AppContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
-    const { localStorage } = window;
-
     this.state = {
     this.state = {
-      editorMode: null,
-      isDeviceSmallerThanMd: null,
       preferDarkModeByMediaQuery: false,
       preferDarkModeByMediaQuery: false,
-      preferDarkModeByUser: localStorage.preferDarkModeByUser === 'true',
-      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
-      preferDrawerModeOnEditByUser: // default: true
-        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
-      isDrawerMode: null,
-      isDrawerOpened: false,
-
-      isPageCreateModalShown: false,
 
 
+      // stetes for contents
       recentlyUpdatedPages: [],
       recentlyUpdatedPages: [],
     };
     };
 
 
     const body = document.querySelector('body');
     const body = document.querySelector('body');
 
 
-    this.isAdmin = body.dataset.isAdmin === 'true';
     this.csrfToken = body.dataset.csrftoken;
     this.csrfToken = body.dataset.csrftoken;
-    this.isPluginEnabled = body.dataset.pluginEnabled === 'true';
-    this.isLoggedin = document.querySelector('body.nologin') == null;
 
 
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
     this.config = JSON.parse(document.getElementById('growi-context-hydrate').textContent || '{}');
 
 
-    const currentUserElem = document.getElementById('growi-current-user');
-    if (currentUserElem != null) {
-      this.currentUser = JSON.parse(currentUserElem.textContent);
-    }
-
     const userAgent = window.navigator.userAgent.toLowerCase();
     const userAgent = window.navigator.userAgent.toLowerCase();
     this.isMobile = /iphone|ipad|android/.test(userAgent);
     this.isMobile = /iphone|ipad|android/.test(userAgent);
 
 
-    this.isDocSaved = true;
-
-    this.originRenderer = new GrowiRenderer(this);
-
-    this.interceptorManager = new InterceptorManager();
-    this.interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(this), 10); // process as soon as possible
-    this.interceptorManager.addInterceptor(new DrawioInterceptor(this), 20);
-    this.interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(this), 900); // process as late as possible
-
     const userlang = body.dataset.userlang;
     const userlang = body.dataset.userlang;
     this.i18n = i18nFactory(userlang);
     this.i18n = i18nFactory(userlang);
-
-    if (this.isLoggedin) {
-      // remove old user cache
-      this.removeOldUserCache();
-    }
-
+    this.locales = this.i18n.options.resources;
     this.containerInstances = {};
     this.containerInstances = {};
     this.componentInstances = {};
     this.componentInstances = {};
     this.rendererInstances = {};
     this.rendererInstances = {};
 
 
-    this.removeOldUserCache = this.removeOldUserCache.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiGet = this.apiGet.bind(this);
     this.apiPost = this.apiPost.bind(this);
     this.apiPost = this.apiPost.bind(this);
     this.apiDelete = this.apiDelete.bind(this);
     this.apiDelete = this.apiDelete.bind(this);
@@ -100,23 +61,6 @@ export default class AppContainer extends Container {
       put: this.apiv3Put.bind(this),
       put: this.apiv3Put.bind(this),
       delete: this.apiv3Delete.bind(this),
       delete: this.apiv3Delete.bind(this),
     };
     };
-
-    this.openPageCreateModal = this.openPageCreateModal.bind(this);
-    this.closePageCreateModal = this.closePageCreateModal.bind(this);
-
-    window.addEventListener('keydown', (event) => {
-      const target = event.target;
-
-      // ignore when target dom is input
-      const inputPattern = /^input|textinput|textarea$/i;
-      if (inputPattern.test(target.tagName) || target.isContentEditable) {
-        return;
-      }
-
-      if (event.key === 'c') {
-        this.setState({ isPageCreateModalShown: true });
-      }
-    });
   }
   }
 
 
   /**
   /**
@@ -126,53 +70,56 @@ export default class AppContainer extends Container {
     return 'AppContainer';
     return 'AppContainer';
   }
   }
 
 
-  init() {
-    this.initDeviceSize();
-    this.initColorScheme();
-    this.initPlugins();
+  initApp() {
+    this.initMediaQueryForColorScheme();
+
+    this.injectToWindow();
   }
   }
 
 
-  initDeviceSize() {
-    const mdOrAvobeHandler = async(mql) => {
-      let isDeviceSmallerThanMd;
+  initContents() {
+    const body = document.querySelector('body');
 
 
-      // sm -> md
-      if (mql.matches) {
-        isDeviceSmallerThanMd = false;
-      }
-      // md -> sm
-      else {
-        isDeviceSmallerThanMd = true;
-      }
+    const currentUserElem = document.getElementById('growi-current-user');
+    if (currentUserElem != null) {
+      this.currentUser = JSON.parse(currentUserElem.textContent);
+    }
 
 
-      this.setState({ isDeviceSmallerThanMd });
-      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
-    };
+    this.isAdmin = body.dataset.isAdmin === 'true';
+
+    this.isDocSaved = true;
+
+    this.originRenderer = new GrowiRenderer(this);
+
+    this.interceptorManager = new InterceptorManager();
+
+    if (this.currentUser != null) {
+      // remove old user cache
+      this.removeOldUserCache();
+    }
+
+    const isPluginEnabled = body.dataset.pluginEnabled === 'true';
+    if (isPluginEnabled) {
+      this.initPlugins();
+    }
 
 
-    this.addBreakpointListener('md', mdOrAvobeHandler, true);
+    this.injectToWindow();
   }
   }
 
 
-  async initColorScheme() {
+  async initMediaQueryForColorScheme() {
     const switchStateByMediaQuery = async(mql) => {
     const switchStateByMediaQuery = async(mql) => {
       const preferDarkMode = mql.matches;
       const preferDarkMode = mql.matches;
-      await this.setState({ preferDarkModeByMediaQuery: preferDarkMode });
+      this.setState({ preferDarkModeByMediaQuery: preferDarkMode });
 
 
-      this.applyColorScheme();
+      applyColorScheme();
     };
     };
 
 
-    const mqlForDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
     // add event listener
     // add event listener
-    mqlForDarkMode.addListener(switchStateByMediaQuery);
-
-    // initialize: check media query
-    switchStateByMediaQuery(mqlForDarkMode);
+    mediaQueryListForDarkMode.addListener(switchStateByMediaQuery);
   }
   }
 
 
   initPlugins() {
   initPlugins() {
-    if (this.isPluginEnabled) {
-      const growiPlugin = window.growiPlugin;
-      growiPlugin.installAll(this, this.originRenderer);
-    }
+    const growiPlugin = window.growiPlugin;
+    growiPlugin.installAll(this, this.originRenderer);
   }
   }
 
 
   injectToWindow() {
   injectToWindow() {
@@ -331,16 +278,6 @@ export default class AppContainer extends Container {
     this.setState({ recentlyUpdatedPages: data.pages });
     this.setState({ recentlyUpdatedPages: data.pages });
   }
   }
 
 
-  setEditorMode(editorMode) {
-    this.setState({ editorMode });
-    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
-  }
-
-  toggleDrawer() {
-    const { isDrawerOpened } = this.state;
-    this.setState({ isDrawerOpened: !isDrawerOpened });
-  }
-
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     let targetComponent;
     switch (componentKind) {
     switch (componentKind) {
@@ -361,98 +298,6 @@ export default class AppContainer extends Container {
     targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
     targetComponent.launchDrawioModal(beginLineNumber, endLineNumber);
   }
   }
 
 
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreference(bool) {
-    this.setState({ preferDrawerModeByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeByUser = bool;
-  }
-
-  /**
-   * Set Sidebar mode preference by user
-   * @param {boolean} preferDockMode
-   */
-  async setDrawerModePreferenceOnEdit(bool) {
-    this.setState({ preferDrawerModeOnEditByUser: bool });
-    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    localStorage.preferDrawerModeOnEditByUser = bool;
-  }
-
-  /**
-   * Update drawer related state by specified 'newState' object
-   * @param {object} newState A newest state object
-   *
-   * Specify 'newState' like following code:
-   *
-   *   { ...this.state, overwriteParam: overwriteValue }
-   *
-   * because updating state of unstated container will be delayed unless you use await
-   */
-  updateDrawerMode(newState) {
-    const {
-      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
-    } = newState;
-
-    // get preference on view or edit
-    const preferDrawerMode = editorMode != null ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
-
-    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
-    const isDrawerOpened = false; // close Drawer anyway
-
-    this.setState({ isDrawerMode, isDrawerOpened });
-  }
-
-  /**
-   * Set color scheme preference by user
-   * @param {boolean} isDarkMode
-   */
-  async setColorSchemePreference(isDarkMode) {
-    await this.setState({ preferDarkModeByUser: isDarkMode });
-
-    // store settings to localStorage
-    const { localStorage } = window;
-    if (isDarkMode == null) {
-      delete localStorage.removeItem('preferDarkModeByUser');
-    }
-    else {
-      localStorage.preferDarkModeByUser = isDarkMode;
-    }
-
-    this.applyColorScheme();
-  }
-
-  /**
-   * Apply color scheme as 'dark' attribute of <html></html>
-   */
-  applyColorScheme() {
-    const { preferDarkModeByMediaQuery, preferDarkModeByUser } = this.state;
-
-    let isDarkMode = preferDarkModeByMediaQuery;
-    if (preferDarkModeByUser != null) {
-      isDarkMode = preferDarkModeByUser;
-    }
-
-    // switch to dark mode
-    if (isDarkMode) {
-      document.documentElement.removeAttribute('light');
-      document.documentElement.setAttribute('dark', 'true');
-    }
-    // switch to light mode
-    else {
-      document.documentElement.setAttribute('light', 'true');
-      document.documentElement.removeAttribute('dark');
-    }
-  }
-
   async apiGet(path, params) {
   async apiGet(path, params) {
     return this.apiRequest('get', path, { params });
     return this.apiRequest('get', path, { params });
   }
   }
@@ -527,12 +372,4 @@ export default class AppContainer extends Container {
     return this.apiv3Request('delete', path, { params });
     return this.apiv3Request('delete', path, { params });
   }
   }
 
 
-  openPageCreateModal() {
-    this.setState({ isPageCreateModalShown: true });
-  }
-
-  closePageCreateModal() {
-    this.setState({ isPageCreateModalShown: false });
-  }
-
 }
 }

+ 151 - 0
src/client/js/services/NavigationContainer.js

@@ -0,0 +1,151 @@
+import { Container } from 'unstated';
+
+/**
+ * Service container related to options for Application
+ * @extends {Container} unstated Container
+ */
+export default class NavigationContainer extends Container {
+
+  constructor(appContainer) {
+    super();
+
+    this.appContainer = appContainer;
+    this.appContainer.registerContainer(this);
+
+    const { localStorage } = window;
+
+    this.state = {
+      editorMode: null,
+
+      isDeviceSmallerThanMd: null,
+      preferDrawerModeByUser: localStorage.preferDrawerModeByUser === 'true',
+      preferDrawerModeOnEditByUser: // default: true
+        localStorage.preferDrawerModeOnEditByUser == null || localStorage.preferDrawerModeOnEditByUser === 'true',
+      isDrawerMode: null,
+      isDrawerOpened: false,
+
+      isPageCreateModalShown: false,
+    };
+
+    this.openPageCreateModal = this.openPageCreateModal.bind(this);
+    this.closePageCreateModal = this.closePageCreateModal.bind(this);
+
+    this.initHotkeys();
+    this.initDeviceSize();
+  }
+
+  /**
+   * Workaround for the mangling in production build to break constructor.name
+   */
+  static getClassName() {
+    return 'NavigationContainer';
+  }
+
+  initHotkeys() {
+    window.addEventListener('keydown', (event) => {
+      const target = event.target;
+
+      // ignore when target dom is input
+      const inputPattern = /^input|textinput|textarea$/i;
+      if (inputPattern.test(target.tagName) || target.isContentEditable) {
+        return;
+      }
+
+      if (event.key === 'c') {
+        // don't fire when not needed
+        if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
+          this.setState({ isPageCreateModalShown: true });
+        }
+      }
+    });
+  }
+
+  initDeviceSize() {
+    const mdOrAvobeHandler = async(mql) => {
+      let isDeviceSmallerThanMd;
+
+      // sm -> md
+      if (mql.matches) {
+        isDeviceSmallerThanMd = false;
+      }
+      // md -> sm
+      else {
+        isDeviceSmallerThanMd = true;
+      }
+
+      this.setState({ isDeviceSmallerThanMd });
+      this.updateDrawerMode({ ...this.state, isDeviceSmallerThanMd }); // generate newest state object
+    };
+
+    this.appContainer.addBreakpointListener('md', mdOrAvobeHandler, true);
+  }
+
+  setEditorMode(editorMode) {
+    this.setState({ editorMode });
+    this.updateDrawerMode({ ...this.state, editorMode }); // generate newest state object
+  }
+
+  toggleDrawer() {
+    const { isDrawerOpened } = this.state;
+    this.setState({ isDrawerOpened: !isDrawerOpened });
+  }
+
+  /**
+   * Set Sidebar mode preference by user
+   * @param {boolean} preferDockMode
+   */
+  async setDrawerModePreference(bool) {
+    this.setState({ preferDrawerModeByUser: bool });
+    this.updateDrawerMode({ ...this.state, preferDrawerModeByUser: bool }); // generate newest state object
+
+    // store settings to localStorage
+    const { localStorage } = window;
+    localStorage.preferDrawerModeByUser = bool;
+  }
+
+  /**
+   * Set Sidebar mode preference by user
+   * @param {boolean} preferDockMode
+   */
+  async setDrawerModePreferenceOnEdit(bool) {
+    this.setState({ preferDrawerModeOnEditByUser: bool });
+    this.updateDrawerMode({ ...this.state, preferDrawerModeOnEditByUser: bool }); // generate newest state object
+
+    // store settings to localStorage
+    const { localStorage } = window;
+    localStorage.preferDrawerModeOnEditByUser = bool;
+  }
+
+  /**
+   * Update drawer related state by specified 'newState' object
+   * @param {object} newState A newest state object
+   *
+   * Specify 'newState' like following code:
+   *
+   *   { ...this.state, overwriteParam: overwriteValue }
+   *
+   * because updating state of unstated container will be delayed unless you use await
+   */
+  updateDrawerMode(newState) {
+    const {
+      editorMode, isDeviceSmallerThanMd, preferDrawerModeByUser, preferDrawerModeOnEditByUser,
+    } = newState;
+
+    // get preference on view or edit
+    const preferDrawerMode = editorMode != null ? preferDrawerModeOnEditByUser : preferDrawerModeByUser;
+
+    const isDrawerMode = isDeviceSmallerThanMd || preferDrawerMode;
+    const isDrawerOpened = false; // close Drawer anyway
+
+    this.setState({ isDrawerMode, isDrawerOpened });
+  }
+
+  openPageCreateModal() {
+    this.setState({ isPageCreateModalShown: true });
+  }
+
+  closePageCreateModal() {
+    this.setState({ isPageCreateModalShown: false });
+  }
+
+}

+ 0 - 23
src/client/js/services/NoLoginContainer.js

@@ -1,23 +0,0 @@
-import { Container } from 'unstated';
-
-/**
- * Service container related to Nologin (installer, login)
- * @extends {Container} unstated Container
- */
-export default class NoLoginContainer extends Container {
-
-  constructor() {
-    super();
-
-    const body = document.querySelector('body');
-    this.csrfToken = body.dataset.csrftoken;
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'NoLoginContainer';
-  }
-
-}

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

@@ -6,6 +6,15 @@ import * as entities from 'entities';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
 
 
+import {
+  DetachCodeBlockInterceptor,
+  RestoreCodeBlockInterceptor,
+} from '../util/interceptor/detach-code-blocks';
+
+import {
+  DrawioInterceptor,
+} from '../util/interceptor/drawio-interceptor';
+
 const logger = loggerFactory('growi:services:PageContainer');
 const logger = loggerFactory('growi:services:PageContainer');
 const scrollThresForSticky = 0;
 const scrollThresForSticky = 0;
 const scrollThresForCompact = 30;
 const scrollThresForCompact = 30;
@@ -69,6 +78,11 @@ export default class PageContainer extends Container {
       isSubnavCompact: false,
       isSubnavCompact: false,
     };
     };
 
 
+    const { interceptorManager } = this.appContainer;
+    interceptorManager.addInterceptor(new DetachCodeBlockInterceptor(appContainer), 10); // process as soon as possible
+    interceptorManager.addInterceptor(new DrawioInterceptor(appContainer), 20);
+    interceptorManager.addInterceptor(new RestoreCodeBlockInterceptor(appContainer), 900); // process as late as possible
+
     this.initStateMarkdown();
     this.initStateMarkdown();
     this.initStateOthers();
     this.initStateOthers();
 
 
@@ -179,6 +193,10 @@ export default class PageContainer extends Container {
     }
     }
   }
   }
 
 
+  get navigationContainer() {
+    return this.appContainer.getContainer('NavigationContainer');
+  }
+
   setLatestRemotePageData(page, user) {
   setLatestRemotePageData(page, user) {
     this.setState({
     this.setState({
       remoteRevisionId: page.revision._id,
       remoteRevisionId: page.revision._id,
@@ -199,7 +217,7 @@ export default class PageContainer extends Container {
    * @param {Array[Tag]} tags Array of Tag
    * @param {Array[Tag]} tags Array of Tag
    */
    */
   updateStateAfterSave(page, tags) {
   updateStateAfterSave(page, tags) {
-    const { editorMode } = this.appContainer.state;
+    const { editorMode } = this.navigationContainer.state;
 
 
     // update state of PageContainer
     // update state of PageContainer
     const newState = {
     const newState = {
@@ -243,7 +261,7 @@ export default class PageContainer extends Container {
    * @return {object} { page: Page, tags: Tag[] }
    * @return {object} { page: Page, tags: Tag[] }
    */
    */
   async save(markdown, optionsToSave = {}) {
   async save(markdown, optionsToSave = {}) {
-    const { editorMode } = this.appContainer.state;
+    const { editorMode } = this.navigationContainer.state;
 
 
     const { pageId, path } = this.state;
     const { pageId, path } = this.state;
     let { revisionId } = this.state;
     let { revisionId } = this.state;
@@ -274,7 +292,7 @@ export default class PageContainer extends Container {
       throw new Error(msg);
       throw new Error(msg);
     }
     }
 
 
-    const { editorMode } = this.appContainer.state;
+    const { editorMode } = this.navigationContainer.state;
     if (editorMode == null) {
     if (editorMode == null) {
       logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
       logger.warn('\'saveAndReload\' requires the \'errorMode\' param');
       return;
       return;

+ 73 - 0
src/client/js/util/color-scheme.js

@@ -0,0 +1,73 @@
+const mediaQueryListForDarkMode = window.matchMedia('(prefers-color-scheme: dark)');
+
+function isUserPreferenceExists() {
+  return localStorage.preferDarkModeByUser != null;
+}
+
+function isPreferedDarkModeByUser() {
+  return localStorage.preferDarkModeByUser === 'true';
+}
+
+function isDarkMode() {
+  if (isUserPreferenceExists()) {
+    return isPreferedDarkModeByUser();
+  }
+  return mediaQueryListForDarkMode.matches;
+}
+
+/**
+ * Apply color scheme as 'dark' attribute of <html></html>
+ */
+function applyColorScheme() {
+  let isDarkMode = mediaQueryListForDarkMode.matches;
+  if (isUserPreferenceExists()) {
+    isDarkMode = isPreferedDarkModeByUser();
+  }
+
+  // switch to dark mode
+  if (isDarkMode) {
+    document.documentElement.removeAttribute('light');
+    document.documentElement.setAttribute('dark', 'true');
+  }
+  // switch to light mode
+  else {
+    document.documentElement.setAttribute('light', 'true');
+    document.documentElement.removeAttribute('dark');
+  }
+}
+
+/**
+ * Remove color scheme preference
+ */
+function removeUserPreference() {
+  if (isUserPreferenceExists()) {
+    delete localStorage.removeItem('preferDarkModeByUser');
+  }
+}
+
+/**
+ * Set color scheme preference
+ * @param {boolean} isDarkMode
+ */
+function updateUserPreference(isDarkMode) {
+  // store settings to localStorage
+  localStorage.preferDarkModeByUser = isDarkMode;
+}
+
+/**
+ * Set color scheme preference with OS settings
+ */
+function updateUserPreferenceWithOsSettings() {
+  localStorage.preferDarkModeByUser = mediaQueryListForDarkMode.matches;
+}
+
+export {
+  mediaQueryListForDarkMode,
+  isUserPreferenceExists,
+  isPreferedDarkModeByUser,
+  isDarkMode,
+  applyColorScheme,
+  removeUserPreference,
+  updateUserPreference,
+  updateUserPreferenceWithOsSettings,
+};

+ 9 - 6
src/client/js/util/interceptor/drawio-interceptor.js

@@ -1,7 +1,9 @@
 /* eslint-disable import/prefer-default-export */
 /* eslint-disable import/prefer-default-export */
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
+import { Provider } from 'unstated';
 import { BasicInterceptor } from 'growi-commons';
 import { BasicInterceptor } from 'growi-commons';
+
 import Drawio from '../../components/Drawio';
 import Drawio from '../../components/Drawio';
 
 
 /**
 /**
@@ -132,12 +134,13 @@ export class DrawioInterceptor extends BasicInterceptor {
   renderReactDOM(drawioMapEntry, elem, isPreview) {
   renderReactDOM(drawioMapEntry, elem, isPreview) {
     ReactDOM.render(
     ReactDOM.render(
       // eslint-disable-next-line react/jsx-filename-extension
       // eslint-disable-next-line react/jsx-filename-extension
-      <Drawio
-        appContainer={this.appContainer}
-        drawioContent={drawioMapEntry.contentHtml}
-        isPreview={isPreview}
-        rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
-      />,
+      <Provider inject={[this.appContainer]}>
+        <Drawio
+          drawioContent={drawioMapEntry.contentHtml}
+          isPreview={isPreview}
+          rangeLineNumberOfMarkdown={drawioMapEntry.rangeLineNumberOfMarkdown}
+        />
+      </Provider>,
       elem,
       elem,
     );
     );
   }
   }

+ 2 - 2
src/client/styles/scss/_mixins.scss

@@ -83,9 +83,9 @@
 @mixin expand-modal-fullscreen($hasModalHeader: true, $hasModalFooter: true) {
 @mixin expand-modal-fullscreen($hasModalHeader: true, $hasModalFooter: true) {
   // full-screen modal
   // full-screen modal
   width: auto;
   width: auto;
-  max-width: unset;
+  max-width: unset !important;
   height: calc(100vh - 30px);
   height: calc(100vh - 30px);
-  margin: 15px;
+  margin: 15px !important;
 
 
   .modal-content {
   .modal-content {
     height: calc(100vh - 30px);
     height: calc(100vh - 30px);

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

@@ -8,6 +8,8 @@ div.body {
 }
 }
 
 
 .wiki {
 .wiki {
+  @extend .text-break;
+
   font-size: 15px;
   font-size: 15px;
 
 
   // override line-height except hljs and child of it.
   // override line-height except hljs and child of it.

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

@@ -63,14 +63,20 @@
 /*
 /*
  * for Guest User Mode
  * for Guest User Mode
  */
  */
+// TODO: reactify and replace with `grw-not-available-for-guest`
 .dropdown-toggle.dropdown-toggle-disabled {
 .dropdown-toggle.dropdown-toggle-disabled {
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
+// TODO: reactify and replace with `grw-not-available-for-guest`
 .edit-button.edit-button-disabled {
 .edit-button.edit-button-disabled {
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
+.grw-not-available-for-guest {
+  cursor: not-allowed !important;
+}
+
 /*
 /*
  * Helper Classes
  * Helper Classes
  */
  */

+ 1 - 1
src/lib/util/mongoose-utils.js

@@ -7,7 +7,7 @@ const getMongoUri = () => {
     || env.MONGODB_URI // MONGOLAB changes their env name
     || env.MONGODB_URI // MONGOLAB changes their env name
     || env.MONGOHQ_URL
     || env.MONGOHQ_URL
     || env.MONGO_URI
     || env.MONGO_URI
-    || ((env.NODE_ENV === 'test') ? 'mongodb://localhost/growi_test' : 'mongodb://localhost/growi');
+    || ((env.NODE_ENV === 'test') ? 'mongodb://mongo/growi_test' : 'mongodb://mongo/growi');
 };
 };
 
 
 const getModelSafely = (modelName) => {
 const getModelSafely = (modelName) => {

+ 5 - 0
src/server/crowi/index.js

@@ -8,6 +8,7 @@ const CdnResourcesService = require('@commons/service/cdn-resources-service');
 const Xss = require('@commons/service/xss');
 const Xss = require('@commons/service/xss');
 const { getMongoUri } = require('@commons/util/mongoose-utils');
 const { getMongoUri } = require('@commons/util/mongoose-utils');
 
 
+const fs = require('fs');
 const path = require('path');
 const path = require('path');
 
 
 const sep = path.sep;
 const sep = path.sep;
@@ -33,6 +34,10 @@ function Crowi(rootdir) {
   this.viewsDir = path.join(this.libDir, 'views') + sep;
   this.viewsDir = path.join(this.libDir, 'views') + sep;
   this.resourceDir = path.join(this.rootDir, 'resource') + sep;
   this.resourceDir = path.join(this.rootDir, 'resource') + sep;
   this.localeDir = path.join(this.resourceDir, 'locales') + sep;
   this.localeDir = path.join(this.resourceDir, 'locales') + sep;
+  this.locales = fs.readdirSync(this.localeDir)
+    .filter((filename) => {
+      return fs.statSync(path.join(this.localeDir, filename)).isDirectory();
+    });
   this.tmpDir = path.join(this.rootDir, 'tmp') + sep;
   this.tmpDir = path.join(this.rootDir, 'tmp') + sep;
   this.cacheDir = path.join(this.tmpDir, 'cache');
   this.cacheDir = path.join(this.tmpDir, 'cache');
 
 

+ 3 - 32
src/server/models/user.js

@@ -58,8 +58,7 @@ module.exports = function(crowi) {
     apiToken: { type: String, index: true },
     apiToken: { type: String, index: true },
     lang: {
     lang: {
       type: String,
       type: String,
-      // eslint-disable-next-line no-eval
-      enum: Object.keys(getLanguageLabels()).map((k) => { return eval(k) }),
+      enum: crowi.locales,
       default: LANG_EN_US,
       default: LANG_EN_US,
     },
     },
     status: {
     status: {
@@ -425,12 +424,8 @@ module.exports = function(crowi) {
       .sort(sort);
       .sort(sort);
   };
   };
 
 
-  userSchema.statics.findAdmins = function(callback) {
-    this.find({ admin: true })
-      .exec((err, admins) => {
-        debug('Admins: ', admins);
-        callback(err, admins);
-      });
+  userSchema.statics.findAdmins = async function() {
+    return this.find({ admin: true });
   };
   };
 
 
   userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
   userSchema.statics.findUsersByPartOfEmail = function(emailPart, options) {
@@ -556,30 +551,6 @@ module.exports = function(crowi) {
     });
     });
   };
   };
 
 
-  userSchema.statics.removeCompletelyById = function(id, callback) {
-    const User = this;
-    User.findById(id, (err, userData) => {
-      if (!userData) {
-        return callback(err, null);
-      }
-
-      debug('Removing user:', userData);
-      // 物理削除可能なのは、承認待ちユーザー、招待中ユーザーのみ
-      // 利用を一度開始したユーザーは論理削除のみ可能
-      if (userData.status !== STATUS_REGISTERED && userData.status !== STATUS_INVITED) {
-        return callback(new Error('Cannot remove completely the user whoes status is not INVITED'), null);
-      }
-
-      userData.remove((err) => {
-        if (err) {
-          return callback(err, null);
-        }
-
-        return callback(null, 1);
-      });
-    });
-  };
-
   userSchema.statics.resetPasswordByRandomString = async function(id) {
   userSchema.statics.resetPasswordByRandomString = async function(id) {
     const user = await this.findById(id);
     const user = await this.findById(id);
 
 

+ 5 - 61
src/server/routes/admin.js

@@ -4,8 +4,6 @@ module.exports = function(crowi, app) {
   const logger = require('@alias/logger')('growi:routes:admin');
   const logger = require('@alias/logger')('growi:routes:admin');
 
 
   const models = crowi.models;
   const models = crowi.models;
-  const User = models.User;
-  const ExternalAccount = models.ExternalAccount;
   const UserGroup = models.UserGroup;
   const UserGroup = models.UserGroup;
   const UserGroupRelation = models.UserGroupRelation;
   const UserGroupRelation = models.UserGroupRelation;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
   const GlobalNotificationSetting = models.GlobalNotificationSetting;
@@ -170,6 +168,7 @@ module.exports = function(crowi, app) {
   // app.get('/admin/notification/slackAuth'     , admin.notification.slackauth);
   // app.get('/admin/notification/slackAuth'     , admin.notification.slackauth);
   actions.notification.slackAuth = function(req, res) {
   actions.notification.slackAuth = function(req, res) {
     const code = req.query.code;
     const code = req.query.code;
+    const { t } = req;
 
 
     if (!code || !slackNotificationService.hasSlackConfig()) {
     if (!code || !slackNotificationService.hasSlackConfig()) {
       return res.redirect('/admin/notification');
       return res.redirect('/admin/notification');
@@ -182,17 +181,17 @@ module.exports = function(crowi, app) {
 
 
         try {
         try {
           await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': data.access_token });
           await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': data.access_token });
-          req.flash('successMessage', ['Successfully Connected!']);
+          req.flash('successMessage', [t('message.successfully_connected')]);
         }
         }
         catch (err) {
         catch (err) {
-          req.flash('errorMessage', ['Failed to save access_token. Please try again.']);
+          req.flash('errorMessage', [t('message.fail_to_save_access_token')]);
         }
         }
 
 
         return res.redirect('/admin/notification');
         return res.redirect('/admin/notification');
       })
       })
       .catch((err) => {
       .catch((err) => {
         debug('oauth response ERROR', err);
         debug('oauth response ERROR', err);
-        req.flash('errorMessage', ['Failed to fetch access_token. Please do connect again.']);
+        req.flash('errorMessage', [t('message.fail_to_fetch_access_token')]);
         return res.redirect('/admin/notification');
         return res.redirect('/admin/notification');
       });
       });
   };
   };
@@ -200,7 +199,7 @@ module.exports = function(crowi, app) {
   // app.post('/admin/notification/slackSetting/disconnect' , admin.notification.disconnectFromSlack);
   // app.post('/admin/notification/slackSetting/disconnect' , admin.notification.disconnectFromSlack);
   actions.notification.disconnectFromSlack = async function(req, res) {
   actions.notification.disconnectFromSlack = async function(req, res) {
     await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': '' });
     await configManager.updateConfigsInTheSameNamespace('notification', { 'slack:token': '' });
-    req.flash('successMessage', ['Successfully Disconnected!']);
+    req.flash('successMessage', [req.t('successfully_disconnected')]);
 
 
     return res.redirect('/admin/notification');
     return res.redirect('/admin/notification');
   };
   };
@@ -232,66 +231,11 @@ module.exports = function(crowi, app) {
     return res.render('admin/users');
     return res.render('admin/users');
   };
   };
 
 
-  // これやったときの relation の挙動未確認
-  actions.user.removeCompletely = function(req, res) {
-    // ユーザーの物理削除
-    const id = req.params.id;
-
-    User.removeCompletelyById(id, (err, removed) => {
-      if (err) {
-        debug('Error while removing user.', err, id);
-        req.flash('errorMessage', '完全な削除に失敗しました。');
-      }
-      else {
-        req.flash('successMessage', '削除しました');
-      }
-      return res.redirect('/admin/users');
-    });
-  };
-
-  // app.post('/_api/admin/users.resetPassword' , admin.api.usersResetPassword);
-  actions.user.resetPassword = async function(req, res) {
-    const id = req.body.user_id;
-    const User = crowi.model('User');
-
-    try {
-      const newPassword = await User.resetPasswordByRandomString(id);
-
-      const user = await User.findById(id);
-
-      const result = { user: user.toObject(), newPassword };
-      return res.json(ApiResponse.success(result));
-    }
-    catch (err) {
-      debug('Error on reseting password', err);
-      return res.json(ApiResponse.error(err));
-    }
-  };
-
   actions.externalAccount = {};
   actions.externalAccount = {};
   actions.externalAccount.index = function(req, res) {
   actions.externalAccount.index = function(req, res) {
     return res.render('admin/external-accounts');
     return res.render('admin/external-accounts');
   };
   };
 
 
-  actions.externalAccount.remove = async function(req, res) {
-    const id = req.params.id;
-
-    let account = null;
-
-    try {
-      account = await ExternalAccount.findByIdAndRemove(id);
-      if (account == null) {
-        throw new Error('削除に失敗しました。');
-      }
-    }
-    catch (err) {
-      req.flash('errorMessage', err.message);
-      return res.redirect('/admin/users/external-accounts');
-    }
-
-    req.flash('successMessage', `外部アカウント '${account.providerType}/${account.accountId}' を削除しました`);
-    return res.redirect('/admin/users/external-accounts');
-  };
 
 
   actions.userGroup = {};
   actions.userGroup = {};
   actions.userGroup.index = function(req, res) {
   actions.userGroup.index = function(req, res) {

+ 1 - 1
src/server/routes/apiv3/app-settings.js

@@ -108,7 +108,7 @@ module.exports = (crowi) => {
     appSetting: [
     appSetting: [
       body('title').trim(),
       body('title').trim(),
       body('confidential'),
       body('confidential'),
-      body('globalLang').isIn(['en-US', 'ja']),
+      body('globalLang').isIn(crowi.locales),
       body('fileUpload').isBoolean(),
       body('fileUpload').isBoolean(),
     ],
     ],
     siteUrlSetting: [
     siteUrlSetting: [

+ 2 - 2
src/server/routes/apiv3/notification-setting.js

@@ -34,8 +34,8 @@ const validator = {
     }),
     }),
   ],
   ],
   notifyForPageGrant: [
   notifyForPageGrant: [
-    body('isNotificationForOwnerPageEnabled').isBoolean(),
-    body('isNotificationForGroupPageEnabled').isBoolean(),
+    body('isNotificationForOwnerPageEnabled').if(value => value != null).isBoolean(),
+    body('isNotificationForGroupPageEnabled').if(value => value != null).isBoolean(),
   ],
   ],
 };
 };
 
 

+ 1 - 1
src/server/routes/apiv3/personal-setting.js

@@ -76,7 +76,7 @@ module.exports = (crowi) => {
     personal: [
     personal: [
       body('name').isString().not().isEmpty(),
       body('name').isString().not().isEmpty(),
       body('email').isEmail(),
       body('email').isEmail(),
-      body('lang').isString().isIn(['en-US', 'ja']),
+      body('lang').isString().isIn(crowi.locales),
       body('isEmailPublished').isBoolean(),
       body('isEmailPublished').isBoolean(),
     ],
     ],
     imageType: [
     imageType: [

+ 1 - 4
src/server/routes/apiv3/statistics.js

@@ -8,8 +8,6 @@ const router = express.Router();
 
 
 const helmet = require('helmet');
 const helmet = require('helmet');
 
 
-const util = require('util');
-
 const USER_STATUS_MASTER = {
 const USER_STATUS_MASTER = {
   1: 'registered',
   1: 'registered',
   2: 'active',
   2: 'active',
@@ -54,8 +52,7 @@ module.exports = (crowi) => {
     const inactiveUserTotal = userCountResults.invited + userCountResults.deleted + userCountResults.suspended + userCountResults.registered;
     const inactiveUserTotal = userCountResults.invited + userCountResults.deleted + userCountResults.suspended + userCountResults.registered;
 
 
     // Get admin users
     // Get admin users
-    const findAdmins = util.promisify(User.findAdmins).bind(User);
-    const adminUsers = await findAdmins();
+    const adminUsers = await User.findAdmins();
 
 
     return {
     return {
       total: activeUserCount + inactiveUserTotal,
       total: activeUserCount + inactiveUserTotal,

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

@@ -93,12 +93,8 @@ module.exports = function(crowi, app) {
   app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
   app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
 
 
   app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
   app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
-  app.post('/admin/user/:id/removeCompletely' , loginRequiredStrictly , adminRequired , csrf, admin.user.removeCompletely);
-  // new route patterns from here:
-  app.post('/_api/admin/users.resetPassword'  , loginRequiredStrictly , adminRequired , csrf, admin.user.resetPassword);
 
 
   app.get('/admin/users/external-accounts'               , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
   app.get('/admin/users/external-accounts'               , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
-  app.post('/admin/users/external-accounts/:id/remove'   , loginRequiredStrictly , adminRequired , admin.externalAccount.remove);
 
 
   // user-groups admin
   // user-groups admin
   app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);
   app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);

+ 7 - 3
src/server/routes/installer.js

@@ -79,7 +79,7 @@ module.exports = function(crowi, app) {
       await adminUser.asyncMakeAdmin();
       await adminUser.asyncMakeAdmin();
     }
     }
     catch (err) {
     catch (err) {
-      req.form.errors.push(`管理ユーザーの作成に失敗しました。${err.message}`);
+      req.form.errors.push(req.t('message.failed_to_create_admin_user', { errMessage: err.message }));
       return res.render('installer');
       return res.render('installer');
     }
     }
     // create initial pages
     // create initial pages
@@ -91,9 +91,13 @@ module.exports = function(crowi, app) {
 
 
     // login with passport
     // login with passport
     req.logIn(adminUser, (err) => {
     req.logIn(adminUser, (err) => {
-      if (err) { return next() }
+      if (err) {
+        req.flash('successMessage', req.t('message.complete_to_install1'));
+        req.session.redirectTo = '/admin/app';
+        return res.redirect('/login');
+      }
 
 
-      req.flash('successMessage', 'GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。');
+      req.flash('successMessage', req.t('message.complete_to_install2'));
       return res.redirect('/admin/app');
       return res.redirect('/admin/app');
     });
     });
   };
   };

+ 12 - 12
src/server/routes/login-passport.js

@@ -34,7 +34,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    * @param {*} res
    */
    */
   const loginFailureHandler = (req, res, message) => {
   const loginFailureHandler = (req, res, message) => {
-    req.flash('errorMessage', message || 'Sign in failure.');
+    req.flash('errorMessage', message || req.t('message.sign_in_failure'));
     return res.redirect('/login');
     return res.redirect('/login');
   };
   };
 
 
@@ -44,7 +44,7 @@ module.exports = function(crowi, app) {
    * @param {*} res
    * @param {*} res
    */
    */
   const loginFailure = (req, res) => {
   const loginFailure = (req, res) => {
-    return loginFailureHandler(req, res, 'Sign in failure.');
+    return loginFailureHandler(req, res, req.t('message.sign_in_failure'));
   };
   };
 
 
   /**
   /**
@@ -142,7 +142,7 @@ module.exports = function(crowi, app) {
       debug('LdapStrategy has not been set up');
       debug('LdapStrategy has not been set up');
       return res.json(ApiResponse.success({
       return res.json(ApiResponse.success({
         status: 'warning',
         status: 'warning',
-        message: 'LdapStrategy has not been set up',
+        message: req.t('message.strategy_has_not_been_set_up', { strategy: 'LdapStrategy' }),
       }));
       }));
     }
     }
 
 
@@ -196,7 +196,7 @@ module.exports = function(crowi, app) {
   const loginWithLocal = (req, res, next) => {
   const loginWithLocal = (req, res, next) => {
     if (!passportService.isLocalStrategySetup) {
     if (!passportService.isLocalStrategySetup) {
       debug('LocalStrategy has not been set up');
       debug('LocalStrategy has not been set up');
-      req.flash('warningMessage', 'LocalStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'LocalStrategy' }));
       return next();
       return next();
     }
     }
 
 
@@ -212,7 +212,7 @@ module.exports = function(crowi, app) {
 
 
       if (err) { // DB Error
       if (err) { // DB Error
         logger.error('Database Server Error: ', err);
         logger.error('Database Server Error: ', err);
-        req.flash('warningMessage', 'Database Server Error occured.');
+        req.flash('warningMessage', req.t('message.database_error'));
         return next(); // pass and the flash message is displayed when all of authentications are failed.
         return next(); // pass and the flash message is displayed when all of authentications are failed.
       }
       }
       if (!user) { return next() }
       if (!user) { return next() }
@@ -227,7 +227,7 @@ module.exports = function(crowi, app) {
   const loginWithGoogle = function(req, res, next) {
   const loginWithGoogle = function(req, res, next) {
     if (!passportService.isGoogleStrategySetup) {
     if (!passportService.isGoogleStrategySetup) {
       debug('GoogleStrategy has not been set up');
       debug('GoogleStrategy has not been set up');
-      req.flash('warningMessage', 'GoogleStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GoogleStrategy' }));
       return next();
       return next();
     }
     }
 
 
@@ -296,7 +296,7 @@ module.exports = function(crowi, app) {
   const loginWithGitHub = function(req, res, next) {
   const loginWithGitHub = function(req, res, next) {
     if (!passportService.isGitHubStrategySetup) {
     if (!passportService.isGitHubStrategySetup) {
       debug('GitHubStrategy has not been set up');
       debug('GitHubStrategy has not been set up');
-      req.flash('warningMessage', 'GitHubStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'GitHubStrategy' }));
       return next();
       return next();
     }
     }
 
 
@@ -338,7 +338,7 @@ module.exports = function(crowi, app) {
   const loginWithTwitter = function(req, res, next) {
   const loginWithTwitter = function(req, res, next) {
     if (!passportService.isTwitterStrategySetup) {
     if (!passportService.isTwitterStrategySetup) {
       debug('TwitterStrategy has not been set up');
       debug('TwitterStrategy has not been set up');
-      req.flash('warningMessage', 'TwitterStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'TwitterStrategy' }));
       return next();
       return next();
     }
     }
 
 
@@ -380,7 +380,7 @@ module.exports = function(crowi, app) {
   const loginWithOidc = function(req, res, next) {
   const loginWithOidc = function(req, res, next) {
     if (!passportService.isOidcStrategySetup) {
     if (!passportService.isOidcStrategySetup) {
       debug('OidcStrategy has not been set up');
       debug('OidcStrategy has not been set up');
-      req.flash('warningMessage', 'OidcStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'OidcStrategy' }));
       return next();
       return next();
     }
     }
 
 
@@ -428,7 +428,7 @@ module.exports = function(crowi, app) {
   const loginWithSaml = function(req, res, next) {
   const loginWithSaml = function(req, res, next) {
     if (!passportService.isSamlStrategySetup) {
     if (!passportService.isSamlStrategySetup) {
       debug('SamlStrategy has not been set up');
       debug('SamlStrategy has not been set up');
-      req.flash('warningMessage', 'SamlStrategy has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'SamlStrategy' }));
       return next();
       return next();
     }
     }
 
 
@@ -496,7 +496,7 @@ module.exports = function(crowi, app) {
   const loginWithBasic = async(req, res, next) => {
   const loginWithBasic = async(req, res, next) => {
     if (!passportService.isBasicStrategySetup) {
     if (!passportService.isBasicStrategySetup) {
       debug('BasicStrategy has not been set up');
       debug('BasicStrategy has not been set up');
-      req.flash('warningMessage', 'Basic has not been set up');
+      req.flash('warningMessage', req.t('message.strategy_has_not_been_set_up', { strategy: 'Basic' }));
       return next();
       return next();
     }
     }
 
 
@@ -587,7 +587,7 @@ module.exports = function(crowi, app) {
         return;
         return;
       }
       }
       else if (err.name === 'UserUpperLimitException') {
       else if (err.name === 'UserUpperLimitException') {
-        req.flash('warningMessage', 'Can not register more than the maximum number of users.');
+        req.flash('warningMessage', req.t('message.maximum_number_of_users'));
         return;
         return;
       }
       }
       /* eslint-enable no-else-return */
       /* eslint-enable no-else-return */

+ 39 - 42
src/server/routes/login.js

@@ -7,7 +7,6 @@ module.exports = function(crowi, app) {
   const debug = require('debug')('growi:routes:login');
   const debug = require('debug')('growi:routes:login');
   const logger = require('@alias/logger')('growi:routes:login');
   const logger = require('@alias/logger')('growi:routes:login');
   const path = require('path');
   const path = require('path');
-  const async = require('async');
   const mailer = crowi.getMailer();
   const mailer = crowi.getMailer();
   const User = crowi.model('User');
   const User = crowi.model('User');
   const { configManager, appService, aclService } = crowi;
   const { configManager, appService, aclService } = crowi;
@@ -103,16 +102,16 @@ module.exports = function(crowi, app) {
         let isError = false;
         let isError = false;
         if (!User.isEmailValid(email)) {
         if (!User.isEmailValid(email)) {
           isError = true;
           isError = true;
-          req.flash('registerWarningMessage', 'This email address could not be used. (Make sure the allowed email address)');
+          req.flash('registerWarningMessage', req.t('message.email_address_could_not_be_used'));
         }
         }
         if (!isRegisterable) {
         if (!isRegisterable) {
           if (!errOn.username) {
           if (!errOn.username) {
             isError = true;
             isError = true;
-            req.flash('registerWarningMessage', 'This User ID is not available.');
+            req.flash('registerWarningMessage', req.t('message.user_id_is_not_available'));
           }
           }
           if (!errOn.email) {
           if (!errOn.email) {
             isError = true;
             isError = true;
-            req.flash('registerWarningMessage', 'This email address is already registered.');
+            req.flash('registerWarningMessage', req.t('message.email_address_is_already_registered'));
           }
           }
         }
         }
         if (isError) {
         if (isError) {
@@ -120,54 +119,26 @@ module.exports = function(crowi, app) {
           return res.redirect('/register');
           return res.redirect('/register');
         }
         }
 
 
-        User.createUserByEmailAndPassword(name, username, email, password, undefined, (err, userData) => {
+        User.createUserByEmailAndPassword(name, username, email, password, undefined, async(err, userData) => {
           if (err) {
           if (err) {
             if (err.name === 'UserUpperLimitException') {
             if (err.name === 'UserUpperLimitException') {
-              req.flash('registerWarningMessage', 'Can not register more than the maximum number of users.');
+              req.flash('registerWarningMessage', req.t('message.can_not_register_maximum_number_of_users'));
             }
             }
             else {
             else {
-              req.flash('registerWarningMessage', 'Failed to register.');
+              req.flash('registerWarningMessage', req.t('message.failed_to_register'));
             }
             }
             return res.redirect('/register');
             return res.redirect('/register');
           }
           }
 
 
-
-          // 作成後、承認が必要なモードなら、管理者に通知する
-          const appTitle = appService.getAppTitle();
-          if (configManager.getConfig('crowi', 'security:registrationMode') === aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
-            // TODO send mail
-            User.findAdmins((err, admins) => {
-              async.each(
-                admins,
-                (adminUser, next) => {
-                  mailer.send({
-                    to: adminUser.email,
-                    subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
-                    template: path.join(crowi.localeDir, 'en-US/admin/userWaitingActivation.txt'),
-                    vars: {
-                      createdUser: userData,
-                      adminUser,
-                      url: appService.getSiteUrl(),
-                      appTitle,
-                    },
-                  },
-                  (err, s) => {
-                    debug('completed to send email: ', err, s);
-                    next();
-                  });
-                },
-                (err) => {
-                  debug('Sending invitation email completed.', err);
-                },
-              );
-            });
+          if (configManager.getConfig('crowi', 'security:registrationMode') !== aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED) {
+            // send mail asynchronous
+            sendEmailToAllAdmins(userData);
           }
           }
 
 
-
           // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
           // add a flash message to inform the user that processing was successful -- 2017.09.23 Yuki Takei
           // cz. loginSuccess method doesn't work on it's own when using passport
           // cz. loginSuccess method doesn't work on it's own when using passport
           //      because `req.login()` prepared by passport is not called.
           //      because `req.login()` prepared by passport is not called.
-          req.flash('successMessage', `The user '${userData.username}' is successfully created.`);
+          req.flash('successMessage', req.t('message.successfully_created',{ username: userData.username }));
 
 
           return loginSuccess(req, res, userData);
           return loginSuccess(req, res, userData);
         });
         });
@@ -180,6 +151,32 @@ module.exports = function(crowi, app) {
     }
     }
   };
   };
 
 
+  async function sendEmailToAllAdmins(userData) {
+    // send mails to all admin users (derived from crowi) -- 2020.06.18 Yuki Takei
+    const admins = await User.findAdmins();
+
+    const appTitle = appService.getAppTitle();
+
+    const promises = admins.map((admin) => {
+      return mailer.send({
+        to: admin.email,
+        subject: `[${appTitle}:admin] A New User Created and Waiting for Activation`,
+        template: path.join(crowi.localeDir, 'en-US/admin/userWaitingActivation.txt'),
+        vars: {
+          createdUser: userData,
+          admin,
+          url: appService.getSiteUrl(),
+          appTitle,
+        },
+      });
+    })
+
+    const results = await Promise.allSettled(promises);
+    results
+      .filter(result => result.status === 'rejected')
+      .forEach(result => logger.error(result.reason));
+  }
+
   actions.invited = async function(req, res) {
   actions.invited = async function(req, res) {
     if (!req.user) {
     if (!req.user) {
       return res.redirect('/login');
       return res.redirect('/login');
@@ -195,7 +192,7 @@ module.exports = function(crowi, app) {
       // check user upper limit
       // check user upper limit
       const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
       const isUserCountExceedsUpperLimit = await User.isUserCountExceedsUpperLimit();
       if (isUserCountExceedsUpperLimit) {
       if (isUserCountExceedsUpperLimit) {
-        req.flash('warningMessage', 'ユーザーが上限に達したためアクティベートできません。');
+        req.flash('warningMessage', req.t('message.can_not_activate_maximum_number_of_users'));
         return res.redirect('/invited');
         return res.redirect('/invited');
       }
       }
 
 
@@ -206,12 +203,12 @@ module.exports = function(crowi, app) {
           return res.redirect('/');
           return res.redirect('/');
         }
         }
         catch (err) {
         catch (err) {
-          req.flash('warningMessage', 'アクティベートに失敗しました。');
+          req.flash('warningMessage', req.t('message.failed_to_activate'));
           return res.render('invited');
           return res.render('invited');
         }
         }
       }
       }
       else {
       else {
-        req.flash('warningMessage', '利用できないユーザーIDです。');
+        req.flash('warningMessage', req.t('message.unable_to_use_this_user'));
         debug('username', username);
         debug('username', username);
         return res.render('invited');
         return res.render('invited');
       }
       }

+ 6 - 0
src/server/service/config-loader.js

@@ -143,6 +143,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.STRING,
     type:    TYPES.STRING,
     default: null,
     default: null,
   },
   },
+  ELASTICSEARCH_REQUEST_TIMEOUT: {
+    ns:      'crowi',
+    key:     'app:elasticsearchRequestTimeout',
+    type:    TYPES.NUMBER,
+    default: 8000, // msec
+  },
   SEARCHBOX_SSL_URL: {
   SEARCHBOX_SSL_URL: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'app:searchboxSslUrl',
     key:     'app:searchboxSslUrl',

+ 1 - 1
src/server/service/search-delegator/elasticsearch.js

@@ -59,7 +59,7 @@ class ElasticsearchDelegator {
     this.client = new elasticsearch.Client({
     this.client = new elasticsearch.Client({
       host,
       host,
       httpAuth,
       httpAuth,
-      requestTimeout: 5000,
+      requestTimeout: this.configManager.getConfig('crowi', 'app:elasticsearchRequestTimeout'),
       // log: 'debug',
       // log: 'debug',
     });
     });
     this.indexName = indexName;
     this.indexName = indexName;

+ 11 - 17
src/server/util/mailer.js

@@ -93,25 +93,19 @@ module.exports = function(crowi) {
     return mc;
     return mc;
   }
   }
 
 
-  function send(config, callback = () => {}) {
-    if (mailer) {
-      const templateVars = config.vars || {};
-      return swig.renderFile(
-        config.template,
-        templateVars,
-        (err, output) => {
-          if (err) {
-            throw err;
-          }
-
-          config.text = output;
-          return mailer.sendMail(setupMailConfig(config), callback);
-        },
-      );
+  async function send(config) {
+    if (mailer == null) {
+      throw new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
     }
     }
 
 
-    logger.debug('Mailer is not completed to set up. Please set up SMTP or AWS setting.');
-    return callback(new Error('Mailer is not completed to set up. Please set up SMTP or AWS setting.'), null);
+    const templateVars = config.vars || {};
+    const output = await swig.renderFile(
+      config.template,
+      templateVars,
+    );
+
+    config.text = output;
+    return mailer.sendMail(setupMailConfig(config));
   }
   }
 
 
 
 

+ 2 - 2
src/server/util/middlewares.js

@@ -163,7 +163,7 @@ module.exports = (crowi) => {
     const isInstalled = await appService.isDBInitialized();
     const isInstalled = await appService.isDBInitialized();
 
 
     if (isInstalled) {
     if (isInstalled) {
-      req.flash('errorMessage', 'Application already installed.');
+      req.flash('errorMessage', req.t('message.application_already_installed'));
       return res.redirect('admin'); // admin以外はadminRequiredで'/'にリダイレクトされる
       return res.redirect('admin'); // admin以外はadminRequiredで'/'にリダイレクトされる
     }
     }
 
 
@@ -186,7 +186,7 @@ module.exports = (crowi) => {
           && configManager.getConfig('crowi', 'aws:bucket') !== ''
           && configManager.getConfig('crowi', 'aws:bucket') !== ''
           && configManager.getConfig('crowi', 'aws:accessKeyId') !== ''
           && configManager.getConfig('crowi', 'aws:accessKeyId') !== ''
           && configManager.getConfig('crowi', 'aws:secretAccessKey') !== '') {
           && configManager.getConfig('crowi', 'aws:secretAccessKey') !== '') {
-        req.flash('globalError', 'AWS settings required to use this function. Please ask the administrator.');
+        req.flash('globalError', req.t('message.aws_sttings_required'));
         return res.redirect('/');
         return res.redirect('/');
       }
       }
 
 

+ 4 - 2
src/server/views/installer.html

@@ -19,6 +19,8 @@
 
 
   {% include './widget/headers/scripts-for-dev.html' %}
   {% include './widget/headers/scripts-for-dev.html' %}
 
 
+  <script src="{{ webpack_asset('js/boot.js') }}"></script>
+
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
 
 
@@ -54,10 +56,10 @@
             <div class="logo">{% include 'widget/logo.html' %}</div>
             <div class="logo">{% include 'widget/logo.html' %}</div>
             <h1 class="my-3">GROWI</h1>
             <h1 class="my-3">GROWI</h1>
 
 
-            <div class="login-form-errors">
+            <div class="login-form-errors px-3">
               {% if req.form.errors.length > 0 %}
               {% if req.form.errors.length > 0 %}
               <div class="alert alert-danger">
               <div class="alert alert-danger">
-                <ul>
+                <ul class="mb-0">
                 {% for error in req.form.errors %}
                 {% for error in req.form.errors %}
                   <li>{{ error }}</li>
                   <li>{{ error }}</li>
                 {% endfor %}
                 {% endfor %}

+ 2 - 0
src/server/views/layout/layout.html

@@ -25,6 +25,8 @@
 
 
   {% include '../widget/headers/scripts-for-dev.html' %}
   {% include '../widget/headers/scripts-for-dev.html' %}
 
 
+  <script src="{{ webpack_asset('js/boot.js') }}"></script>
+
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/vendors.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
   <script src="{{ webpack_asset('js/commons.js') }}" defer></script>
   {% if getConfig('crowi', 'plugin:isEnabledPlugins') %}
   {% if getConfig('crowi', 'plugin:isEnabledPlugins') %}

+ 82 - 26
yarn.lock

@@ -2638,11 +2638,6 @@ async@^2.6.3:
   dependencies:
   dependencies:
     lodash "^4.17.14"
     lodash "^4.17.14"
 
 
-async@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/async/-/async-3.0.1.tgz#dfeb34657d1e63c94c0eee424297bf8a2c9a8182"
-  integrity sha512-ZswD8vwPtmBZzbn9xyi8XBQWXH3AvOQ43Za1KWYq7JeycrZuUYzx01KvHcVbXltjqH4y0MWrQ33008uLTqXuDw==
-
 async@~0.2.6:
 async@~0.2.6:
   version "0.2.10"
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
@@ -3984,6 +3979,16 @@ commander@^3.0.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.1.tgz#4595aec3530525e671fb6f85fb173df8ff8bf57a"
   resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.1.tgz#4595aec3530525e671fb6f85fb173df8ff8bf57a"
   integrity sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ==
   integrity sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ==
 
 
+commander@^4.0.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
+  integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
+
+commander@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
+  integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
+
 commondir@^1.0.1:
 commondir@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@@ -4653,6 +4658,16 @@ date-fns@^2.0.0:
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
   resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.0.0.tgz#52f05c6ae1fe0e395670082c72b690ab781682d0"
   integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
   integrity sha512-nGZDA64Ktq5uTWV4LEH3qX+foV4AguT5qxwRlJDzJtf57d4xLNwtwrfb7SzKCoikoae8Bvxf0zdaEG/xWssp/w==
 
 
+date-format@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
+  integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
+
+date-format@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-3.0.0.tgz#eb8780365c7d2b1511078fb491e6479780f3ad95"
+  integrity sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==
+
 date-now@^0.1.4:
 date-now@^0.1.4:
   version "0.1.4"
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
   resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
@@ -4982,22 +4997,17 @@ dotenv-expand@>=5.1.0:
   resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
   resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
   integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==
   integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==
 
 
-dotenv@>=8.2.0:
+dotenv@>=8.2.0, dotenv@^8.2.0:
   version "8.2.0"
   version "8.2.0"
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
   resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.2.0.tgz#97e619259ada750eea3e4ea3e26bceea5424b16a"
   integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
   integrity sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==
 
 
-dtrace-provider@^0.7.0:
-  version "0.7.1"
-  resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.7.1.tgz#c06b308f2f10d5d5838aec9c571e5d588dc71d04"
-  dependencies:
-    nan "^2.3.3"
-
 dtrace-provider@~0.8:
 dtrace-provider@~0.8:
-  version "0.8.5"
-  resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.5.tgz#98ebba221afac46e1c39fd36858d8f9367524b92"
+  version "0.8.8"
+  resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e"
+  integrity sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==
   dependencies:
   dependencies:
-    nan "^2.3.3"
+    nan "^2.14.0"
 
 
 duplexer2@~0.1.4:
 duplexer2@~0.1.4:
   version "0.1.4"
   version "0.1.4"
@@ -5284,6 +5294,14 @@ env-cmd@^10.0.1:
     commander "^3.0.0"
     commander "^3.0.0"
     cross-spawn "^6.0.0"
     cross-spawn "^6.0.0"
 
 
+env-cmd@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/env-cmd/-/env-cmd-10.1.0.tgz#c7f5d3b550c9519f137fdac4dd8fb6866a8c8c4b"
+  integrity sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==
+  dependencies:
+    commander "^4.0.0"
+    cross-spawn "^7.0.0"
+
 errno@^0.1.3:
 errno@^0.1.3:
   version "0.1.6"
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.6.tgz#c386ce8a6283f14fc09563b71560908c9bf53026"
   resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.6.tgz#c386ce8a6283f14fc09563b71560908c9bf53026"
@@ -6213,6 +6231,11 @@ flatted@^2.0.0:
   version "2.0.0"
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916"
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916"
 
 
+flatted@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
+  integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
+
 flatten@^1.0.2:
 flatten@^1.0.2:
   version "1.0.2"
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
   resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
@@ -6350,7 +6373,7 @@ fs-extra@3.0.1:
     jsonfile "^3.0.0"
     jsonfile "^3.0.0"
     universalify "^0.1.0"
     universalify "^0.1.0"
 
 
-fs-extra@8.1.0:
+fs-extra@8.1.0, fs-extra@^8.1.0:
   version "8.1.0"
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
   integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
   integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
@@ -8608,8 +8631,9 @@ ldapauth-fork@^4.0.1:
     lru-cache "^4.0.2"
     lru-cache "^4.0.2"
 
 
 ldapjs@^1.0.1:
 ldapjs@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/ldapjs/-/ldapjs-1.0.1.tgz#352b812ae74b0a8e96549a4b896060eee1b9a546"
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/ldapjs/-/ldapjs-1.0.2.tgz#544ff7032b7b83c68f0701328d9297aa694340f9"
+  integrity sha1-VE/3Ayt7g8aPBwEyjZKXqmlDQPk=
   dependencies:
   dependencies:
     asn1 "0.2.3"
     asn1 "0.2.3"
     assert-plus "^1.0.0"
     assert-plus "^1.0.0"
@@ -8621,7 +8645,7 @@ ldapjs@^1.0.1:
     vasync "^1.6.4"
     vasync "^1.6.4"
     verror "^1.8.1"
     verror "^1.8.1"
   optionalDependencies:
   optionalDependencies:
-    dtrace-provider "^0.7.0"
+    dtrace-provider "~0.8"
 
 
 leven@^2.1.0:
 leven@^2.1.0:
   version "2.1.0"
   version "2.1.0"
@@ -8869,6 +8893,17 @@ log-symbols@^3.0.0:
   dependencies:
   dependencies:
     chalk "^2.4.2"
     chalk "^2.4.2"
 
 
+log4js@^6.3.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.3.0.tgz#10dfafbb434351a3e30277a00b9879446f715bcb"
+  integrity sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==
+  dependencies:
+    date-format "^3.0.0"
+    debug "^4.1.1"
+    flatted "^2.0.1"
+    rfdc "^1.1.4"
+    streamroller "^2.2.4"
+
 lolex@^5.0.0:
 lolex@^5.0.0:
   version "5.1.2"
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/lolex/-/lolex-5.1.2.tgz#953694d098ce7c07bc5ed6d0e42bc6c0c6d5a367"
   resolved "https://registry.yarnpkg.com/lolex/-/lolex-5.1.2.tgz#953694d098ce7c07bc5ed6d0e42bc6c0c6d5a367"
@@ -9668,15 +9703,11 @@ mv@~2:
     ncp "~2.0.0"
     ncp "~2.0.0"
     rimraf "~2.4.0"
     rimraf "~2.4.0"
 
 
-nan@^2.12.1, nan@^2.13.2:
+nan@^2.12.1, nan@^2.13.2, nan@^2.14.0:
   version "2.14.1"
   version "2.14.1"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
   integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
 
 
-nan@^2.3.3:
-  version "2.8.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.8.0.tgz#ed715f3fe9de02b57a5e6252d90a96675e1f085a"
-
 nanomatch@^1.2.9:
 nanomatch@^1.2.9:
   version "1.2.9"
   version "1.2.9"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.9.tgz#879f7150cb2dab7a471259066c104eee6e0fa7c2"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.9.tgz#879f7150cb2dab7a471259066c104eee6e0fa7c2"
@@ -9892,7 +9923,7 @@ node-releases@^1.1.49:
   dependencies:
   dependencies:
     semver "^6.3.0"
     semver "^6.3.0"
 
 
-node-sass@^4.12.0:
+node-sass@^4.14.1:
   version "4.14.1"
   version "4.14.1"
   resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.14.1.tgz#99c87ec2efb7047ed638fb4c9db7f3a42e2217b5"
   resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.14.1.tgz#99c87ec2efb7047ed638fb4c9db7f3a42e2217b5"
   integrity sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==
   integrity sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==
@@ -12420,7 +12451,7 @@ request@^2.74.0:
     tunnel-agent "^0.6.0"
     tunnel-agent "^0.6.0"
     uuid "^3.1.0"
     uuid "^3.1.0"
 
 
-request@^2.87.0, request@^2.88.0:
+request@^2.87.0, request@^2.88.0, request@^2.88.2:
   version "2.88.2"
   version "2.88.2"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
   integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
   integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -12610,6 +12641,11 @@ reveal.js@^3.5.0:
   version "3.6.0"
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/reveal.js/-/reveal.js-3.6.0.tgz#ce0e64f30cbebd6e5ce885c2f384085c5e5821e8"
   resolved "https://registry.yarnpkg.com/reveal.js/-/reveal.js-3.6.0.tgz#ce0e64f30cbebd6e5ce885c2f384085c5e5821e8"
 
 
+rfdc@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.1.4.tgz#ba72cc1367a0ccd9cf81a870b3b58bd3ad07f8c2"
+  integrity sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==
+
 rgb-regex@^1.0.1:
 rgb-regex@^1.0.1:
   version "1.0.1"
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1"
   resolved "https://registry.yarnpkg.com/rgb-regex/-/rgb-regex-1.0.1.tgz#c0e0d6882df0e23be254a475e8edd41915feaeb1"
@@ -12667,6 +12703,17 @@ rndm@1.2.0:
   version "1.2.0"
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c"
   resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c"
 
 
+rs-i18n@^0.0.9:
+  version "0.0.9"
+  resolved "https://registry.yarnpkg.com/rs-i18n/-/rs-i18n-0.0.9.tgz#9975aa4ae8c3a7dee2e39b96d1a4b2929261ce4f"
+  integrity sha512-wPg9gaNHpRiuSJm5hJNO2zwqOmC9lRkdp3mr768DuCU3l+Ws+xk5Ut7+x2ekTpJJFe4WJuB/TwGWzZ0cmJ6jUQ==
+  dependencies:
+    commander "^5.1.0"
+    dotenv "^8.2.0"
+    env-cmd "^10.1.0"
+    log4js "^6.3.0"
+    request "^2.88.2"
+
 rsvp@^4.8.4:
 rsvp@^4.8.4:
   version "4.8.5"
   version "4.8.5"
   resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
   resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
@@ -13589,6 +13636,15 @@ stream-to-promise@^2.2.0:
     end-of-stream "~1.1.0"
     end-of-stream "~1.1.0"
     stream-to-array "~2.3.0"
     stream-to-array "~2.3.0"
 
 
+streamroller@^2.2.4:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-2.2.4.tgz#c198ced42db94086a6193608187ce80a5f2b0e53"
+  integrity sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==
+  dependencies:
+    date-format "^2.1.0"
+    debug "^4.1.1"
+    fs-extra "^8.1.0"
+
 streamsearch@0.1.2:
 streamsearch@0.1.2:
   version "0.1.2"
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
   resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio