Просмотр исходного кода

Merge branch 'master' into feat/grid-edit-modal-for-master-merge

ryuichi-e 5 лет назад
Родитель
Сommit
2df7461d45
100 измененных файлов с 3291 добавлено и 652 удалено
  1. 1 1
      .devcontainer/Dockerfile
  2. 4 4
      .github/workflows/ci.yml
  3. 19 1
      .vscode/launch.json
  4. 47 2
      CHANGES.md
  5. 1 1
      README.md
  6. 2 1
      config/logger/config.dev.js
  7. 2 0
      config/migrate.js
  8. 0 1
      config/webpack.common.js
  9. 2 2
      docker/Dockerfile
  10. 9 4
      package.json
  11. 1 1
      resource/locales/en_US/admin/admin.json
  12. 0 0
      resource/locales/en_US/admin/userInvitation.txt
  13. 0 0
      resource/locales/en_US/admin/userWaitingActivation.txt
  14. 4 0
      resource/locales/en_US/meta.json
  15. 0 0
      resource/locales/en_US/notifications/comment.txt
  16. 0 0
      resource/locales/en_US/notifications/pageCreate.txt
  17. 0 0
      resource/locales/en_US/notifications/pageDelete.txt
  18. 0 0
      resource/locales/en_US/notifications/pageEdit.txt
  19. 0 0
      resource/locales/en_US/notifications/pageLike.txt
  20. 0 0
      resource/locales/en_US/notifications/pageMove.txt
  21. 0 0
      resource/locales/en_US/sandbox-bootstrap4.md
  22. 0 0
      resource/locales/en_US/sandbox-diagrams.md
  23. 1 1
      resource/locales/en_US/sandbox-math.md
  24. 0 0
      resource/locales/en_US/sandbox.md
  25. 22 13
      resource/locales/en_US/translation.json
  26. 0 0
      resource/locales/en_US/welcome.md
  27. 1 1
      resource/locales/index.js
  28. 0 0
      resource/locales/ja_JP/admin/admin.json
  29. 0 0
      resource/locales/ja_JP/admin/userInvitation.txt
  30. 0 0
      resource/locales/ja_JP/admin/userWaitingActivation.txt
  31. 4 0
      resource/locales/ja_JP/meta.json
  32. 0 0
      resource/locales/ja_JP/notifications/comment.txt
  33. 0 0
      resource/locales/ja_JP/notifications/pageCreate.txt
  34. 0 0
      resource/locales/ja_JP/notifications/pageDelete.txt
  35. 0 0
      resource/locales/ja_JP/notifications/pageEdit.txt
  36. 0 0
      resource/locales/ja_JP/notifications/pageLike.txt
  37. 0 0
      resource/locales/ja_JP/notifications/pageMove.txt
  38. 0 0
      resource/locales/ja_JP/sandbox-bootstrap4.md
  39. 0 0
      resource/locales/ja_JP/sandbox-diagrams.md
  40. 1 1
      resource/locales/ja_JP/sandbox-math.md
  41. 0 0
      resource/locales/ja_JP/sandbox.md
  42. 12 3
      resource/locales/ja_JP/translation.json
  43. 0 0
      resource/locales/ja_JP/welcome.md
  44. 318 0
      resource/locales/zh_CN/admin/admin.json
  45. 14 0
      resource/locales/zh_CN/admin/userInvitation.txt
  46. 21 0
      resource/locales/zh_CN/admin/userWaitingActivation.txt
  47. 4 0
      resource/locales/zh_CN/meta.json
  48. 9 0
      resource/locales/zh_CN/notifications/comment.txt
  49. 5 0
      resource/locales/zh_CN/notifications/pageCreate.txt
  50. 5 0
      resource/locales/zh_CN/notifications/pageDelete.txt
  51. 5 0
      resource/locales/zh_CN/notifications/pageEdit.txt
  52. 5 0
      resource/locales/zh_CN/notifications/pageLike.txt
  53. 5 0
      resource/locales/zh_CN/notifications/pageMove.txt
  54. 253 0
      resource/locales/zh_CN/sandbox-bootstrap4.md
  55. 7 0
      resource/locales/zh_CN/sandbox-diagrams.md
  56. 71 0
      resource/locales/zh_CN/sandbox-math.md
  57. 457 0
      resource/locales/zh_CN/sandbox.md
  58. 733 0
      resource/locales/zh_CN/translation.json
  59. 27 0
      resource/locales/zh_CN/welcome.md
  60. 9 0
      rs-i18n.env
  61. 6 10
      src/client/js/app.jsx
  62. 7 10
      src/client/js/base.jsx
  63. 51 34
      src/client/js/components/Admin/App/AppSetting.jsx
  64. 3 3
      src/client/js/components/Admin/Common/AdminNavigation.jsx
  65. 140 0
      src/client/js/components/Admin/Security/OidcSecuritySetting.jsx
  66. 34 26
      src/client/js/components/Admin/Users/PasswordResetModal.jsx
  67. 1 1
      src/client/js/components/Admin/Users/UserInviteModal.jsx
  68. 5 1
      src/client/js/components/Drawio.jsx
  69. 67 0
      src/client/js/components/Fab.jsx
  70. 31 37
      src/client/js/components/InstallerForm.jsx
  71. 19 26
      src/client/js/components/Me/BasicInfoSettings.jsx
  72. 4 1
      src/client/js/components/Me/PasswordSettings.jsx
  73. 10 5
      src/client/js/components/Me/PersonalSettings.jsx
  74. 15 11
      src/client/js/components/Navbar/DrawerToggler.jsx
  75. 11 31
      src/client/js/components/Navbar/GlobalSearch.jsx
  76. 107 0
      src/client/js/components/Navbar/GrowiNavbar.jsx
  77. 61 0
      src/client/js/components/Navbar/GrowiNavbarBottom.jsx
  78. 136 36
      src/client/js/components/Navbar/GrowiSubNavigation.jsx
  79. 0 92
      src/client/js/components/Navbar/GrowiSubNavigationForUserPage.jsx
  80. 88 0
      src/client/js/components/Navbar/GrowiSubNavigationSwitcher.jsx
  81. 0 41
      src/client/js/components/Navbar/PageCreateButton.jsx
  82. 1 1
      src/client/js/components/Navbar/PageCreator.jsx
  83. 6 1
      src/client/js/components/Navbar/RevisionAuthor.jsx
  84. 13 2
      src/client/js/components/NotAvailableForGuest.jsx
  85. 69 0
      src/client/js/components/Page/RenderTagLabels.jsx
  86. 62 0
      src/client/js/components/Page/TagEditModal.jsx
  87. 0 71
      src/client/js/components/Page/TagEditor.jsx
  88. 63 92
      src/client/js/components/Page/TagLabels.jsx
  89. 1 1
      src/client/js/components/PageDeleteModal.jsx
  90. 1 1
      src/client/js/components/PageDuplicateModal.jsx
  91. 3 1
      src/client/js/components/PageEditor.jsx
  92. 78 0
      src/client/js/components/PageEditor/EditorNavbarBottom.jsx
  93. 40 19
      src/client/js/components/PageEditor/OptionsSelector.jsx
  94. 2 2
      src/client/js/components/PageEditor/PagePathNavForEditor.jsx
  95. 4 2
      src/client/js/components/PageEditor/Preview.jsx
  96. 4 6
      src/client/js/components/PageManagement/ApiErrorMessage.jsx
  97. 1 1
      src/client/js/components/PageRenameModal.jsx
  98. 56 46
      src/client/js/components/PageStatusAlert.jsx
  99. 1 1
      src/client/js/components/PutbackPageModal.jsx
  100. 9 3
      src/client/js/components/SavePageControls/GrantSelector.jsx

+ 1 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-12
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:14
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update

+ 4 - 4
.github/workflows/ci.yml

@@ -13,7 +13,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -68,7 +68,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -129,7 +129,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [12.x]
+        node-version: [14.x]
 
     steps:
     - uses: actions/checkout@v2
@@ -200,7 +200,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [10.x, 12.x]
+        node-version: [12.x, 14.x]
 
     steps:
     - uses: actions/checkout@v2

+ 19 - 1
.vscode/launch.json

@@ -29,11 +29,29 @@
         "request": "launch",
         "name": "Debug: Chrome",
         "sourceMaps": true,
-        "webRoot": "${workspaceFolder}/public",
         "sourceMapPathOverrides": {
           "webpack:///*": "${workspaceFolder}/*"
         },
+        "webRoot": "${workspaceFolder}/public",
         "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"
+          }
+        ]
       }
     ]
 }

+ 47 - 2
CHANGES.md

@@ -1,6 +1,51 @@
 # CHANGES
 
-## v4.0.7-RC
+## v4.1.0-RC
+
+### BREAKING CHANGES
+
+* GROWI v4.1.x no longer support Node.js v10.x
+* GROWI v4.1.x no longer support growi-plugin-attachment-refs@v1
+
+Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/41x.html>
+
+### Updates
+
+* Support: Support Node.js v14
+
+
+
+## v4.0.10
+
+* Improvement: Adjust ToC height
+* Fix: Fail to rename/delete a page set as "Anyone with the link"
+
+
+## v4.0.9
+
+* Feature: Detailed configurations for OpenID Connect
+    * Authorization Endpoint
+    * Token Endpoint
+    * Revocation Endpoint
+    * Introspection Endpoint
+    * UserInfo Endpoint
+    * Registration Endpoint
+    * JSON Web Key Set URI
+* Improvement: Navigations
+    * New floating subnavigation
+    * New open drawer button
+    * New fixed bottom navbar on mobile
+    * New fixed bottom navbar for editor on mobile
+    * FAB (Floating action button)
+* Improvement: Sticky admin navigation
+* Fix: Reseting password doesn't work
+* Fix: Styles for printing
+* Fix: Unable to create page with original path after emptying trash
+* I18n: Support zh-CN
+
+## v4.0.8  (Missing number)
+
+## v4.0.7
 
 * Feature: Set request timeout for Elasticsearch with env var `ELASTICSEARCH_REQUEST_TIMEOUT`
 * Improvement: Apply styles faster on booting client
@@ -1353,7 +1398,7 @@ Upgrading Guide: <https://docs.growi.org/en/admin-guide/upgrading/34x.html>
 
 * Fix: The problem that path including round bracket makes something bad
 * Fix: Recursively option processes also unexpedted pages
-* Fix: en-US translation
+* Fix: en_US translation
 
 ## v2.0.7
 

+ 1 - 1
README.md

@@ -92,7 +92,7 @@ Development
 
 ## Dependencies
 
-- Node.js v12.x (DON'T USE 13.x)
+- Node.js v12.x or v14.x
 - npm 6.x
 - yarn
 - MongoDB 3.x

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

@@ -34,5 +34,6 @@ module.exports = {
   'growi:cli:app': 'debug',
   'growi:services:*': 'debug',
   // 'growi:StaffCredit': 'debug',
-  // 'growi:TableOfContents': 'debug',
+  // 'growi:cli:StickyStretchableScroller': 'debug',
+
 };

+ 2 - 0
config/migrate.js

@@ -21,6 +21,8 @@ const mongodb = {
   databaseName: url.pathname.substring(1), // omit heading slash
   options: {
     useNewUrlParser: true, // removes a deprecation warning when connecting
+    useUnifiedTopology: true,
+    useFindAndModify: false,
   },
 };
 

+ 0 - 1
config/webpack.common.js

@@ -69,7 +69,6 @@ module.exports = (options) => {
         '@client': helpers.root('src/client'),
         '@tmp': helpers.root('tmp'),
         '@alias/logger': helpers.root('src/lib/service/logger'),
-        '@alias/locales': helpers.root('resource/locales'),
         // replace bunyan
         bunyan: 'browser-bunyan',
       },

+ 2 - 2
docker/Dockerfile

@@ -7,7 +7,7 @@ ARG flavor=default
 ##
 ## deps-resolver
 ##
-FROM node:12-slim AS deps-resolver
+FROM node:14-slim AS deps-resolver
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/usr/local/share/.cache/yarn \
 ##
 ## prebuilder-default
 ##
-FROM node:12-slim AS prebuilder-default
+FROM node:14-slim AS prebuilder-default
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV appDir /opt/growi

+ 9 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.0.7-RC",
+  "version": "4.1.0-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -54,6 +54,9 @@
     "preserver:prod": "npm run migrate",
     "prestart": "npm run build:prod",
     "resource": "node bin/download-cdn-resources.js",
+    "i18n-json-merge:withTran": "rs-i18n -lan -- -t",
+    "i18n-json-merge:noTran": "rs-i18n -lan --",
+    "i18n-json-merge": "npm run i18n-json-merge:withTran --",
     "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:prod:ci": "npm run server:prod -- --ci",
@@ -100,7 +103,7 @@
     "express-validator": "^6.1.1",
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
-    "growi-commons": "^4.0.8",
+    "growi-commons": "^5.0.3",
     "helmet": "^3.13.0",
     "i18next": "^19.0.0",
     "i18next-express-middleware": "^1.4.1",
@@ -181,10 +184,11 @@
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.0.2",
     "eslint": "^6.0.1",
-    "eslint-config-weseek": "^1.0.3",
+    "eslint-config-weseek": "^1.0.4",
     "eslint-plugin-import": "^2.18.0",
     "eslint-plugin-jest": "^23.0.3",
     "eslint-plugin-react": "^7.14.2",
+    "eslint-plugin-react-hooks": "^4.0.4",
     "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "hard-source-webpack-plugin": "^0.13.1",
@@ -232,6 +236,7 @@
     "reactstrap": "^8.0.1",
     "replacestream": "^4.0.3",
     "reveal.js": "^3.5.0",
+    "rs-i18n": "^0.0.9",
     "sass-loader": "^8.0.0",
     "simple-load-script": "^1.0.2",
     "socket.io-client": "^2.0.3",
@@ -260,7 +265,7 @@
     "debug": "src/lib/service/logger/alias-for-debug"
   },
   "engines": {
-    "node": ">=10.17.0 <13",
+    "node": "^12 || ^14",
     "npm": ">=6.11.3 <7",
     "yarn": ">=1.19.1 <2"
   }

+ 1 - 1
resource/locales/en-US/admin/admin.json → resource/locales/en_US/admin/admin.json

@@ -223,7 +223,7 @@
     "invite_users": "Invite new users",
     "click_twice_same_checkbox": "You should check at least one checkbox.",
     "invite_modal": {
-      "emails": "Emails",
+      "emails": "Emails (Possible to invite multiple people with new lines)",
       "invite_thru_email": "Send invitation email",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",

+ 0 - 0
resource/locales/en-US/admin/userInvitation.txt → resource/locales/en_US/admin/userInvitation.txt


+ 0 - 0
resource/locales/en-US/admin/userWaitingActivation.txt → resource/locales/en_US/admin/userWaitingActivation.txt


+ 4 - 0
resource/locales/en_US/meta.json

@@ -0,0 +1,4 @@
+{
+  "id": "en_US",
+  "displayName": "English"
+}

+ 0 - 0
resource/locales/en-US/notifications/comment.txt → resource/locales/en_US/notifications/comment.txt


+ 0 - 0
resource/locales/en-US/notifications/pageCreate.txt → resource/locales/en_US/notifications/pageCreate.txt


+ 0 - 0
resource/locales/en-US/notifications/pageDelete.txt → resource/locales/en_US/notifications/pageDelete.txt


+ 0 - 0
resource/locales/en-US/notifications/pageEdit.txt → resource/locales/en_US/notifications/pageEdit.txt


+ 0 - 0
resource/locales/en-US/notifications/pageLike.txt → resource/locales/en_US/notifications/pageLike.txt


+ 0 - 0
resource/locales/en-US/notifications/pageMove.txt → resource/locales/en_US/notifications/pageMove.txt


+ 0 - 0
resource/locales/en-US/sandbox-bootstrap4.md → resource/locales/en_US/sandbox-bootstrap4.md


+ 0 - 0
resource/locales/en-US/sandbox-diagrams.md → resource/locales/en_US/sandbox-diagrams.md


+ 1 - 1
resource/locales/en-US/sandbox-math.md → resource/locales/en_US/sandbox-math.md

@@ -4,7 +4,7 @@ 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
+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

+ 0 - 0
resource/locales/en-US/sandbox.md → resource/locales/en_US/sandbox.md


+ 22 - 13
resource/locales/en-US/translation.json → resource/locales/en_US/translation.json

@@ -119,7 +119,6 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
-  "Edit tags for this page": "Edit tags for this page",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Load latest": "Load latest",
@@ -179,16 +178,16 @@
   },
   "Password": "Password",
   "Password Settings": "Password settings",
-    "personal_settings": {
-    "disassociate_external_account": "Disassociate External Account",
-    "disassociate_external_account_desc": "Are you sure to disassociate the <strong>{{providerType}}</strong> account <strong>{{accountId}}</strong>?",
-    "set_new_password": "Set new Password",
-    "update_password": "Update password",
-      "current_password": "Current password",
-      "new_password": "New password",
-      "new_password_confirm": "Re-enter new password",
-      "password_is_not_set": "Password is not set"
-    },
+  "personal_settings": {
+  "disassociate_external_account": "Disassociate External Account",
+  "disassociate_external_account_desc": "Are you sure to disassociate the <strong>{{providerType}}</strong> account <strong>{{accountId}}</strong>?",
+  "set_new_password": "Set new Password",
+  "update_password": "Update password",
+    "current_password": "Current password",
+    "new_password": "New password",
+    "new_password_confirm": "Re-enter new password",
+    "password_is_not_set": "Password is not set"
+  },
   "security_settings": "Security settings",
   "API Settings": "API settings",
   "API Token Settings": "API token settings",
@@ -341,7 +340,8 @@
     "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}} "
+    "remove_external_user_success": "Succeeded to remove {{accountId}} ",
+    "failed_to_reset_password":"Failed to reset password"
   },
   "template": {
     "modal_label": {
@@ -450,6 +450,14 @@
     "issuerHost": "Issuer Host",
     "scope": "Scope",
     "desc_of_callback_URL": "Use it in the setting of the {{AuthName}} Identity provider",
+    "authorization_endpoint": "Authorization Endpoint",
+    "token_endpoint": "Token Endpoint",
+    "revocation_endpoint": "Revocation Endpoint",
+    "introspection_endpoint": "Introspection Endpoint",
+    "userinfo_endpoint": "UserInfo Endpoint",
+    "end_session_endpoint": "EndSessioin Endpoint",
+    "registration_endpoint": "Registration Endpoint",
+    "jwks_uri": "JSON Web Key Set URL",
     "clientID": "Client ID",
     "client_secret": "Client Secret",
     "updated_general_security_setting": "Succeeded to update security setting",
@@ -575,7 +583,8 @@
         "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"
+        "updated_oidc": "Succeeded to update OpenID Connect",
+        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
       },
       "how_to": {
         "google": "How to configure Google OAuth?",

+ 0 - 0
resource/locales/en-US/welcome.md → resource/locales/en_US/welcome.md


+ 1 - 1
resource/locales/index.js

@@ -1,2 +1,2 @@
-// !!DO NOT REMOVE THIS FILE!!
+// !!DO NOT EDIT/REMOVE THIS FILE!!
 // entry point for @alienfast/i18next-loader

+ 0 - 0
resource/locales/ja/admin/admin.json → resource/locales/ja_JP/admin/admin.json


+ 0 - 0
resource/locales/ja/admin/userInvitation.txt → resource/locales/ja_JP/admin/userInvitation.txt


+ 0 - 0
resource/locales/ja/admin/userWaitingActivation.txt → resource/locales/ja_JP/admin/userWaitingActivation.txt


+ 4 - 0
resource/locales/ja_JP/meta.json

@@ -0,0 +1,4 @@
+{
+  "id": "ja_JP",
+  "displayName": "日本語"
+}

+ 0 - 0
resource/locales/ja/notifications/comment.txt → resource/locales/ja_JP/notifications/comment.txt


+ 0 - 0
resource/locales/ja/notifications/pageCreate.txt → resource/locales/ja_JP/notifications/pageCreate.txt


+ 0 - 0
resource/locales/ja/notifications/pageDelete.txt → resource/locales/ja_JP/notifications/pageDelete.txt


+ 0 - 0
resource/locales/ja/notifications/pageEdit.txt → resource/locales/ja_JP/notifications/pageEdit.txt


+ 0 - 0
resource/locales/ja/notifications/pageLike.txt → resource/locales/ja_JP/notifications/pageLike.txt


+ 0 - 0
resource/locales/ja/notifications/pageMove.txt → resource/locales/ja_JP/notifications/pageMove.txt


+ 0 - 0
resource/locales/ja/sandbox-bootstrap4.md → resource/locales/ja_JP/sandbox-bootstrap4.md


+ 0 - 0
resource/locales/ja/sandbox-diagrams.md → resource/locales/ja_JP/sandbox-diagrams.md


+ 1 - 1
resource/locales/ja/sandbox-math.md → resource/locales/ja_JP/sandbox-math.md

@@ -4,7 +4,7 @@ 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
+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

+ 0 - 0
resource/locales/ja/sandbox.md → resource/locales/ja_JP/sandbox.md


+ 12 - 3
resource/locales/ja/translation.json → resource/locales/ja_JP/translation.json

@@ -118,7 +118,6 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
-  "Edit tags for this page": "タグを編集する",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -342,7 +341,8 @@
     "activate_user_success": "{{username}}を有効化しました",
     "deactivate_user_success": "{{username}}を無効化しました",
     "remove_user_success": "{{username}}を削除しました",
-    "remove_external_user_success": "{{accountId}}を削除しました"
+    "remove_external_user_success": "{{accountId}}を削除しました",
+    "failed_to_reset_password":"パスワードのリセットに失敗しました"
   },
   "template": {
     "modal_label": {
@@ -447,6 +447,14 @@
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
+    "authorization_endpoint": "認可エンドポイント",
+    "token_endpoint": "トークンエンドポイント",
+    "revocation_endpoint": "失効エンドポイント",
+    "introspection_endpoint": "検証エンドポイント",
+    "userinfo_endpoint": "ユーザ情報エンドポイント",
+    "end_session_endpoint": "セッション終了エンドポイント",
+    "registration_endpoint": "登録エンドポイント",
+    "jwks_uri": "JSON Web Key Set URL",
     "clientID": "クライアントID",
     "client_secret": "クライアントシークレット",
     "updated_general_security_setting": "セキュリティ設定を更新しました。",
@@ -568,7 +576,8 @@
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
-        "updated_oidc": "OpenID Connect を更新しました"
+        "updated_oidc": "OpenID Connect を更新しました",
+        "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
       },
       "how_to": {
         "google": "Google OAuth の設定方法",

+ 0 - 0
resource/locales/ja/welcome.md → resource/locales/ja_JP/welcome.md


+ 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 }}
+

+ 4 - 0
resource/locales/zh_CN/meta.json

@@ -0,0 +1,4 @@
+{
+	"id": "zh_CN",
+	"displayName": "简体中文"
+}

+ 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>

Разница между файлами не показана из-за своего большого размера
+ 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]

+ 733 - 0
resource/locales/zh_CN/translation.json

@@ -0,0 +1,733 @@
+{
+	"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": "添加标签",
+	"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}} ",
+    "failed_to_reset_password":"Failed to reset password"
+  },
+	"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}}身份提供程序的设置中使用它",
+    "authorization_endpoint": "Authorization Endpoint",
+    "token_endpoint": "Token Endpoint",
+    "revocation_endpoint": "Revocation Endpoint",
+    "introspection_endpoint": "Introspection Endpoint",
+    "userinfo_endpoint": "UserInfo Endpoint",
+    "end_session_endpoint": "EndSessioin Endpoint",
+    "registration_endpoint": "Registration Endpoint",
+    "jwks_uri": "JSON Web Key Set URL",
+		"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",
+        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
+			},
+			"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": "安装程序"
+	},
+	"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": "无法使用此用户。",
+		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
+		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
+		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
+	}
+}

+ 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

+ 6 - 10
src/client/js/app.jsx

@@ -10,11 +10,8 @@ import SearchPage from './components/SearchPage';
 import TagsList from './components/TagsList';
 import PageEditor from './components/PageEditor';
 import PagePathNavForEditor from './components/PageEditor/PagePathNavForEditor';
-// eslint-disable-next-line import/no-duplicates
-import OptionsSelector from './components/PageEditor/OptionsSelector';
-// eslint-disable-next-line import/no-duplicates
+import EditorNavbarBottom from './components/PageEditor/EditorNavbarBottom';
 import { defaultEditorOptions, defaultPreviewOptions } from './components/PageEditor/OptionsSelector';
-import SavePageControls from './components/SavePageControls';
 import PageEditorByHackmd from './components/PageEditorByHackmd';
 import Page from './components/Page';
 import PageHistory from './components/PageHistory';
@@ -38,7 +35,7 @@ import CommentContainer from './services/CommentContainer';
 import EditorContainer from './services/EditorContainer';
 import TagContainer from './services/TagContainer';
 import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
-import GrowiSubNavigationForUserPage from './components/Navbar/GrowiSubNavigationForUserPage';
+import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
 import PersonalContainer from './services/PersonalContainer';
 
 import { appContainer, componentMappings } from './base';
@@ -74,7 +71,7 @@ Object.assign(componentMappings, {
   // 'revision-history': <PageHistory pageId={pageId} />,
   'tags-page': <TagsList crowi={appContainer} />,
 
-  'page-status-alert': <PageStatusAlert />,
+  'grw-page-status-alert-container': <PageStatusAlert />,
 
   'trash-page-alert': <TrashPageAlert />,
 
@@ -103,8 +100,8 @@ if (pageContainer.state.path != null) {
   Object.assign(componentMappings, {
     // eslint-disable-next-line quote-props
     'page': <Page />,
-    'grw-subnav': <GrowiSubNavigation />,
-    'grw-subnav-for-user-page': <GrowiSubNavigationForUserPage />,
+    'grw-subnav-container': <GrowiSubNavigation />,
+    'grw-subnav-switcher-container': <GrowiSubNavigationSwitcher />,
   });
 }
 // additional definitions if user is logged in
@@ -112,8 +109,7 @@ 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 />,
+    'page-editor-navbar-bottom-container': <EditorNavbarBottom />,
   });
   if (pageContainer.state.pageId != null) {
     Object.assign(componentMappings, {

+ 7 - 10
src/client/js/base.jsx

@@ -3,15 +3,14 @@ import React from 'react';
 import loggerFactory from '@alias/logger';
 import Xss from '@commons/service/xss';
 
-import SearchTop from './components/Navbar/SearchTop';
-import NavbarToggler from './components/Navbar/NavbarToggler';
-import PersonalDropdown from './components/Navbar/PersonalDropdown';
+import GrowiNavbar from './components/Navbar/GrowiNavbar';
+import GrowiNavbarBottom from './components/Navbar/GrowiNavbarBottom';
 import Sidebar from './components/Sidebar';
+import Fab from './components/Fab';
 import StaffCredit from './components/StaffCredit/StaffCredit';
 
 import AppContainer from './services/AppContainer';
 import WebsocketContainer from './services/WebsocketContainer';
-import PageCreateButton from './components/Navbar/PageCreateButton';
 import PageCreateModal from './components/PageCreateModal';
 
 const logger = loggerFactory('growi:cli:app');
@@ -39,17 +38,15 @@ logger.info('AppContainer has been initialized');
  *  value: React Element
  */
 const componentMappings = {
-  'grw-navbar-toggler': <NavbarToggler />,
+  'grw-navbar': <GrowiNavbar />,
+  'grw-navbar-bottom-container': <GrowiNavbarBottom />,
 
-  'grw-search-top': <SearchTop />,
-  'personal-dropdown': <PersonalDropdown />,
-
-  'create-page-button': <PageCreateButton />,
-  'create-page-button-icon': <PageCreateButton isIcon />,
   'page-create-modal': <PageCreateModal />,
 
   'grw-sidebar-wrapper': <Sidebar />,
 
+  'grw-fab-container': <Fab />,
+
   'staff-credit': <StaffCredit />,
 };
 

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

@@ -3,10 +3,11 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import loggerFactory from '@alias/logger';
 
+import { localeMetadatas } from '../../../util/i18n';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastSuccess, toastError } from '../../../util/apiNotification';
 
-import AppContainer from '../../../services/AppContainer';
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
@@ -45,7 +46,9 @@ class AppSetting extends React.Component {
               className="form-control"
               type="text"
               defaultValue={adminAppContainer.state.title || ''}
-              onChange={(e) => { adminAppContainer.changeTitle(e.target.value) }}
+              onChange={(e) => {
+                adminAppContainer.changeTitle(e.target.value);
+              }}
               placeholder="GROWI"
             />
             <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
@@ -53,13 +56,19 @@ class AppSetting extends React.Component {
         </div>
 
         <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">
             <input
               className="form-control"
               type="text"
               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')}
             />
             <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
@@ -67,37 +76,39 @@ class AppSetting extends React.Component {
         </div>
 
         <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="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>
+            {
+              localeMetadatas.map(meta => (
+                <div key={meta.id} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    id={`radioLang${meta.id}`}
+                    className="custom-control-input"
+                    name="globalLang"
+                    value={meta.id}
+                    checked={adminAppContainer.state.globalLang === meta.id}
+                    onChange={(e) => {
+                      adminAppContainer.changeGlobalLang(e.target.value);
+                    }}
+                  />
+                  <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
+                </div>
+              ))
+            }
           </div>
         </div>
 
         <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="custom-control custom-checkbox custom-checkbox-info">
               <input
@@ -106,9 +117,16 @@ class AppSetting extends React.Component {
                 className="custom-control-input"
                 name="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>
 
             <p className="form-text text-muted">
@@ -127,11 +145,10 @@ class AppSetting extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const AppSettingWrapper = withUnstatedContainers(AppSetting, [AppContainer, AdminAppContainer]);
+const AppSettingWrapper = withUnstatedContainers(AppSetting, [AdminAppContainer]);
 
 AppSetting.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 

+ 3 - 3
src/client/js/components/Admin/Common/AdminNavigation.jsx

@@ -69,9 +69,9 @@ const AdminNavigation = (props) => {
   };
 
   return (
-    <div>
+    <React.Fragment>
       {/* List group */}
-      <div className="list-group admin-navigation d-none d-lg-block">
+      <div className="list-group admin-navigation sticky-top d-none d-lg-block">
         {getListGroupItemOrDropdownItemList(true)}
       </div>
 
@@ -104,7 +104,7 @@ const AdminNavigation = (props) => {
         </div>
       </div>
 
-    </div>
+    </React.Fragment>
   );
 };
 

+ 140 - 0
src/client/js/components/Admin/Security/OidcSecuritySetting.jsx

@@ -170,6 +170,146 @@ class OidcSecurityManagement extends React.Component {
               </div>
             </div>
 
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcAuthorizationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.authorization_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcAuthorizationEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcAuthorizationEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcAuthorizationEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcTokenEndpoint" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.token_endpoint')}</label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcTokenEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcTokenEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcTokenEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcRevocationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.revocation_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcRevocationEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcRevocationEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcRevocationEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcIntrospectionEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.introspection_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcIntrospectionEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcIntrospectionEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcIntrospectionEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcUserInfoEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.userinfo_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcUserInfoEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcUserInfoEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcUserInfoEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcEndSessionEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.end_session_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcEndSessionEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcEndSessionEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcEndSessionEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcRegistrationEndpoint" className="text-left text-md-right col-md-3 col-form-label">
+                {t('security_setting.registration_endpoint')}
+              </label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcRegistrationEndpoint"
+                  defaultValue={adminOidcSecurityContainer.state.oidcRegistrationEndpoint || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcRegistrationEndpoint(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
+            <div className="row mb-5 form-group">
+              <label htmlFor="oidcJWKSUri" className="text-left text-md-right col-md-3 col-form-label">{t('security_setting.jwks_uri')}</label>
+              <div className="col-md-6">
+                <input
+                  className="form-control"
+                  type="text"
+                  name="oidcJWKSUri"
+                  defaultValue={adminOidcSecurityContainer.state.oidcJWKSUri || ''}
+                  onChange={e => adminOidcSecurityContainer.changeOidcJWKSUri(e.target.value)}
+                />
+                <p className="form-text text-muted">
+                  <small dangerouslySetInnerHTML={{ __html: t('security_setting.OAuth.OIDC.Use discovered URL if empty') }} />
+                </p>
+              </div>
+            </div>
+
             <h3 className="alert-anchor border-bottom">
               Attribute Mapping ({t('security_setting.optional')})
             </h3>

+ 34 - 26
src/client/js/components/Admin/Users/PasswordResetModal.jsx

@@ -23,14 +23,14 @@ class PasswordResetModal extends React.Component {
   }
 
   async resetPassword() {
-    const { appContainer, userForPasswordResetModal } = this.props;
-
-    const res = await appContainer.apiPost('/admin/users.resetPassword', { user_id: userForPasswordResetModal._id });
-    if (res.ok) {
-      this.setState({ temporaryPassword: res.newPassword, isPasswordResetDone: true });
+    const { t, appContainer, userForPasswordResetModal } = this.props;
+    try {
+      const res = await appContainer.apiv3Put('/users/reset-password', { id: userForPasswordResetModal._id });
+      const { newPassword } = res.data;
+      this.setState({ temporaryPassword: newPassword, isPasswordResetDone: true });
     }
-    else {
-      toastError('Failed to reset password');
+    catch (err) {
+      toastError(err, t('toaster.failed_to_reset_password'));
     }
   }
 
@@ -38,15 +38,15 @@ class PasswordResetModal extends React.Component {
     const { t, userForPasswordResetModal } = this.props;
 
     return (
-      <div>
-        <p className="alert alert-danger">{t('admin:user_management.reset_password_modal.password_reset_message')}</p>
+      <>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('admin:user_management.reset_password_modal.password_never_seen')}<br />
+          <span className="text-danger">{t('admin:user_management.reset_password_modal.send_new_password')}</span>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
-      </div>
+      </>
     );
   }
 
@@ -54,26 +54,34 @@ class PasswordResetModal extends React.Component {
     const { t, userForPasswordResetModal } = this.props;
 
     return (
-      <div>
+      <>
+        <p className="alert alert-danger">{t('admin:user_management.reset_password_modal.password_reset_message')}</p>
         <p>
-          {t('admin:user_management.reset_password_modal.password_never_seen')}<br />
-          <span className="text-danger">{t('admin:user_management.reset_password_modal.send_new_password')}</span>
+          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <p>
-          {t('admin:user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
+          {t('admin:user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
         </p>
-        <button type="submit" className="btn btn-primary" onClick={this.resetPassword}>
-          {t('admin:user_management.reset_password')}
-        </button>
-      </div>
+      </>
+    );
+  }
+
+  returnModalFooterBeforeReset() {
+    const { t } = this.props;
+    return (
+      <button type="submit" className="btn btn-danger" onClick={this.resetPassword}>
+        {t('admin:user_management.reset_password')}
+      </button>
     );
   }
 
-  returnModalFooter() {
+  returnModalFooterAfterReset() {
+    const { t } = this.props;
+
     return (
-      <div>
-        <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>OK</button>
-      </div>
+      <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>
+        {t('Close')}
+      </button>
     );
   }
 
@@ -87,10 +95,10 @@ class PasswordResetModal extends React.Component {
           {t('admin:user_management.reset_password') }
         </ModalHeader>
         <ModalBody>
-          {this.state.isPasswordResetDone ? this.renderModalBodyBeforeReset() : this.returnModalBodyAfterReset()}
+          {this.state.isPasswordResetDone ? this.returnModalBodyAfterReset() : this.renderModalBodyBeforeReset()}
         </ModalBody>
         <ModalFooter>
-          {this.state.isPasswordResetDone && this.returnModalFooter()}
+          {this.state.isPasswordResetDone ? this.returnModalFooterAfterReset() : this.returnModalFooterBeforeReset()}
         </ModalFooter>
       </Modal>
     );

+ 1 - 1
src/client/js/components/Admin/Users/UserInviteModal.jsx

@@ -49,7 +49,7 @@ class UserInviteModal extends React.Component {
         <label> {t('admin:user_management.invite_modal.emails')}</label>
         <textarea
           className="form-control"
-          placeholder="e.g. user@growi.org"
+          placeholder="e.g.&#13;&#10;user1@growi.org&#13;&#10;user2@growi.org"
           style={{ height: '200px' }}
           value={this.state.emailInputValue}
           onChange={this.handleInput}

+ 5 - 1
src/client/js/components/Drawio.jsx

@@ -3,6 +3,10 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import AppContainer from '../services/AppContainer';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
 import NotAvailableForGuest from './NotAvailableForGuest';
 
 class Drawio extends React.Component {
@@ -83,4 +87,4 @@ Drawio.propTypes = {
   rangeLineNumberOfMarkdown: PropTypes.object.isRequired,
 };
 
-export default withTranslation()(Drawio);
+export default withTranslation()(withUnstatedContainers(Drawio, [AppContainer]));

+ 67 - 0
src/client/js/components/Fab.jsx

@@ -0,0 +1,67 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import StickyEvents from 'sticky-events';
+
+import NavigationContainer from '../services/NavigationContainer';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+
+const logger = loggerFactory('growi:cli:Fab');
+
+const Fab = (props) => {
+  const { navigationContainer } = props;
+
+  const [animateClasses, setAnimateClasses] = useState('invisible');
+
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+
+    const classes = event.detail.isSticky ? 'animated fadeInUp faster' : 'animated fadeOut faster';
+    setAnimateClasses(classes);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-fav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+
+  return (
+    <div className="grw-fab d-none d-md-block">
+      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
+        <button
+          type="button"
+          className="btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light"
+          onClick={navigationContainer.openPageCreateModal}
+        >
+          <i className="icon-pencil"></i>
+        </button>
+      </div>
+      <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: 0, right: 0 }}>
+        <button type="button" className="btn btn-light btn-scroll-to-top rounded-circle p-0" onClick={() => navigationContainer.smoothScrollIntoView()}>
+          <i className="icon-control-start"></i>
+        </button>
+      </div>
+    </div>
+  );
+
+};
+
+Fab.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(Fab, [NavigationContainer]);

+ 31 - 37
src/client/js/components/InstallerForm.jsx

@@ -4,6 +4,8 @@ import PropTypes from 'prop-types';
 import i18next from 'i18next';
 import { withTranslation } from 'react-i18next';
 
+import { localeMetadatas } from '../util/i18n';
+
 class InstallerForm extends React.Component {
 
   constructor(props) {
@@ -11,13 +13,13 @@ class InstallerForm extends React.Component {
 
     this.state = {
       isValidUserName: true,
-      checkedBtn: 'en-US',
+      selectedLang: {},
     };
     this.checkUserName = this.checkUserName.bind(this);
   }
 
   componentWillMount() {
-    this.changeLanguage('en-US');
+    this.changeLanguage(localeMetadatas[0]);
   }
 
   checkUserName(event) {
@@ -32,9 +34,9 @@ class InstallerForm extends React.Component {
       .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
   }
 
-  changeLanguage(locale) {
-    i18next.changeLanguage(locale);
-    this.setState({ checkedBtn: locale });
+  changeLanguage(meta) {
+    i18next.changeLanguage(meta.id);
+    this.setState({ selectedLang: meta });
   }
 
   render() {
@@ -43,8 +45,6 @@ class InstallerForm extends React.Component {
       ? ''
       : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
 
-    const checkedBtn = this.state.checkedBtn;
-
     return (
       <div className={`login-dialog p-3 mx-auto${hasErrorClass}`}>
         <div className="row">
@@ -57,36 +57,30 @@ class InstallerForm extends React.Component {
         </div>
         <div className="row">
           <form role="form" action="/installer" method="post" id="register-form" className="col-md-12">
-            <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 className="dropdown mb-3">
+              <div className="d-flex dropdown-with-icon">
+                <i className="icon-bubbles border-0 rounded-0" />
+                <button
+                  type="button"
+                  className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                  id="dropdownLanguage"
+                  data-toggle="dropdown"
+                  aria-haspopup="true"
+                  aria-expanded="true"
+                >
+                  <span className="float-left">
+                    {this.state.selectedLang.displayName}
+                  </span>
+                </button>
+                <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
+                  {
+                  localeMetadatas.map(meta => (
+                    <button key={meta.id} className="dropdown-item" type="button" onClick={() => { this.changeLanguage(meta) }}>
+                      {meta.displayName}
+                    </button>
+                  ))
+                }
+                </div>
               </div>
             </div>
 

+ 19 - 26
src/client/js/components/Me/BasicInfoSettings.jsx

@@ -3,15 +3,16 @@ import React, { Fragment } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
+import { localeMetadatas } from '../../util/i18n';
+
 import { toastSuccess, toastError } from '../../util/apiNotification';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import AppContainer from '../../services/AppContainer';
 import PersonalContainer from '../../services/PersonalContainer';
 
 class BasicInfoSettings extends React.Component {
 
-  constructor(appContainer) {
+  constructor() {
     super();
 
     this.onClickSubmit = this.onClickSubmit.bind(this);
@@ -110,28 +111,21 @@ class BasicInfoSettings extends React.Component {
         <div className="form-group row">
           <label className="text-left text-md-right col-md-3 col-form-label">{t('Language')}</label>
           <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>
+            {
+              localeMetadatas.map(meta => (
+                <div key={meta.id} className="custom-control custom-radio custom-control-inline">
+                  <input
+                    type="radio"
+                    id={`radioLang${meta.id}`}
+                    className="custom-control-input"
+                    name="userForm[lang]"
+                    checked={personalContainer.state.lang === meta.id}
+                    onChange={() => { personalContainer.changeLang(meta.id) }}
+                  />
+                  <label className="custom-control-label" htmlFor={`radioLang${meta.id}`}>{meta.displayName}</label>
+                </div>
+              ))
+            }
           </div>
         </div>
 
@@ -149,11 +143,10 @@ class BasicInfoSettings extends React.Component {
 
 }
 
-const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [AppContainer, PersonalContainer]);
+const BasicInfoSettingsWrapper = withUnstatedContainers(BasicInfoSettings, [PersonalContainer]);
 
 BasicInfoSettings.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   personalContainer: PropTypes.instanceOf(PersonalContainer).isRequired,
 };
 

+ 4 - 1
src/client/js/components/Me/PasswordSettings.jsx

@@ -67,7 +67,10 @@ class PasswordSettings extends React.Component {
 
     return (
       <React.Fragment>
-        {(!personalContainer.state.isPasswordSet) && <div className="alert alert-warning">{ t('Password is not set') }</div>}
+        { (!personalContainer.state.isPasswordSet) && (
+          <div className="alert alert-warning">{ t('personal_settings.password_is_not_set') }</div>
+        ) }
+
         <div className="container-fluid my-4">
           {(personalContainer.state.isPasswordSet)
             ? <h2 className="border-bottom">{t('personal_settings.update_password')}</h2>

+ 10 - 5
src/client/js/components/Me/PersonalSettings.jsx

@@ -18,19 +18,24 @@ class PersonalSettings extends React.Component {
         <div className="personal-settings">
           <ul className="nav nav-tabs" role="tablist">
             <li className="nav-item">
-              <a className="nav-link active" href="#user-settings" data-toggle="tab" role="tab"><i className="icon-user"></i> { t('User Information') }</a>
+              <a className="nav-link active" href="#user-settings" data-toggle="tab" role="tab">
+                <i className="icon-fw icon-user"></i>{ t('User Information') }
+              </a>
             </li>
             <li className="nav-item">
               <a className="nav-link" href="#external-accounts" data-toggle="tab" role="tab">
-                <i className="icon-share-alt mr-1"></i>
-                { t('admin:user_management.external_accounts') }
+                <i className="icon-fw icon-share-alt"></i>{ t('admin:user_management.external_accounts') }
               </a>
             </li>
             <li className="nav-item">
-              <a className="nav-link" href="#password-settings" data-toggle="tab" role="tab"><i className="icon-lock"></i> { t('Password Settings') }</a>
+              <a className="nav-link" href="#password-settings" data-toggle="tab" role="tab">
+                <i className="icon-fw icon-lock"></i>{ t('Password Settings') }
+              </a>
             </li>
             <li className="nav-item">
-              <a className="nav-link" href="#apiToken" data-toggle="tab" role="tab"><i className="icon-paper-plane"></i> { t('API Settings') }</a>
+              <a className="nav-link" href="#apiToken" data-toggle="tab" role="tab">
+                <i className="icon-fw icon-paper-plane"></i>{ t('API Settings') }
+              </a>
             </li>
           </ul>
           <div className="tab-content p-t-10">

+ 15 - 11
src/client/js/components/Navbar/NavbarToggler.jsx → src/client/js/components/Navbar/DrawerToggler.jsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
@@ -6,24 +6,26 @@ import { withTranslation } from 'react-i18next';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import NavigationContainer from '../../services/NavigationContainer';
 
-const NavbarToggler = (props) => {
+const DrawerToggler = (props) => {
 
   const { navigationContainer } = props;
 
-  const clickHandler = () => {
+  const clickHandler = useCallback(() => {
     navigationContainer.toggleDrawer();
-  };
+  }, [navigationContainer]);
+
+  const iconClass = props.iconClass || 'icon-menu';
 
   return (
-    <a
-      className="nav-link grw-navbar-toggler border-0 waves-effect waves-light"
+    <button
+      className="grw-drawer-toggler btn btn-secondary btn-xl"
       type="button"
       aria-expanded="false"
       aria-label="Toggle navigation"
       onClick={clickHandler}
     >
-      <i className="icon-menu"></i>
-    </a>
+      <i className={iconClass}></i>
+    </button>
   );
 
 };
@@ -31,12 +33,14 @@ const NavbarToggler = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const NavbarTogglerWrapper = withUnstatedContainers(NavbarToggler, [NavigationContainer]);
+const DrawerTogglerWrapper = withUnstatedContainers(DrawerToggler, [NavigationContainer]);
 
 
-NavbarToggler.propTypes = {
+DrawerToggler.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  iconClass: PropTypes.string,
 };
 
-export default withTranslation()(NavbarTogglerWrapper);
+export default withTranslation()(DrawerTogglerWrapper);

+ 11 - 31
src/client/js/components/Navbar/SearchTop.jsx → src/client/js/components/Navbar/GlobalSearch.jsx

@@ -9,7 +9,7 @@ import NavigationContainer from '../../services/NavigationContainer';
 import SearchForm from '../SearchForm';
 
 
-class SearchTop extends React.Component {
+class GlobalSearch extends React.Component {
 
   constructor(props) {
     super(props);
@@ -51,24 +51,8 @@ class SearchTop extends React.Component {
     window.location.href = url.href;
   }
 
-  Root = ({ children }) => {
-    const { isDeviceSmallerThanMd: isCollapsed } = this.props.navigationContainer.state;
-
-    return isCollapsed
-      ? (
-        <div id="grw-search-top-collapse" className="collapse bg-dark p-3">
-          {children}
-        </div>
-      )
-      : (
-        <div className="grw-search-top-fixed position-fixed">
-          {children}
-        </div>
-      );
-  };
-
-  SearchTopForm = () => {
-    const { t, appContainer } = this.props;
+  render() {
+    const { t, appContainer, dropup } = this.props;
     const scopeLabel = this.state.isScopeChildren
       ? t('header_search_box.label.This tree')
       : t('header_search_box.label.All pages');
@@ -77,9 +61,9 @@ class SearchTop extends React.Component {
     const isReachable = config.isSearchServiceReachable;
 
     return (
-      <div className={`form-group mb-0 ${isReachable ? '' : 'has-error'}`}>
+      <div className={`form-group mb-0 d-print-none ${isReachable ? '' : 'has-error'}`}>
         <div className="input-group flex-nowrap">
-          <div className="input-group-prepend">
+          <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
             <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
               {scopeLabel}
             </button>
@@ -94,6 +78,7 @@ class SearchTop extends React.Component {
             onInputChange={this.onInputChange}
             onSubmit={this.search}
             placeholder="Search ..."
+            dropup={dropup}
           />
           <div className="btn-group-submit-search">
             <span className="btn-link text-decoration-none" onClick={this.search}>
@@ -105,24 +90,19 @@ class SearchTop extends React.Component {
     );
   }
 
-  render() {
-    const { Root, SearchTopForm } = this;
-    return (
-      <Root><SearchTopForm /></Root>
-    );
-  }
-
 }
 
-SearchTop.propTypes = {
+GlobalSearch.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+
+  dropup: PropTypes.bool,
 };
 
 /**
  * Wrapper component for using unstated
  */
-const SearchTopWrapper = withUnstatedContainers(SearchTop, [AppContainer, NavigationContainer]);
+const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer, NavigationContainer]);
 
-export default withTranslation()(SearchTopWrapper);
+export default withTranslation()(GlobalSearchWrapper);

+ 107 - 0
src/client/js/components/Navbar/GrowiNavbar.jsx

@@ -0,0 +1,107 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import NavigationContainer from '../../services/NavigationContainer';
+import AppContainer from '../../services/AppContainer';
+
+import GrowiLogo from '../GrowiLogo';
+
+import PersonalDropdown from './PersonalDropdown';
+import GlobalSearch from './GlobalSearch';
+
+class GrowiNavbar extends React.Component {
+
+  renderNavbarRight() {
+    const { t, appContainer, navigationContainer } = this.props;
+    const { currentUser } = appContainer;
+
+    // render login button
+    if (currentUser == null) {
+      return <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
+    }
+
+    return (
+      <>
+        <li className="nav-item d-none d-md-block">
+          <button className="px-md-2 nav-link btn-create-page border-0 bg-transparent" type="button" onClick={navigationContainer.openPageCreateModal}>
+            <i className="icon-pencil mr-2"></i>
+            <span className="d-none d-lg-block">{ t('New') }</span>
+          </button>
+        </li>
+
+        <li className="grw-personal-dropdown nav-item dropdown dropdown-toggle dropdown-toggle-no-caret">
+          <PersonalDropdown />
+        </li>
+      </>
+    );
+  }
+
+  renderConfidential() {
+    const { appContainer } = this.props;
+    const { crowi } = appContainer.config;
+
+    return (
+      <li className="nav-item confidential text-light">
+        <i className="icon-info d-md-none" data-toggle="tooltip" title={crowi.confidential} />
+        <span className="d-none d-md-inline">
+          {crowi.confidential}
+        </span>
+      </li>
+    );
+  }
+
+  render() {
+    const { appContainer, navigationContainer } = this.props;
+    const { crowi, isSearchServiceConfigured } = appContainer.config;
+    const { isDeviceSmallerThanMd } = navigationContainer.state;
+
+    return (
+      <>
+
+        {/* Brand Logo  */}
+        <div className="navbar-brand mr-0">
+          <a className="grw-logo d-block" href="/">
+            <GrowiLogo />
+          </a>
+        </div>
+
+        <div className="grw-app-title d-none d-md-block">
+          {crowi.title}
+        </div>
+
+
+        {/* Navbar Right  */}
+        <ul className="navbar-nav ml-auto">
+          {this.renderNavbarRight()}
+        </ul>
+
+        {crowi.confidential != null && this.renderConfidential()}
+
+        { isSearchServiceConfigured && !isDeviceSmallerThanMd && (
+          <div className="grw-global-search grw-global-search-top position-absolute">
+            <GlobalSearch />
+          </div>
+        ) }
+      </>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const GrowiNavbarWrapper = withUnstatedContainers(GrowiNavbar, [AppContainer, NavigationContainer]);
+
+
+GrowiNavbar.propTypes = {
+  t: PropTypes.func.isRequired, //  i18next
+
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withTranslation()(GrowiNavbarWrapper);

+ 61 - 0
src/client/js/components/Navbar/GrowiNavbarBottom.jsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import NavigationContainer from '../../services/NavigationContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import GlobalSearch from './GlobalSearch';
+
+const GrowiNavbarBottom = (props) => {
+
+  const {
+    navigationContainer,
+  } = props;
+  const { isDrawerOpened, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const additionalClasses = ['grw-navbar-bottom'];
+  if (isDrawerOpened) {
+    additionalClasses.push('grw-navbar-bottom-drawer-opened');
+  }
+
+  return (
+    <div className="d-md-none d-edit-none fixed-bottom">
+
+      { isDeviceSmallerThanMd && (
+        <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
+          <div className="p-3">
+            <GlobalSearch dropup />
+          </div>
+        </div>
+      ) }
+
+      <div className={`navbar navbar-expand navbar-dark bg-primary px-0 ${additionalClasses.join(' ')}`}>
+
+        <ul className="navbar-nav w-100">
+          <li className="nav-item">
+            <a type="button" className="nav-link btn-lg" onClick={() => navigationContainer.toggleDrawer()}>
+              <i className="icon-menu"></i>
+            </a>
+          </li>
+          <li className="nav-item mx-auto">
+            <a type="button" className="nav-link btn-lg" data-target="#grw-global-search-collapse" data-toggle="collapse">
+              <i className="icon-magnifier"></i>
+            </a>
+          </li>
+          <li className="nav-item">
+            <a type="button" className="nav-link btn-lg" onClick={() => navigationContainer.openPageCreateModal()}>
+              <i className="icon-pencil"></i>
+            </a>
+          </li>
+        </ul>
+      </div>
+
+    </div>
+  );
+};
+
+GrowiNavbarBottom.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(GrowiNavbarBottom, [NavigationContainer]);

+ 136 - 36
src/client/js/components/Navbar/GrowiSubNavigation.jsx

@@ -11,15 +11,19 @@ import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLi
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
+import NavigationContainer from '../../services/NavigationContainer';
+import PageContainer from '../../services/PageContainer';
 
 import RevisionPathControls from '../Page/RevisionPathControls';
-import PageContainer from '../../services/PageContainer';
 import TagLabels from '../Page/TagLabels';
 import LikeButton from '../LikeButton';
 import BookmarkButton from '../BookmarkButton';
 
 import PageCreator from './PageCreator';
 import RevisionAuthor from './RevisionAuthor';
+import DrawerToggler from './DrawerToggler';
+import UserPicture from '../User/UserPicture';
+
 
 // eslint-disable-next-line react/prop-types
 const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
@@ -57,64 +61,157 @@ const PagePathNav = ({ pageId, pagePath, isPageForbidden }) => {
   );
 };
 
+// eslint-disable-next-line react/prop-types
+const UserPagePathNav = ({ pageId, pagePath }) => {
+  const linkedPagePath = new LinkedPagePath(pagePath);
+  const latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
+
+  return (
+    <div className="grw-page-path-nav">
+      <span className="d-flex align-items-center flex-wrap">
+        <h4 className="grw-user-page-path">{latterLink}</h4>
+        <RevisionPathControls
+          pageId={pageId}
+          pagePath={pagePath}
+        />
+      </span>
+    </div>
+  );
+};
+
+/* eslint-disable react/prop-types */
+const UserInfo = ({ pageUser }) => {
+  return (
+    <div className="grw-users-info d-flex align-items-center d-edit-none">
+      <UserPicture user={pageUser} />
+
+      <div className="users-meta">
+        <h1 className="user-page-name">
+          {pageUser.name}
+        </h1>
+        <div className="user-page-meta mt-1 mb-0">
+          <span className="user-page-username mr-2"><i className="icon-user mr-1"></i>{pageUser.username}</span>
+          <span className="user-page-email mr-2">
+            <i className="icon-envelope mr-1"></i>
+            {pageUser.isEmailPublished ? pageUser.email : '*****'}
+          </span>
+          {pageUser.introduction && <span className="user-page-introduction">{pageUser.introduction}</span>}
+        </div>
+      </div>
+
+    </div>
+  );
+};
+/* eslint-enable react/prop-types */
+
+/* eslint-disable react/prop-types */
+const PageReactionButtons = ({ appContainer, pageContainer }) => {
+
+  const { pageId, isLiked, pageUser } = pageContainer.state;
+
+  return (
+    <>
+      {pageUser == null && (
+      <span className="mr-2">
+        <LikeButton pageId={pageId} isLiked={isLiked} />
+      </span>
+      )}
+      <span className="mr-2">
+        <BookmarkButton pageId={pageId} crowi={appContainer} />
+      </span>
+    </>
+  );
+};
+/* eslint-enable react/prop-types */
+
 const GrowiSubNavigation = (props) => {
-  const isPageForbidden = document.querySelector('#grw-subnav').getAttribute('data-is-forbidden-page') === 'true';
-  const { appContainer, pageContainer } = props;
+  const {
+    appContainer, navigationContainer, pageContainer, isCompactMode,
+  } = props;
+  const { isDrawerMode } = navigationContainer.state;
   const {
     pageId, path, createdAt, creator, updatedAt, revisionAuthor,
+    isForbidden: isPageForbidden, pageUser,
   } = pageContainer.state;
 
   const isPageNotFound = pageId == null;
+  const isUserPage = pageUser != null;
   const isPageInTrash = isTrashPage(path);
 
   // Display only the RevisionPath
   if (isPageNotFound || isPageForbidden) {
     return (
-      <div className="px-3 py-3 grw-subnavbar">
+      <div className="grw-subnav d-flex align-items-center justify-content-between">
         <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
       </div>
     );
   }
 
-  const additionalClassNames = ['grw-subnavbar'];
-
   return (
-    <div className={`d-flex align-items-center justify-content-between px-3 py-1 ${additionalClassNames.join(' ')}`}>
+    <div className={`grw-subnav d-flex align-items-center justify-content-between ${isCompactMode ? 'grw-subnav-compact' : ''}`}>
 
-      {/* Page Path */}
-      <div>
-        <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
-        { !isPageNotFound && !isPageForbidden && (
-          <TagLabels />
+      {/* Left side */}
+      <div className="d-flex">
+        { isDrawerMode && (
+          <div className="d-none d-md-flex align-items-center border-right mr-3 pr-3">
+            <DrawerToggler />
+          </div>
         ) }
+
+        <div>
+          { !isCompactMode && !isPageNotFound && !isPageForbidden && !isUserPage && (
+            <div className="mb-2">
+              <TagLabels />
+            </div>
+          ) }
+
+          { isUserPage
+            ? (
+              <>
+                <UserPagePathNav pageId={pageId} pagePath={path} />
+                <UserInfo pageUser={pageUser} />
+              </>
+            )
+            : (
+              <PagePathNav pageId={pageId} pagePath={path} isPageForbidden={isPageForbidden} />
+            )
+          }
+
+        </div>
       </div>
 
-      <div className="d-flex align-items-center">
-        { !isPageInTrash && (
-          /* Header Button */
-          <div className="mr-2">
-            <LikeButton pageId={pageId} isLiked={pageContainer.state.isLiked} />
-          </div>
-        ) }
-        { !isPageInTrash && (
-          <div>
-            <BookmarkButton pageId={pageId} crowi={appContainer} />
+      {/* Right side */}
+      <div className="d-flex">
+
+        <div className="d-flex flex-column align-items-end justify-content-center">
+          <div className="d-flex">
+            { !isPageInTrash && <PageReactionButtons appContainer={appContainer} pageContainer={pageContainer} /> }
+            <div className="mt-2">
+              {/* TODO: impl View / Edit / HackMD button group */}
+              {/* <div className="btn-group" role="group" aria-label="Basic example">
+              <button type="button" className="btn btn-outline-primary">Left</button>
+              <button type="button" className="btn btn-outline-primary">Middle</button>
+              <button type="button" className="btn btn-outline-primary">Right</button>
+            </div> */}
+            </div>
           </div>
-        ) }
+        </div>
 
         {/* Page Authors */}
-        <ul className="authors text-nowrap d-none d-lg-block d-edit-none">
-          { creator != null && (
-            <li>
-              <PageCreator creator={creator} createdAt={createdAt} />
-            </li>
-          ) }
-          { revisionAuthor != null && (
-            <li className="mt-1">
-              <RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} />
-            </li>
-          ) }
-        </ul>
+        { (!isCompactMode && !isUserPage) && (
+          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none">
+            { creator != null && (
+              <li className="pb-1">
+                <PageCreator creator={creator} createdAt={createdAt} />
+              </li>
+            ) }
+            { revisionAuthor != null && (
+              <li className="mt-1 pt-1 border-top">
+                <RevisionAuthor revisionAuthor={revisionAuthor} updatedAt={updatedAt} />
+              </li>
+            ) }
+          </ul>
+        ) }
       </div>
 
     </div>
@@ -125,13 +222,16 @@ const GrowiSubNavigation = (props) => {
 /**
  * Wrapper component for using unstated
  */
-const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, PageContainer]);
+const GrowiSubNavigationWrapper = withUnstatedContainers(GrowiSubNavigation, [AppContainer, NavigationContainer, PageContainer]);
 
 
 GrowiSubNavigation.propTypes = {
   t: PropTypes.func.isRequired, //  i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+  isCompactMode: PropTypes.bool,
 };
 
 export default withTranslation()(GrowiSubNavigationWrapper);

+ 0 - 92
src/client/js/components/Navbar/GrowiSubNavigationForUserPage.jsx

@@ -1,92 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import LinkedPagePath from '@commons/models/linked-page-path';
-import PagePathHierarchicalLink from '@commons/components/PagePathHierarchicalLink';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '../../services/AppContainer';
-import PageContainer from '../../services/PageContainer';
-
-import RevisionPathControls from '../Page/RevisionPathControls';
-import BookmarkButton from '../BookmarkButton';
-import UserPicture from '../User/UserPicture';
-
-// eslint-disable-next-line react/prop-types
-const PagePathNav = ({ pageId, pagePath }) => {
-  const linkedPagePath = new LinkedPagePath(pagePath);
-  const latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
-
-  return (
-    <div className="grw-page-path-nav">
-      <span className="d-flex align-items-center flex-wrap">
-        <h4 className="grw-user-page-path">{latterLink}</h4>
-        <RevisionPathControls
-          pageId={pageId}
-          pagePath={pagePath}
-        />
-      </span>
-    </div>
-  );
-};
-
-const GrowiSubNavigationForUserPage = (props) => {
-  const pageUser = JSON.parse(document.querySelector('#grw-subnav-for-user-page').getAttribute('data-page-user'));
-  const { appContainer, pageContainer } = props;
-  const {
-    pageId, path,
-  } = pageContainer.state;
-
-  const additionalClassNames = ['grw-subnavbar', 'grw-subnavbar-user-page'];
-  const layoutType = appContainer.getConfig().layoutType;
-
-  if (layoutType === 'growi') {
-    additionalClassNames.push('py-3');
-  }
-
-  return (
-    <div className={`px-3 py-3 ${additionalClassNames.join(' ')}`}>
-      <PagePathNav pageId={pageId} pagePath={path} />
-
-      <div className="d-flex align-items-center justify-content-between">
-
-        <div className="users-info d-flex align-items-center d-edit-none">
-          <UserPicture user={pageUser} />
-
-          <div className="users-meta">
-            <h1>
-              {pageUser.name}
-            </h1>
-            <ul className="user-page-meta mt-1 mb-0">
-              <li className="user-page-username"><i className="icon-user mr-1"></i>{pageUser.username}</li>
-              <li className="user-page-email">
-                <i className="icon-envelope mr-1"></i>
-                {pageUser.isEmailPublished ? pageUser.email : '*****'}
-              </li>
-              {pageUser.introduction && <li className="user-page-introduction"><p>{pageUser.introduction}</p></li>}
-            </ul>
-          </div>
-        </div>
-
-        <BookmarkButton pageId={pageId} crowi={appContainer} size="lg" />
-      </div>
-    </div>
-  );
-
-};
-
-/**
- * Wrapper component for using unstated
- */
-const GrowiSubNavigationForUserPageWrapper = withUnstatedContainers(GrowiSubNavigationForUserPage, [AppContainer, PageContainer]);
-
-
-GrowiSubNavigationForUserPage.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-export default withTranslation()(GrowiSubNavigationForUserPageWrapper);

+ 88 - 0
src/client/js/components/Navbar/GrowiSubNavigationSwitcher.jsx

@@ -0,0 +1,88 @@
+import React, { useState, useEffect, useCallback } from 'react';
+// import PropTypes from 'prop-types';
+import loggerFactory from '@alias/logger';
+
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
+
+import GrowiSubNavigation from './GrowiSubNavigation';
+
+const logger = loggerFactory('growi:cli:GrowiSubNavigationSticky');
+
+
+/**
+ * Subnavigation
+ *
+ * needs:
+ *   #grw-subnav-fixed-container element
+ *   #grw-subnav-sticky-trigger element
+ *
+ * @param {object} props
+ */
+const GrowiSubNavigationSwitcher = (props) => {
+
+  const [isVisible, setVisible] = useState(false);
+
+  const resetWidth = useCallback(() => {
+    const elem = document.getElementById('grw-subnav-fixed-container');
+
+    if (elem == null || elem.parentNode == null) {
+      return;
+    }
+
+    // get parent width
+    const { clientWidth: width } = elem.parentNode;
+    // update style
+    elem.style.width = `${width}px`;
+  }, []);
+
+  // setup effect by resizing event
+  useEffect(() => {
+    const resizeHandler = debounce(100, resetWidth);
+
+    window.addEventListener('resize', resizeHandler);
+
+    // return clean up handler
+    return () => {
+      window.removeEventListener('resize', resizeHandler);
+    };
+  }, [resetWidth]);
+
+  const stickyChangeHandler = useCallback((event) => {
+    logger.debug('StickyEvents.CHANGE detected');
+    setVisible(event.detail.isSticky);
+  }, []);
+
+  // setup effect by sticky event
+  useEffect(() => {
+    // sticky
+    // See: https://github.com/ryanwalters/sticky-events
+    const stickyEvents = new StickyEvents({ stickySelector: '#grw-subnav-sticky-trigger' });
+    const { stickySelector } = stickyEvents;
+    const elem = document.querySelector(stickySelector);
+    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+    // return clean up handler
+    return () => {
+      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+    };
+  }, [stickyChangeHandler]);
+
+  // update width
+  useEffect(() => {
+    resetWidth();
+  });
+
+  return (
+    <div className={`grw-subnav-switcher ${isVisible ? '' : 'grw-subnav-switcher-hidden'}`}>
+      <div id="grw-subnav-fixed-container" className="grw-subnav-fixed-container position-fixed">
+        <GrowiSubNavigation isCompactMode />
+      </div>
+    </div>
+  );
+};
+
+GrowiSubNavigationSwitcher.propTypes = {
+};
+
+export default GrowiSubNavigationSwitcher;

+ 0 - 41
src/client/js/components/Navbar/PageCreateButton.jsx

@@ -1,41 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-import NavigationContainer from '../../services/NavigationContainer';
-
-const PageCreateButton = (props) => {
-  const { t, navigationContainer, isIcon } = props;
-
-  if (isIcon) {
-    return (
-      <button className="btn btn-lg btn-primary rounded-circle waves-effect waves-light" type="button" onClick={navigationContainer.openPageCreateModal}>
-        <i className="icon-pencil"></i>
-      </button>
-    );
-  }
-
-  return (
-    <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>
-      <span className="d-none d-lg-block">{ t('New') }</span>
-    </button>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageCreateButtonWrapper = withUnstatedContainers(PageCreateButton, [NavigationContainer]);
-
-
-PageCreateButton.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-
-  isIcon: PropTypes.bool,
-};
-
-export default withTranslation()(PageCreateButtonWrapper);

+ 1 - 1
src/client/js/components/Navbar/PageCreator.jsx

@@ -9,7 +9,7 @@ const PageCreator = (props) => {
   const { creator, createdAt, isCompactMode } = props;
   const creatInfo = isCompactMode
     ? (<div>Created at <span className="text-muted">{createdAt}</span></div>)
-    : (<div><div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div><div className="text-muted">{createdAt}</div></div>);
+    : (<div><div>Created by <a href={userPageRoot(creator)}>{creator.name}</a></div><div className="text-muted text-date">{createdAt}</div></div>);
   const pictureSize = isCompactMode ? 'xs' : 'sm';
 
   return (

+ 6 - 1
src/client/js/components/Navbar/RevisionAuthor.jsx

@@ -9,7 +9,12 @@ const RevisionAuthor = (props) => {
   const { revisionAuthor, updatedAt, isCompactMode } = props;
   const updateInfo = isCompactMode
     ? (<div>Updated at <span className="text-muted">{updatedAt}</span></div>)
-    : (<div><div>Updated by  <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div><div className="text-muted">{updatedAt}</div></div>);
+    : (
+      <div>
+        <div>Updated by <a href={userPageRoot(revisionAuthor)}>{revisionAuthor.name}</a></div>
+        <div className="text-muted text-date">{updatedAt}</div>
+      </div>
+    );
   const pictureSize = isCompactMode ? 'xs' : 'sm';
 
   return (

+ 13 - 2
src/client/js/components/NotAvailableForGuest.jsx

@@ -3,7 +3,17 @@ import PropTypes from 'prop-types';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
-const NotAvailableForGuest = ({ children }) => {
+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)}`;
 
@@ -24,7 +34,8 @@ const NotAvailableForGuest = ({ children }) => {
 };
 
 NotAvailableForGuest.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   children: PropTypes.node.isRequired,
 };
 
-export default NotAvailableForGuest;
+export default withUnstatedContainers(NotAvailableForGuest, [AppContainer]);

+ 69 - 0
src/client/js/components/Page/RenderTagLabels.jsx

@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../UnstatedUtils';
+import PageContainer from '../../services/PageContainer';
+
+function RenderTagLabels(props) {
+  const { t, tags, pageContainer } = props;
+  const { pageId } = pageContainer;
+
+  function openEditorHandler() {
+    if (props.openEditorModal == null) {
+      return;
+    }
+    props.openEditorModal();
+  }
+
+  // activate suspense
+  if (tags == null) {
+    throw new Promise(() => {});
+  }
+
+  const isTagsEmpty = tags.length === 0;
+
+  const tagElements = tags.map((tag) => {
+    return (
+      <a key={`${pageId}_${tag}`} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
+        {tag}
+      </a>
+    );
+  });
+
+  return (
+    <>
+      {tagElements}
+
+      <a className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty ? 'no-tags' : ''}`} onClick={openEditorHandler}>
+        { isTagsEmpty
+          ? (
+            <>{ t('Add tags for this page') }<i className="ml-1 icon-plus"></i></>
+          )
+          : (
+            <i className="icon-plus"></i>
+          )
+        }
+      </a>
+    </>
+  );
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const RenderTagLabelsWrapper = withUnstatedContainers(RenderTagLabels, [PageContainer]);
+
+
+RenderTagLabels.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+
+  tags: PropTypes.array,
+  openEditorModal: PropTypes.func,
+
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+
+};
+
+export default withTranslation()(RenderTagLabelsWrapper);

+ 62 - 0
src/client/js/components/Page/TagEditModal.jsx

@@ -0,0 +1,62 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import TagsInput from './TagsInput';
+
+function TagEditModal(props) {
+  const [tags, setTags] = useState([]);
+
+  function onTagsUpdatedByTagsInput(tags) {
+    setTags(tags);
+  }
+
+  useEffect(() => {
+    setTags(props.tags);
+  }, [props.tags]);
+
+  function closeModalHandler() {
+    if (props.onClose == null) {
+      return;
+    }
+    props.onClose();
+  }
+
+  function handleSubmit() {
+    if (props.onTagsUpdated == null) {
+      return;
+    }
+
+    props.onTagsUpdated(tags);
+    closeModalHandler();
+  }
+
+  return (
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
+      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
+          Edit Tags
+      </ModalHeader>
+      <ModalBody>
+        <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
+      </ModalBody>
+      <ModalFooter>
+        <Button color="primary" onClick={handleSubmit}>
+            Done
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+}
+
+TagEditModal.propTypes = {
+  tags: PropTypes.array,
+  isOpen: PropTypes.bool.isRequired,
+  onClose: PropTypes.func,
+  onTagsUpdated: PropTypes.func,
+};
+
+export default TagEditModal;

+ 0 - 71
src/client/js/components/Page/TagEditor.jsx

@@ -1,71 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import AppContainer from '../../services/AppContainer';
-
-import TagsInput from './TagsInput';
-
-export default class TagEditor extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      tags: [],
-      isOpenModal: false,
-    };
-
-    this.show = this.show.bind(this);
-    this.onTagsUpdatedByTagsInput = this.onTagsUpdatedByTagsInput.bind(this);
-    this.closeModalHandler = this.closeModalHandler.bind(this);
-    this.handleSubmit = this.handleSubmit.bind(this);
-  }
-
-  show(tags) {
-    this.setState({ tags, isOpenModal: true });
-  }
-
-  onTagsUpdatedByTagsInput(tags) {
-    this.setState({ tags });
-  }
-
-  closeModalHandler() {
-    this.setState({ isOpenModal: false });
-  }
-
-  async handleSubmit() {
-    this.props.onTagsUpdated(this.state.tags);
-
-    // close modal
-    this.setState({ isOpenModal: false });
-  }
-
-  render() {
-    return (
-      <Modal isOpen={this.state.isOpenModal} toggle={this.closeModalHandler} id="edit-tag-modal">
-        <ModalHeader tag="h4" toggle={this.closeModalHandler} className="bg-primary text-light">
-          Edit Tags
-        </ModalHeader>
-        <ModalBody>
-          <TagsInput tags={this.state.tags} onTagsUpdated={this.onTagsUpdatedByTagsInput} />
-        </ModalBody>
-        <ModalFooter>
-          <Button color="primary" onClick={this.handleSubmit}>
-            Done
-          </Button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-TagEditor.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  onTagsUpdated: PropTypes.func.isRequired,
-};

+ 63 - 92
src/client/js/components/Page/TagLabels.jsx

@@ -1,16 +1,16 @@
-import React from 'react';
+import React, { Suspense } from 'react';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
-import * as toastr from 'toastr';
+import { toastSuccess, toastError } from '../../util/apiNotification';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
-import NavigationContainer from '../../services/NavigationContainer';
 import PageContainer from '../../services/PageContainer';
 import EditorContainer from '../../services/EditorContainer';
 
-import TagEditor from './TagEditor';
+import RenderTagLabels from './RenderTagLabels';
+import TagEditModal from './TagEditModal';
 
 class TagLabels extends React.Component {
 
@@ -18,118 +18,84 @@ class TagLabels extends React.Component {
     super(props);
 
     this.state = {
-      showTagEditor: false,
+      isTagEditModalShown: false,
     };
 
-    this.showEditor = this.showEditor.bind(this);
+    this.openEditorModal = this.openEditorModal.bind(this);
+    this.closeEditorModal = this.closeEditorModal.bind(this);
     this.tagsUpdatedHandler = this.tagsUpdatedHandler.bind(this);
   }
 
   /**
    * @return tags data
-   *   1. pageContainer.state.tags if editorMode is null
-   *   2. editorContainer.state.tags if editorMode is not null
+   *   1. pageContainer.state.tags if isEditorMode is false
+   *   2. editorContainer.state.tags if isEditorMode is true
    */
   getEditTargetData() {
-    const { editorMode } = this.props.navigationContainer.state;
-    return (editorMode == null)
-      ? this.props.pageContainer.state.tags
-      : this.props.editorContainer.state.tags;
+    const { isEditorMode } = this.props;
+    return (isEditorMode) ? this.props.editorContainer.state.tags : this.props.pageContainer.state.tags;
   }
 
-  showEditor() {
-    this.tagEditor.show(this.getEditTargetData());
+  openEditorModal() {
+    this.setState({ isTagEditModalShown: true });
+  }
+
+  closeEditorModal() {
+    this.setState({ isTagEditModalShown: false });
   }
 
   async tagsUpdatedHandler(tags) {
-    const { appContainer, navigationContainer, editorContainer } = this.props;
-    const { editorMode } = navigationContainer.state;
+    const { appContainer, editorContainer, isEditorMode } = this.props;
 
-    // post api request and update tags
-    if (editorMode == null) {
-      const { pageContainer } = this.props;
-
-      try {
-        const { pageId } = pageContainer.state;
-        await appContainer.apiPost('/tags.update', { pageId, tags });
-
-        // update pageContainer.state
-        pageContainer.setState({ tags });
-        editorContainer.setState({ tags });
-
-        this.apiSuccessHandler();
-      }
-      catch (err) {
-        this.apiErrorHandler(err);
-        return;
-      }
-    }
     // only update tags in editorContainer
-    else {
-      editorContainer.setState({ tags });
+    if (isEditorMode) {
+      return editorContainer.setState({ tags });
     }
-  }
 
-  apiSuccessHandler() {
-    toastr.success(undefined, 'updated tags successfully', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '1200',
-      extendedTimeOut: '150',
-    });
-  }
+    // post api request and update tags
+    const { pageContainer } = this.props;
+
+    try {
+      const { pageId } = pageContainer.state;
+      await appContainer.apiPost('/tags.update', { pageId, tags });
+
+      // update pageContainer.state
+      pageContainer.setState({ tags });
+      editorContainer.setState({ tags });
 
-  apiErrorHandler(err) {
-    toastr.error(err.message, 'Error occured', {
-      closeButton: true,
-      progressBar: true,
-      newestOnTop: false,
-      showDuration: '100',
-      hideDuration: '100',
-      timeOut: '3000',
-    });
+      toastSuccess('updated tags successfully');
+    }
+    catch (err) {
+      toastError(err, 'fail to update tags');
+    }
   }
 
-  render() {
-    const { t } = this.props;
-    const { pageId } = this.props.pageContainer.state;
 
+  render() {
     const tags = this.getEditTargetData();
 
-    const tagElements = tags.map((tag) => {
-      return (
-        <span key={`${pageId}_${tag}`} className="text-muted">
-          <i className="tag-icon icon-tag mr-1"></i>
-          <a className="tag-name mr-2" href={`/_search?q=tag:${tag}`} key={`${pageId}_${tag}_link`}>{tag}</a>
-        </span>
-      );
-    });
-
     return (
-      <div className="tag-labels">
-        {tags.length === 0 && (
-          <a className="btn btn-link btn-edit-tags no-tags p-0 text-muted" onClick={this.showEditor}>
-            { t('Add tags for this page') } <i className="manage-tags ml-2 icon-plus"></i>
-          </a>
-        )}
-        {tagElements}
-        {tags.length > 0 && (
-          <a className="btn btn-link btn-edit-tags p-0 text-muted" onClick={this.showEditor}>
-            <i className="manage-tags ml-2 icon-plus"></i> { t('Edit tags for this page') }
-          </a>
-        )}
-
-        <TagEditor
-          ref={(c) => { this.tagEditor = c }}
+      <>
+
+        <form className="grw-tag-labels form-inline">
+          <i className="tag-icon icon-tag mr-2"></i>
+          <Suspense fallback={<span className="grw-tag-label badge badge-secondary">―</span>}>
+            <RenderTagLabels
+              tags={tags}
+              openEditorModal={this.openEditorModal}
+            />
+          </Suspense>
+        </form>
+
+        <TagEditModal
+          tags={tags}
+          isOpen={this.state.isTagEditModalShown}
+          onClose={this.closeEditorModal}
           appContainer={this.props.appContainer}
-          show={this.state.showTagEditor}
           onTagsUpdated={this.tagsUpdatedHandler}
-        >
-        </TagEditor>
-      </div>
+        />
+
+      </>
     );
   }
 
@@ -138,15 +104,20 @@ class TagLabels extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, NavigationContainer, PageContainer, EditorContainer]);
-
+const TagLabelsWrapper = withUnstatedContainers(TagLabels, [AppContainer, PageContainer, EditorContainer]);
 
 TagLabels.propTypes = {
   t: PropTypes.func.isRequired, // i18next
+
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+
+  isEditorMode: PropTypes.bool,
+};
+
+TagLabels.defaultProps = {
+  isEditorMode: false,
 };
 
 export default withTranslation()(TagLabelsWrapper);

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

@@ -124,7 +124,7 @@ const PageDeleteModal = (props) => {
         {!isDeleteCompletelyModal && renderDeleteCompletelyForm()}
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} linkPath={path} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
         <button type="button" className={`btn btn-${deleteIconAndKey[deleteMode].color}`} onClick={deleteButtonHandler}>
           <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }

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

@@ -100,7 +100,7 @@ const PageDuplicateModal = (props) => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} linkPath={path} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
         <button type="button" className="btn btn-primary" onClick={duplicate}>Duplicate page</button>
       </ModalFooter>
     </Modal>

+ 3 - 1
src/client/js/components/PageEditor.jsx

@@ -21,6 +21,8 @@ class PageEditor extends React.Component {
   constructor(props) {
     super(props);
 
+    this.previewElement = React.createRef();
+
     const config = this.props.appContainer.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
@@ -305,7 +307,7 @@ class PageEditor extends React.Component {
             onSave={this.onSaveWithShortcut}
           />
         </div>
-        <div className="d-none d-xl-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
           <Preview
             markdown={this.state.markdown}
             // eslint-disable-next-line no-return-assign

+ 78 - 0
src/client/js/components/PageEditor/EditorNavbarBottom.jsx

@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { Collapse } from 'reactstrap';
+
+import NavigationContainer from '../../services/NavigationContainer';
+import { withUnstatedContainers } from '../UnstatedUtils';
+
+import SavePageControls from '../SavePageControls';
+
+import OptionsSelector from './OptionsSelector';
+
+const EditorNavbarBottom = (props) => {
+
+  const [isExpanded, setExpanded] = useState(false);
+
+  const {
+    navigationContainer,
+  } = props;
+  const { editorMode, isDrawerMode, isDeviceSmallerThanMd } = navigationContainer.state;
+
+  const additionalClasses = ['grw-editor-navbar-bottom'];
+
+  const renderDrawerButton = () => (
+    <button type="button" className="btn btn-outline-secondary border-0" onClick={() => navigationContainer.toggleDrawer()}>
+      <i className="icon-menu"></i>
+    </button>
+  );
+
+  // eslint-disable-next-line react/prop-types
+  const renderExpandButton = () => (
+    <div className="d-md-none ml-2">
+      <button
+        type="button"
+        className={`btn btn-outline-secondary btn-expand border-0 ${isExpanded ? 'expand' : ''}`}
+        onClick={() => setExpanded(!isExpanded)}
+      >
+        <i className="icon-arrow-up"></i>
+      </button>
+    </div>
+  );
+
+  const isOptionsSelectorEnabled = editorMode !== 'hackmd';
+  const isCollapsedOptionsSelectorEnabled = isOptionsSelectorEnabled && isDeviceSmallerThanMd;
+
+  return (
+    <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `}>
+      <div className={`navbar navbar-expand border-top px-2 ${additionalClasses.join(' ')}`}>
+        <form className="form-inline">
+          { isDrawerMode && renderDrawerButton() }
+          { isOptionsSelectorEnabled && !isDeviceSmallerThanMd && <OptionsSelector /> }
+        </form>
+        <form className="form-inline ml-auto">
+          <SavePageControls />
+          { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
+        </form>
+      </div>
+      {/* Collapsed OptionsSelector */}
+      { isCollapsedOptionsSelectorEnabled && (
+        <Collapse isOpen={isExpanded}>
+          <div className="px-2"> {/* set padding for border-top */}
+            <div className={`navbar navbar-expand border-top px-0 ${additionalClasses.join(' ')}`}>
+              <form className="form-inline ml-auto">
+                <OptionsSelector />
+              </form>
+            </div>
+          </div>
+        </Collapse>
+      ) }
+    </div>
+  );
+};
+
+EditorNavbarBottom.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+};
+
+export default withUnstatedContainers(EditorNavbarBottom, [NavigationContainer]);

+ 40 - 19
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -8,6 +8,7 @@ import {
 } from 'reactstrap';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
+import AppContainer from '../../services/AppContainer';
 import EditorContainer from '../../services/EditorContainer';
 
 
@@ -26,7 +27,7 @@ class OptionsSelector extends React.Component {
   constructor(props) {
     super(props);
 
-    const config = this.props.crowi.getConfig();
+    const config = this.props.appContainer.getConfig();
     const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
@@ -109,10 +110,19 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="my-0 form-group">
-        <label className="mr-2">Theme:</label>
-        <div className="btn-group btn-group-sm dropup">
-          <button className="btn btn-outline-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <div className="input-group">
+        <div className="input-group-prepend">
+          <span className="input-group-text" id="igt-theme">Theme</span>
+        </div>
+        <div className="input-group-append dropup">
+          <button
+            type="button"
+            className="btn btn-outline-secondary dropdown-toggle"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="false"
+            aria-describedby="igt-theme"
+          >
             {selectedTheme}
           </button>
           <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
@@ -136,10 +146,19 @@ class OptionsSelector extends React.Component {
     });
 
     return (
-      <div className="my-0 form-group">
-        <label className="mr-2">Keymap:</label>
-        <div className="btn-group btn-group-sm dropup">
-          <button className="btn btn-outline-secondary dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+      <div className="input-group">
+        <div className="input-group-prepend">
+          <span className="input-group-text" id="igt-keymap">Keymap</span>
+        </div>
+        <div className="input-group-append dropup">
+          <button
+            type="button"
+            className="btn btn-outline-secondary dropdown-toggle"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            aria-expanded="false"
+            aria-describedby="igt-keymap"
+          >
             {selectedKeymapMode}
           </button>
           <div className="dropdown-menu" aria-labelledby="dropdownMenuLink">
@@ -156,7 +175,6 @@ class OptionsSelector extends React.Component {
 
         <Dropdown
           direction="up"
-          size="sm"
           className="grw-editor-configuration-dropdown"
           isOpen={this.state.isCddMenuOpened}
           toggle={this.onToggleConfigurationDropdown}
@@ -190,9 +208,11 @@ class OptionsSelector extends React.Component {
 
     return (
       <DropdownItem toggle={false} onClick={this.onClickStyleActiveLine}>
-        <span className="icon-container"></span>
-        <span className="menuitem-label mr-2">{ t('page_edit.Show active line') }</span>
-        <span className="icon-container"><i className={iconClassName}></i></span>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
       </DropdownItem>
     );
   }
@@ -215,9 +235,11 @@ class OptionsSelector extends React.Component {
 
     return (
       <DropdownItem toggle={false} onClick={this.onClickRenderMathJaxInRealtime}>
-        <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
-        <span className="menuitem-label">MathJax Rendering</span>
-        <i className={iconClassName}></i>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"><img src="/images/icons/fx.svg" width="14px" alt="fx"></img></span>
+          <span className="menuitem-label">MathJax Rendering</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
       </DropdownItem>
     );
   }
@@ -237,14 +259,13 @@ class OptionsSelector extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [EditorContainer]);
+const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer, EditorContainer]);
 
 OptionsSelector.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  crowi: PropTypes.object.isRequired,
 };
 
 export default withTranslation()(OptionsSelectorWrapper);

+ 2 - 2
src/client/js/components/PageEditor/PagePathNavForEditor.jsx

@@ -20,7 +20,7 @@ const PagePathNavForEditor = (props) => {
   const pagePathHierarchicalLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
 
   return (
-    <div className="grw-page-path-nav-for-edit mt-1">
+    <div className="grw-page-path-nav-for-edit">
       <span className="d-flex align-items-center flex-wrap">
         <h3 className="mb-0 grw-page-path-link">{pagePathHierarchicalLink}</h3>
         <RevisionPathControls
@@ -28,7 +28,7 @@ const PagePathNavForEditor = (props) => {
           pagePath={path}
         />
       </span>
-      <TagLabels />
+      <TagLabels isEditorMode />
     </div>
   );
 };

+ 4 - 2
src/client/js/components/PageEditor/Preview.jsx

@@ -82,7 +82,9 @@ class Preview extends React.PureComponent {
             className="page-editor-preview-body"
             ref={(elm) => {
                 this.previewElement = elm;
-                this.props.inputRef(elm);
+                if (this.props.inputRef != null) {
+                  this.props.inputRef(elm);
+                }
               }}
             onScroll={(event) => {
                 if (this.props.onScroll != null) {
@@ -112,7 +114,7 @@ Preview.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 
   markdown: PropTypes.string,
-  inputRef: PropTypes.func.isRequired, // for getting div element
+  inputRef: PropTypes.func,
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   onScroll: PropTypes.func,

+ 4 - 6
src/client/js/components/PageManagement/ApiErrorMessage.jsx

@@ -5,7 +5,7 @@ import { withTranslation } from 'react-i18next';
 
 const ApiErrorMessage = (props) => {
   const {
-    t, errorCode, errorMessage, linkPath,
+    t, errorCode, errorMessage, targetPath,
   } = props;
 
   function reload() {
@@ -18,7 +18,7 @@ const ApiErrorMessage = (props) => {
         return (
           <>
             <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.already_exists') }</strong>
-            <small><a href={linkPath}>{linkPath} <i className="icon-login"></i></a></small>
+            <small><a href={targetPath}>{targetPath} <i className="icon-login"></i></a></small>
           </>
         );
       case 'notfound_or_forbidden':
@@ -42,12 +42,10 @@ const ApiErrorMessage = (props) => {
         return (
           <strong><i className="icon-fw icon-ban"></i> Invalid path</strong>
         );
-      case 'unknown':
+      default:
         return (
           <strong><i className="icon-fw icon-ban"></i> Unknown error occured</strong>
         );
-      default:
-        return null;
     }
   }
 
@@ -77,7 +75,7 @@ ApiErrorMessage.propTypes = {
 
   errorCode:    PropTypes.string,
   errorMessage: PropTypes.string,
-  linkPath:     PropTypes.string,
+  targetPath:   PropTypes.string,
 };
 
 export default withTranslation()(ApiErrorMessage);

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

@@ -150,7 +150,7 @@ const PageRenameModal = (props) => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} linkPath={path} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} targetPath={pageNameInput} />
         <button type="button" className="btn btn-primary" onClick={rename}>Rename</button>
       </ModalFooter>
     </Modal>

+ 56 - 46
src/client/js/components/PageStatusAlert.jsx

@@ -25,73 +25,64 @@ class PageStatusAlert extends React.Component {
     this.state = {
     };
 
-    this.renderSomeoneEditingAlert = this.renderSomeoneEditingAlert.bind(this);
-    this.renderDraftExistsAlert = this.renderDraftExistsAlert.bind(this);
-    this.renderUpdatedAlert = this.renderUpdatedAlert.bind(this);
-  }
-
-  componentWillMount() {
-    this.props.appContainer.registerComponentInstance('PageStatusAlert', this);
+    this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
+    this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
+    this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
   }
 
   refreshPage() {
     window.location.reload();
   }
 
-  renderSomeoneEditingAlert() {
+  getContentsForSomeoneEditingAlert() {
     const { t } = this.props;
-    return (
-      <div className="alert-hackmd-someone-editing alert alert-success fixed-bottom p-3 mb-0">
+    return [
+      ['bg-success', 'd-hackmd-none'],
+      <>
         <i className="icon-fw icon-people"></i>
         {t('hackmd.someone_editing')}
-        &nbsp;
-        <i className="fa fa-angle-double-right"></i>
-        &nbsp;
-        <a href="#hackmd" className="font-weight-bold text-decoration-none">
-          <u>Open HackMD Editor</u>
-        </a>
-      </div>
-    );
+      </>,
+      <a href="#hackmd" className="btn btn-outline-white">
+        <i className="fa fa-fw fa-file-text-o mr-1"></i>
+        Open HackMD Editor
+      </a>,
+    ];
   }
 
-  renderDraftExistsAlert(isRealtime) {
+  getContentsForDraftExistsAlert(isRealtime) {
     const { t } = this.props;
-    return (
-      <div className="alert-hackmd-draft-exists alert alert-success fixed-bottom p-3 mb-0">
+    return [
+      ['bg-success', 'd-hackmd-none'],
+      <>
         <i className="icon-fw icon-pencil"></i>
         {t('hackmd.this_page_has_draft')}
-        &nbsp;
-        <i className="fa fa-angle-double-right"></i>
-        &nbsp;
-        <a href="#hackmd" className="font-weight-bold text-decoration-none">
-          <u>Open HackMD Editor</u>
-        </a>
-      </div>
-    );
+      </>,
+      <a href="#hackmd" className="btn btn-outline-white">
+        <i className="fa fa-fw fa-file-text-o mr-1"></i>
+        Open HackMD Editor
+      </a>,
+    ];
   }
 
-  renderUpdatedAlert() {
+  getContentsForUpdatedAlert() {
     const { t } = this.props;
     const label1 = t('edited this page');
     const label2 = t('Load latest');
 
-    return (
-      <div className="alert alert-warning fixed-bottom p-3 mb-0">
+    return [
+      ['bg-warning'],
+      <>
         <i className="icon-fw icon-bulb"></i>
         {this.props.pageContainer.state.lastUpdateUsername} {label1}
-        &nbsp;
-        <i className="fa fa-angle-double-right"></i>
-        &nbsp;
-        <a href="#" onClick={this.refreshPage} className="font-weight-bold text-decoration-none">
-          <u>{label2}</u>
-        </a>
-      </div>
-    );
+      </>,
+      <a href="#" className="btn btn-outline-white" onClick={this.refreshPage}>
+        <i className="icon-fw icon-reload mr-1"></i>
+        {label2}
+      </a>,
+    ];
   }
 
   render() {
-    let content = <React.Fragment></React.Fragment>;
-
     const {
       revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
     } = this.props.pageContainer.state;
@@ -99,20 +90,39 @@ class PageStatusAlert extends React.Component {
     const isRevisionOutdated = revisionId !== remoteRevisionId;
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
+    let getContentsFunc = null;
+
     // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
-      content = this.renderUpdatedAlert();
+      getContentsFunc = this.getContentsFunc;
     }
     // when someone editing with HackMD
     else if (isHackmdDraftUpdatingInRealtime) {
-      content = this.renderSomeoneEditingAlert();
+      getContentsFunc = this.getContentsForSomeoneEditingAlert;
     }
     // when the draft of HackMD is newest
     else if (hasDraftOnHackmd) {
-      content = this.renderDraftExistsAlert();
+      getContentsFunc = this.getContentsForDraftExistsAlert;
+    }
+    // do not render anything
+    else {
+      return null;
     }
 
-    return content;
+    const [additionalClasses, label, btn] = getContentsFunc();
+
+    return (
+      <div className={`card grw-page-status-alert text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
+        <div className="card-body">
+          <p className="card-text grw-card-label-container">
+            {label}
+          </p>
+          <p className="card-text grw-card-btn-container">
+            {btn}
+          </p>
+        </div>
+      </div>
+    );
   }
 
 }

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

@@ -73,7 +73,7 @@ const PutBackPageModal = (props) => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} linkPath={path} />
+        <ApiErrorMessage errorCode={errorCode} errorMessage={errorMessage} />
         <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
         </button>

+ 9 - 3
src/client/js/components/SavePageControls/GrantSelector.jsx

@@ -146,7 +146,8 @@ class GrantSelector extends React.Component {
 
       const labelElm = (
         <span>
-          <i className={`icon icon-fw ${opt.iconClass}`}></i> {t(label)}
+          <i className={`icon icon-fw ${opt.iconClass}`}></i>
+          <span className="label">{t(label)}</span>
         </span>
       );
 
@@ -161,7 +162,12 @@ class GrantSelector extends React.Component {
 
     // add specified group option
     if (grantGroup != null) {
-      const labelElm = <span><i className="icon icon-fw icon-organization"></i> {this.getGroupName()}</span>;
+      const labelElm = (
+        <span>
+          <i className="icon icon-fw icon-organization"></i>
+          <span className="label">{this.getGroupName()}</span>
+        </span>
+      );
 
       // set dropdownToggleLabelElm
       dropdownToggleLabelElm = labelElm;
@@ -171,7 +177,7 @@ class GrantSelector extends React.Component {
 
     return (
       <div className="form-group grw-grant-selector mb-0">
-        <UncontrolledDropdown direction="up" size="sm">
+        <UncontrolledDropdown direction="up">
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={this.props.disabled}>
             {dropdownToggleLabelElm}
           </DropdownToggle>

Некоторые файлы не были показаны из-за большого количества измененных файлов