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

Merge branch 'master' of https://github.com/weseek/growi into imprv/98451-PageEditorByHackmd.jsx-FC-TS

Kami-jo 3 лет назад
Родитель
Сommit
c891592efd
100 измененных файлов с 3703 добавлено и 2721 удалено
  1. 1 1
      .devcontainer/devcontainer.json
  2. 13 1
      .github/workflows/reusable-app-prod.yml
  3. 12 14
      .vscode/settings.json
  4. 120 1
      CHANGELOG.md
  5. 1 1
      lerna.json
  6. 4 4
      package.json
  7. 5 0
      packages/app/.env.development
  8. 2 2
      packages/app/bin/github-actions/update-readme.sh
  9. 1 0
      packages/app/config/ci/.env.local.for-ci
  10. 83 0
      packages/app/config/rate-limiter.ts
  11. 1 0
      packages/app/config/webpack.common.js
  12. 0 3
      packages/app/docker/Dockerfile
  13. 6 6
      packages/app/docker/README.md
  14. 17 13
      packages/app/package.json
  15. 32 0
      packages/app/public/images/customize-settings/dock-dark.svg
  16. 32 0
      packages/app/public/images/customize-settings/dock-light.svg
  17. 31 0
      packages/app/public/images/customize-settings/drawer-dark.svg
  18. 31 0
      packages/app/public/images/customize-settings/drawer-light.svg
  19. 203 2
      packages/app/public/static/locales/en_US/admin/admin.json
  20. 10 0
      packages/app/public/static/locales/en_US/translation.json
  21. 202 1
      packages/app/public/static/locales/ja_JP/admin/admin.json
  22. 10 0
      packages/app/public/static/locales/ja_JP/translation.json
  23. 203 1
      packages/app/public/static/locales/zh_CN/admin/admin.json
  24. 10 0
      packages/app/public/static/locales/zh_CN/translation.json
  25. 1 0
      packages/app/regconfig.json
  26. 2 0
      packages/app/src/client/admin.jsx
  27. 6 21
      packages/app/src/client/app.jsx
  28. 8 5
      packages/app/src/client/base.jsx
  29. 3 0
      packages/app/src/client/interfaces/clearable.ts
  30. 0 2
      packages/app/src/client/legacy/crowi.js
  31. 1 2
      packages/app/src/client/plugin.js
  32. 8 30
      packages/app/src/client/services/AppContainer.js
  33. 0 167
      packages/app/src/client/services/CommentContainer.js
  34. 55 11
      packages/app/src/client/services/ContextExtractor.tsx
  35. 3 3
      packages/app/src/client/services/PageContainer.js
  36. 0 172
      packages/app/src/client/services/PageHistoryContainer.js
  37. 0 186
      packages/app/src/client/services/PersonalContainer.js
  38. 0 113
      packages/app/src/client/services/RevisionComparerContainer.js
  39. 34 0
      packages/app/src/client/services/ShowPageAccessoriesModal.tsx
  40. 23 0
      packages/app/src/client/services/page-operation.ts
  41. 0 208
      packages/app/src/client/util/GrowiRenderer.js
  42. 0 23
      packages/app/src/client/util/PreProcessor/XssFilter.js
  43. 1 0
      packages/app/src/client/util/blink-section-header.ts
  44. 4 7
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  45. 39 41
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  46. 0 59
      packages/app/src/components/Admin/AdminHome/SystemInfomationTable.jsx
  47. 53 0
      packages/app/src/components/Admin/AdminHome/SystemInfomationTable.tsx
  48. 139 145
      packages/app/src/components/Admin/App/AppSetting.jsx
  49. 0 113
      packages/app/src/components/Admin/App/AppSettingsPageContents.jsx
  50. 108 0
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  51. 20 24
      packages/app/src/components/Admin/App/FileUploadSetting.tsx
  52. 10 11
      packages/app/src/components/Admin/App/GcsSettings.jsx
  53. 16 18
      packages/app/src/components/Admin/App/MailSetting.tsx
  54. 0 79
      packages/app/src/components/Admin/App/PluginSetting.jsx
  55. 66 0
      packages/app/src/components/Admin/App/PluginSetting.tsx
  56. 10 16
      packages/app/src/components/Admin/App/SesSetting.tsx
  57. 0 105
      packages/app/src/components/Admin/App/SiteUrlSetting.jsx
  58. 93 0
      packages/app/src/components/Admin/App/SiteUrlSetting.tsx
  59. 14 17
      packages/app/src/components/Admin/App/SmtpSetting.tsx
  60. 76 0
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  61. 26 0
      packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx
  62. 70 0
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  63. 73 0
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  64. 137 0
      packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  65. 123 0
      packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  66. 202 0
      packages/app/src/components/Admin/AuditLogManagement.tsx
  67. 12 5
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  68. 0 23
      packages/app/src/components/Admin/Common/AdminUpdateButtonRow.jsx
  69. 23 0
      packages/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx
  70. 9 12
      packages/app/src/components/Admin/Common/LabeledProgressBar.tsx
  71. 16 7
      packages/app/src/components/Admin/Customize/Customize.jsx
  72. 0 79
      packages/app/src/components/Admin/Customize/CustomizeCssSetting.jsx
  73. 68 0
      packages/app/src/components/Admin/Customize/CustomizeCssSetting.tsx
  74. 0 39
      packages/app/src/components/Admin/Customize/CustomizeFunctionOption.jsx
  75. 37 0
      packages/app/src/components/Admin/Customize/CustomizeFunctionOption.tsx
  76. 0 174
      packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.jsx
  77. 163 0
      packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.tsx
  78. 0 89
      packages/app/src/components/Admin/Customize/CustomizeHeaderSetting.jsx
  79. 76 0
      packages/app/src/components/Admin/Customize/CustomizeHeaderSetting.tsx
  80. 0 156
      packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.jsx
  81. 145 0
      packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx
  82. 4 12
      packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  83. 185 0
      packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx
  84. 0 120
      packages/app/src/components/Admin/Customize/CustomizeScriptSetting.jsx
  85. 107 0
      packages/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx
  86. 118 0
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  87. 78 80
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  88. 0 72
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.jsx
  89. 58 0
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  90. 2 3
      packages/app/src/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx
  91. 8 3
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx
  92. 0 47
      packages/app/src/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.jsx
  93. 35 0
      packages/app/src/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx
  94. 10 6
      packages/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  95. 0 46
      packages/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.jsx
  96. 36 0
      packages/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx
  97. 8 8
      packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx
  98. 0 66
      packages/app/src/components/Admin/ExportArchiveData/ArchiveFilesTable.jsx
  99. 51 0
      packages/app/src/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx
  100. 0 46
      packages/app/src/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.jsx

+ 1 - 1
.devcontainer/devcontainer.json

@@ -25,7 +25,7 @@
     "editorconfig.editorconfig",
     "esbenp.prettier-vscode",
     "shinnn.stylelint",
-    "hex-ci.stylelint-plus",
+    "stylelint.vscode-stylelint"
 	],
 
 	// Uncomment the next line if you want start specific services in your Docker Compose config.

+ 13 - 1
.github/workflows/reusable-app-prod.yml

@@ -59,7 +59,16 @@ jobs:
     - name: Archive production files
       id: archive-prod-files
       run: |
-        tar -cf production.tar packages/**/dist packages/app/public
+        tar -cf production.tar \
+          package.json \
+          packages/app/config \
+          packages/app/public \
+          packages/app/resource \
+          packages/app/tmp \
+          packages/app/migrate-mongo-config.js \
+          packages/app/.env.production* \
+          packages/*/package.json \
+          packages/*/dist
         echo ::set-output name=file::production.tar
 
     - name: Upload production files as artifact
@@ -197,6 +206,9 @@ jobs:
     steps:
     - uses: actions/checkout@v3
 
+    - name: Install fonts
+      run: sudo apt install fonts-noto
+
     - uses: actions/setup-node@v3
       with:
         node-version: ${{ matrix.node-version }}

+ 12 - 14
.vscode/settings.json

@@ -3,23 +3,21 @@
 
   "eslint.workingDirectories": [{ "mode": "auto" }],
 
-  // use stylelint-plus
-  // see https://qiita.com/y-w/items/bd7f11013fe34b69f0df#vs-code%E3%81%A8%E7%B5%84%E3%81%BF%E5%90%88%E3%82%8F%E3%81%9B%E3%82%8B
+  // use vscode-stylelint
+  // see https://github.com/stylelint/vscode-stylelint
+  "stylelint.validate": ["css", "less", "scss"],
+  "stylelint.ignoreDisables": true,
   "css.validate": false,
+  "less.validate": false,
   "scss.validate": false,
-  "[css]": {
-    "editor.formatOnSave": true
-  },
-  "[scss]": {
-    "editor.formatOnSave": true
-  },
 
-  // for vscode-eslint
-  "[javascript]": {
-    "editor.formatOnSave": false
-  },
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": true,
-    "source.fixAll.markdownlint": true
-  }
+    "source.fixAll.markdownlint": true,
+    "source.fixAll.stylelint": true
+  },
+
+  "githubPullRequests.ignoredPullRequestBranches": [
+    "master"
+  ]
 }

+ 120 - 1
CHANGELOG.md

@@ -1,9 +1,128 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.9...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.2...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.2](https://github.com/weseek/growi/compare/v5.1.1...v5.1.2) - 2022-08-03
+
+### 💎 Features
+
+- feat: Make content width of each page configurable (#6107) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv(auditlog): Clear and reload button (#6398) @miya
+- imprv(auditlog): Date Range Picker  (#6395) @miya
+
+### 🐛 Bug Fixes
+
+- fix: MathJax rendering (#6396) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Make Editor component Functional Component and TypeScript (#6374) @yukendev
+
+## [v5.1.1](https://github.com/weseek/growi/compare/v5.1.0...v5.1.1) - 2022-08-01
+
+### 💎 Features
+
+- feat: Users can set users per ip from env var at API Rate Limit  (#6379) @yukendev
+- feat: Show user picture in Audit Log (#6342) @miya
+- feat: Reset search criteria button (#6327) @miya
+
+### 🚀 Improvement
+
+- imprv(auditlog): Display number of actions that can be saved (#6353) @miya
+- imprv(auditlog): Include delete-related actions in small group (#6351) @miya
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (v5.1.0) (#6378) @yuki-takei
+- fix: Recover page path operation (#6368) @hakumizuki
+- fix: Migration script for inserting NamedQuery (#6364) @yuki-takei
+- fix: "Error: cannnot get grant label" occured with lsx (#6348) @yukendev
+
+## [v5.1.0](https://github.com/weseek/growi/compare/v5.0.11...v5.1.0) - 2022-07-21
+
+### 💎 Features
+
+- feat: Custom brand logo image (#5709) @mudana-grune
+- feat: Rate Limit by rate-limit-flexible (#6053) @yukendev
+- feat: Audit Log (#5915) @miya
+
+### 🚀 Improvement
+
+- imprv: Prevent XSS with React (#6274) @yuki-takei
+- imprv: Reflect tmp tag data (#6124) @kaoritokashiki
+- imprv: Update subscribe button icon on Navbar (#6213) @jam411
+- imprv: Event emittion by socket.io is triggered only when ES reindexing (#6077) @hirokei-camel
+
+### 🐛 Bug Fixes
+
+- fix: Drawio rendering (#6275) @hakumizuki
+- fix: Blink section header on init (#6249) @yuki-takei
+- fix: Error when trying login with an email that contains plus sign (#6232) @miya
+- fix: Use APIv3 for api get check_username (#6226) @kaoritokashiki
+- fix: Slack integration connection test (#6201) @yukendev
+- fix: Not found page for `/${ObjectId like string}` path (#6208) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Refactor PageInfo types (#6283) @yuki-takei
+- support: Refactor growi renderer using hooks 2 (#6237) @yuki-takei
+- support: Refactor growi renderer using hooks (#6223) @hakumizuki
+- imprv: Omit Personal Container (#6182) @kaoritokashiki
+
+## [v5.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
+
+### 💎 Features
+
+- feat: Integrate recount descendant count after paths fix (#6170) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Redirect when the anchor is #password (#6144) @Kami-jo
+
+### 🐛 Bug Fixes
+
+- fix: User registration page is not redirected after tmp login (#6197) @kaoritokashiki
+- fix: Empty trash doesn't work (#6168) @yukendev
+
+### 🧰 Maintenance
+
+- support: Ease rate limit temporary (#6191) @yuki-takei
+- support: Omit page history container and page revision comparer container (#6185) @yukendev
+
+## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
+
+### 💎 Features
+
+- feat: Sidebar default mode settings (#6111) @yukendev
+- feat: Get GCS instance that uses Application Default Credentials for v5 (#6051) @Yohei-Shiina
+- feat: Resume rename on server boot (#5862)(#6014) @Yohei-Shiina
+- feat: Show page item control menu on empty page (#6070)(#6103) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Show page control on subnavigation at existing empty page  (#5638) @Yohei-Shiina
+- imprv: Remove toc and page authors in empty page (#5661) @Yohei-Shiina
+- imprv: SWRize apiGet /tag.search (#6062) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Scrolling preview (#6148) @yuki-takei
+- fix: Show page history comparation modal on init (#6072) @hirokei-camel
+- fix: Ensure backword compatibility for ES6 when using max_analyzed_offset (#6121) @hakumizuki
+- fix: Set max_analyzed_offset to elasticsearch querying options (#6115) @hakumizuki
+- fix: Revision err when updating tags (#6073) @kaoritokashiki
+- fix: Support 3 types of syntax for OpenID Connect Issuer Host (#6061) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Omit comment container (#6147) @yuki-takei
+- support: Upgrade typescript to ^4.6.0 (#6082) @hakumizuki
+
 ## [v5.0.9](https://github.com/weseek/growi/compare/v5.0.8...v5.0.9) - 2022-06-13
 
 ### 🚀 Improvement

+ 1 - 1
lerna.json

@@ -1,7 +1,7 @@
 {
   "npmClient": "yarn",
   "useWorkspaces": true,
-  "version": "5.0.10-RC.0",
+  "version": "5.1.3-RC.0",
   "packages": [
     "packages/*"
   ]

+ 4 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.0.10-RC.0",
+  "version": "5.1.3-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -75,15 +75,15 @@
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",
     "reg-publish-s3-plugin": "^0.11.0",
-    "reg-suit": "^0.11.1",
+    "reg-suit": "^0.12.1",
     "rewire": "^5.0.0",
     "shipjs": "^0.24.1",
     "stylelint": "^14.2.0",
     "stylelint-config-recess-order": "^3.0.0",
     "ts-jest": "^27.0.4",
-    "ts-node": "^9.1.1",
+    "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3",
+    "typescript": "~4.7",
     "yargs": "^17.3.1"
   },
   "engines": {

+ 5 - 0
packages/app/.env.development

@@ -29,3 +29,8 @@ OGP_URI="http://ogp:8088"
 # SLACKBOT_WITHOUT_PROXY_BOT_TOKEN=''
 # GROWI_CLOUD_URI='http://growi.cloud'
 # GROWI_APP_ID_FOR_GROWI_CLOUD=012345
+# AUDIT_LOG_ENABLED=false
+# ACTIVITY_EXPIRATION_SECONDS=2592000
+# AUDIT_LOG_ACTION_GROUP_SIZE=SMALL
+# AUDIT_LOG_ADDITIONAL_ACTIONS=
+# AUDIT_LOG_EXCLUDE_ACTIONS=

+ 2 - 2
packages/app/bin/github-actions/update-readme.sh

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

+ 1 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
+MATHJAX=1

+ 83 - 0
packages/app/config/rate-limiter.ts

@@ -0,0 +1,83 @@
+export type IApiRateLimitConfig = {
+  method: string,
+  maxRequests: number,
+  usersPerIpProspection?: number,
+}
+export type IApiRateLimitEndpointMap = {
+  [endpoint: string]: IApiRateLimitConfig
+}
+
+export const DEFAULT_MAX_REQUESTS = 500;
+export const DEFAULT_DURATION_SEC = 60;
+export const DEFAULT_USERS_PER_IP_PROSPECTION = 5;
+
+const MAX_REQUESTS_TIER_1 = 5;
+const MAX_REQUESTS_TIER_2 = 20;
+const MAX_REQUESTS_TIER_3 = 50;
+const MAX_REQUESTS_TIER_4 = 100;
+
+// default config without reg exp
+export const defaultConfig: IApiRateLimitEndpointMap = {
+  '/_api/v3/healthcheck': {
+    method: 'GET',
+    maxRequests: 60,
+    usersPerIpProspection: 1,
+  },
+  '/installer': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 1,
+  },
+  '/login': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 100,
+  },
+  '/login/activateInvited': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_2,
+  },
+  '/register': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 20,
+  },
+  '/user-activation/register': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_1,
+    usersPerIpProspection: 20,
+  },
+  '/_api/login/testLdap': {
+    method: 'POST',
+    maxRequests: MAX_REQUESTS_TIER_2,
+    usersPerIpProspection: 1,
+  },
+  '/_api/check_username': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_3,
+  },
+};
+
+// default config with reg exp
+export const defaultConfigWithRegExp = {
+  '/forgot-password/.*': {
+    method: 'ALL',
+    maxRequests: MAX_REQUESTS_TIER_1,
+  },
+  '/user-activation/.*': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_1,
+  },
+  '/attachment/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+  '/download/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+  '/share/[0-9a-z]{24}': {
+    method: 'GET',
+    maxRequests: MAX_REQUESTS_TIER_4,
+  },
+};

+ 1 - 0
packages/app/config/webpack.common.js

@@ -76,6 +76,7 @@ module.exports = (options) => {
     },
     node: {
       fs: 'empty',
+      module: 'empty',
     },
     module: {
       rules: options.module.rules.concat([

+ 0 - 3
packages/app/docker/Dockerfile

@@ -111,15 +111,12 @@ RUN yarn lerna run build
 # make artifacts
 RUN tar -cf packages.tar \
   package.json \
-  tsconfig.base.json \
   packages/app/config \
   packages/app/public \
   packages/app/resource \
   packages/app/tmp \
   packages/app/migrate-mongo-config.js \
   packages/app/.env.production* \
-  packages/app/tsconfig.base.json \
-  packages/app/tsconfig.json \
   packages/*/package.json \
   packages/*/dist
 

+ 6 - 6
packages/app/docker/README.md

@@ -10,12 +10,12 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.0.9`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
-* [`5.0.9-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.9/docker/Dockerfile)
-* [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
-* [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
-* [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
-* [`4.4.13-nocdn`, `4.4-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
+* [`5.1.2`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.2/docker/Dockerfile)
+* [`5.1.2-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.2/docker/Dockerfile)
+* [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
+* [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
+* [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
+* [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 
 
 What is GROWI?

+ 17 - 13
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.10-RC.0",
+  "version": "5.1.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -10,7 +10,7 @@
     "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
-    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
+    "postbuild": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server:ci": "yarn server --ci",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
@@ -59,16 +59,17 @@
   "dependencies": {
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
-    "@browser-bunyan/console-formatted-stream": "^1.6.2",
+    "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch6": "npm:@elastic/elasticsearch@^6.8.8",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.10-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.10-RC.0",
-    "@growi/plugin-lsx": "^5.0.10-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.10-RC.0",
-    "@growi/slack": "^5.0.10-RC.0",
+    "@growi/codemirror-textlint": "^5.1.3-RC.0",
+    "@growi/core": "^5.1.3-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.3-RC.0",
+    "@growi/plugin-lsx": "^5.1.3-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.3-RC.0",
+    "@growi/slack": "^5.1.3-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -82,7 +83,7 @@
     "axios": "^0.24.0",
     "axios-retry": "^3.2.4",
     "body-parser": "^1.18.2",
-    "browser-bunyan": "^1.6.3",
+    "browser-bunyan": "^1.8.0",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
     "compression": "^1.7.4",
@@ -103,7 +104,6 @@
     "express": "^4.16.1",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
-    "express-rate-limit": "^5.3.0",
     "express-session": "^1.16.1",
     "express-validator": "^6.14.0",
     "express-webpack-assets": "^0.1.0",
@@ -141,7 +141,9 @@
     "passport-saml": "^3.2.0",
     "passport-twitter": "^1.0.4",
     "prom-client": "^13.0.0",
+    "rate-limiter-flexible": "^2.3.7",
     "react-card-flip": "^1.0.10",
+    "react-datepicker": "^4.7.0",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-image-crop": "^8.3.0",
@@ -165,11 +167,12 @@
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
     "handsontable": "v7.0.0 or above is no loger MIT lisence.",
-    "ts-loader": "v9 is not compatible with webpack@5"
+    "ts-loader": "v9 is not compatible with webpack@5",
+    "ts-node": "v10 occurs 'SyntaxError: Cannot use import statement outside a module' when using migrate-mongo"
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.10-RC.0",
+    "@growi/ui": "^5.1.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
@@ -253,7 +256,8 @@
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "ts-loader": "^8.3.0",
-    "ts-node-dev": "^1.1.6",
+    "ts-node": "^9.1.1",
+    "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9",
     "tsconfig-paths-webpack-plugin": "^3.5.1",
     "unstated": "^2.1.1",

Разница между файлами не показана из-за своего большого размера
+ 32 - 0
packages/app/public/images/customize-settings/dock-dark.svg


Разница между файлами не показана из-за своего большого размера
+ 32 - 0
packages/app/public/images/customize-settings/dock-light.svg


+ 31 - 0
packages/app/public/images/customize-settings/drawer-dark.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#16171d" opacity="0.586"/>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </g>
+  </g>
+</svg>

+ 31 - 0
packages/app/public/images/customize-settings/drawer-light.svg

@@ -0,0 +1,31 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#fff"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#abb4bd"/>
+      <rect width="42.646" height="5" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#abb4bd"/>
+      <rect width="42.646" height="5" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
+    </g>
+    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#25272f" opacity="0.271"/>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#f3f7fc"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#abb4bd"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#abb4bd"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
+    </g>
+  </g>
+</svg>

+ 203 - 2
packages/app/public/static/locales/en_US/admin/admin.json

@@ -149,6 +149,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "Default sidebar mode",
+      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
+      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
+      "dock_mode_default_open": "Open the page as it was opened from the beginning",
+      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+    },
     "layout": "Layout",
     "layout_options": {
       "default": "Default content width",
@@ -200,7 +207,13 @@
     "ctrl_space": "Ctrl+Space to autocomplete",
     "custom_script": "Custom script",
     "write_java": "You can write Javascript that is applied to whole system.",
-    "reflect_change": "You need to reload the page to reflect the change."
+    "reflect_change": "You need to reload the page to reflect the change.",
+    "custom_logo" : "Custom Logo",
+    "default_logo": "Default Logo",
+    "upload_logo": "Upload Logo",
+    "current_logo": "Current Logo",
+    "upload_new_logo": "Upload New Logo",
+    "delete_logo": "Delete Logo"
   },
   "importer_management": {
     "beta_warning": "This function is Beta.",
@@ -409,7 +422,7 @@
     }
   },
   "slack_integration_legacy": {
-    "alert_disabled": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
+    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   "user_management": {
@@ -514,5 +527,193 @@
       "force_update_parents_label": "Forcibly add missing users",
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
+  },
+  "audit_log_management": {
+    "user": "User",
+    "username": "Username",
+    "date": "Date",
+    "action": "Action",
+    "ip": "IP Address",
+    "url": "URL",
+    "settings": "Settings",
+    "return": "Return",
+    "clear": "Clear",
+    "activity_expiration_date": "Audit Log expiration date",
+    "activity_expiration_date_explain": "Created Audit Log are automatically deleted after the number of seconds set in the environment variable from the creation time",
+    "fixed_by_env_var": "This is fixed by the env var <code>{{key}}={{value}}</code>.",
+    "available_action_list": "Search / View All Available Actions",
+    "available_action_list_explain": "List of actions that can be searched/viewed in the current settings",
+    "action_list": "Action List",
+    "disable_mode_explain": "Audit log is currently disabled. To enable it, set the environment variable <code>AUDIT_LOG_ENABLED</code> to true.",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+  },
+  "audit_log_action_category": {
+    "Page": "Page",
+    "Comment": "Comment",
+    "Tag": "Tag",
+    "Attachment": "Attachment",
+    "ShareLink": "ShareLink",
+    "Search": "Search",
+    "User": "User",
+    "Admin": "Admin"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "User Creation",
+    "USER_LOGIN_WITH_LOCAL": "Login with ID/Password",
+    "USER_LOGIN_WITH_LDAP": "Login with LDAP",
+    "USER_LOGIN_WITH_GOOGLE": "Login with Google",
+    "USER_LOGIN_WITH_GITHUB": "Login with GitHub",
+    "USER_LOGIN_WITH_TWITTER": "Login with Twitter",
+    "USER_LOGIN_WITH_OIDC": "Login with OIDC",
+    "USER_LOGIN_WITH_SAML": "Login with SAML",
+    "USER_LOGIN_WITH_BASIC": "Login with BASIC",
+    "USER_LOGIN_FAILURE": "Login failure",
+    "USER_LOGOUT": "Logout",
+    "USER_FOGOT_PASSWORD": "Request password reset",
+    "USER_RESET_PASSWORD": "Reset password",
+    "USER_PERSONAL_SETTINGS_UPDATE": "User personal settings update",
+    "USER_IMAGE_TYPE_UPDATE": "User image type update",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP account associate",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "LDAP account disconnect",
+    "USER_PASSWORD_UPDATE": "Password update",
+    "USER_API_TOKEN_UPDATE": "API Token update",
+    "USER_EDITOR_SETTINGS_UPDATE": "Editor settings update",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "In-App Notification settings update",
+    "PAGE_VIEW": "Page view",
+    "PAGE_USER_HOME_VIEW": "Page view (User home)",
+    "PAGE_FORBIDDEN": "Page view (Fobidden page)",
+    "PAGE_NOT_FOUND": "Page view (Not found page)",
+    "PAGE_NOT_CREATABLE": "Page view(Not Creatable page)",
+    "PAGE_LIKE": "Like",
+    "PAGE_UNLIKE": "Unlike",
+    "PAGE_BOOKMARK": "Bookmark",
+    "PAGE_UNBOOKMARK": "Unbookmark",
+    "PAGE_CREATE": "Create page",
+    "PAGE_UPDATE": "Update page",
+    "PAGE_RENAME": "Rename page",
+    "PAGE_DUPLICATE": "Duplicate page",
+    "PAGE_DELETE": "Delete page",
+    "PAGE_DELETE_COMPLETELY": "Delete completely page",
+    "PAGE_REVERT": "Revert page",
+    "PAGE_EMPTY_TRASH": "Empty trash",
+    "PAGE_SUBSCRIBE": "Subscribe page",
+    "PAGE_UNSUBSCRIBE": "Unsubscribe page",
+    "PAGE_EXPORT": "Export page",
+    "TAG_UPDATE": "Update tags",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "Read all In-App notifications",
+    "COMMENT_CREATE": "Create comment",
+    "COMMENT_UPDATE": "Update comment",
+    "COMMENT_REMOVE": "Delete comment",
+    "SHARE_LINK_CREATE": "Create Share link",
+    "SHARE_LINK_DELETE": "Delete Share link",
+    "SHARE_LINK_DELETE_BY_PAGE": "Remove all shared links on the page",
+    "SHARE_LINK_ALL_DELETE": "Delete all share link",
+    "SHARE_LINK_PAGE_VIEW": "Page view(Share link)",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "Page view(Expired share link)",
+    "SHARE_LINK_NOT_FOUND": "Page view (Not found share link)",
+    "ATTACHMENT_ADD": "Add Attachment",
+    "ATTACHMENT_REMOVE": "Delete Attachment",
+    "ACTION_ATTACHMENT_DOWNLOAD": "Download Attachment",
+    "SEARCH_PAGE": "Page Search",
+    "SEARCH_PAGE_VIEW": "Page view(Search results page)",
+    "ADMIN_APP_SETTING_UPDATE": "Update App Settings",
+    "ADMIN_SITE_URL_UPDATE": "Update Site URL Settings",
+    "ADMIN_MAIL_SMTP_UPDATE": "Update E-mail(SMTP) Settings",
+    "ADMIN_MAIL_SES_UPDATE": "Update E-mail(SES) Settings",
+    "ADMIN_MAIL_TEST_SUBMIT" : "Send test mail",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "Update File Upload Settings",
+    "ADMIN_PLUGIN_UPDATE": "Update Plugin Settings",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "Enable Maintenance Mode",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "Disabled Maintenance Mode",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "Update Security Settings",
+    "ADMIN_PERMIT_SHARE_LINK": "Enable Share Link",
+    "ADMIN_REJECT_SHARE_LINK": "Disable Share Link",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "Enable ID/Password auth",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "Disable ID/Password auth",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "Update ID/Password auth settings",
+    "ADMIN_AUTH_LDAP_ENABLED": "Enable LDAP auth",
+    "ADMIN_AUTH_LDAP_DISABLED": "Disable LDAP auth",
+    "ADMIN_AUTH_LDAP_UPDATE": "Update LDAP auth settings",
+    "ADMIN_AUTH_SAML_ENABLED": "Enable SAML auth",
+    "ADMIN_AUTH_SAML_DISABLED": "Disable SAML auth",
+    "ADMIN_AUTH_SAML_UPDATE": "Update SAML auth settings",
+    "ADMIN_AUTH_OIDC_ENABLED": "Enable OIDC auth",
+    "ADMIN_AUTH_OIDC_DISABLED": "Disable OIDC auth",
+    "ADMIN_AUTH_OIDC_UPDATE": "Update OIDC settings",
+    "ADMIN_AUTH_BASIC_ENABLED": "Enable BASIC auth",
+    "ADMIN_AUTH_BASIC_DISABLED": "Disable BASIC auth",
+    "ADMIN_AUTH_BASIC_UPDATE": "Update BASIC auth settings",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "Enable Google auth",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "Disable Google auth",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "Update Google auth settings",
+    "ADMIN_AUTH_GITHUB_ENABLED": "Enable GitHub auth",
+    "ADMIN_AUTH_GITHUB_DISABLED": "Disable GitHub auth",
+    "ADMIN_AUTH_GITHUB_UPDATE": "Update GitHub auth settings",
+    "ADMIN_AUTH_TWITTER_ENABLED": "Enable Twitter auth",
+    "ADMIN_AUTH_TWITTER_DISABLED": "Disable Twitter auth",
+    "ADMIN_AUTH_TWITTER_UPDATE": "Update Twitter auth settings",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "Update Link Break settings",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "Update Indent settings",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "Update Presentation setting",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "Update prevent XSS settings",
+    "ADMIN_LAYOUT_UPDATE": "Update Layout",
+    "ADMIN_THEME_UPDATE": "Update Theme",
+    "ADMIN_SIDEBAR_UPDATE": "Update Default Sidebar mode",
+    "ADMIN_FUNCTION_UPDATE": "Update Function",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "Update Code Highlight",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "Update Custom Title",
+    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "Update Custom HTML header",
+    "ADMIN_CUSTOM_CSS_UPDATE": "Update Custom CSS",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "Update Custom script",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",
+    "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Discard Archived Data",
+    "ADMIN_ESA_DATA_IMPORTED": "Import from esa.io",
+    "ADMIN_ESA_DATA_UPDATED": "Update esa.io import settings",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Test connection to esa",
+    "ADMIN_QIITA_DATA_IMPORTED": "Import from Qiita:Team",
+    "ADMIN_QIITA_DATA_UPDATED": "Update Qiita:Team import settings",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Test connection to Qiita:Team",
+    "ADMIN_ARCHIVE_DATA_CREATE": "Create Archived Data",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data",
+    "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "Add User trigger notification notification settings",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "Delete User trigger notification notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "Add Grobal notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "Update Grobal notification settings",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "Update Grobal notification permissions",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "Enable Grobal notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "Disable Grobal notification settings",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "Delete Grobal notification settings",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "Add Slack Workspace",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "Delete Slack Workspace",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "Change Slack bot type",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "Delete Slack bot type",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "Regenerate Slack access token",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "Make the Slack bot primary",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "Update Slack bot permissions",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "Update Proxy URL for Custom bot with proxy",
+    "ADMIN_SLACK_RELATION_TEST": "Test connection to slack bot",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "Update Slack bot without proxy settings",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "Update Slack bot without proxy permissions",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Test connection to Slack bot without proxy",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Update Slack Incoming Webhooks configuration",
+    "ADMIN_USERS_INVITE": "User Invitation",
+    "ADMIN_USERS_PASSWORD_RESET": "Reset user password",
+    "ADMIN_USERS_ACTIVATE": "Activate user",
+    "ADMIN_USERS_DEACTIVATE": "Deactivate user",
+    "ADMIN_USERS_GIVE_ADMIN": "Give admin access",
+    "ADMIN_USERS_REMOVE_ADMIN": "Remove admin access",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "Resend invitation email",
+    "ADMIN_USERS_REMOVE": "Remove user",
+    "ADMIN_USER_GROUP_CREATE": "Create User Group",
+    "ADMIN_USER_GROUP_UPDATE": "Update User Group",
+    "ADMIN_USER_GROUP_DELETE": "Delete User Group",
+    "ADMIN_USER_GROUP_ADD_USER": "Add User to User Group",
+    "ADMIN_SEARCH_CONNECTION": "Attempting to reconnect to Elasticsearch",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "Normalize of Elasticsearch indexes",
+    "ADMIN_SEARCH_INDICES_REBUILD": "Rebuild Elasticsearch indexes"
   }
 }

+ 10 - 0
packages/app/public/static/locales/en_US/translation.json

@@ -126,6 +126,8 @@
   "UserGroup": "UserGroup",
   "ChildUserGroup": "ChildUserGroup",
   "UserGroup Management": "UserGroup Management",
+  "AuditLog": "Audit Log",
+  "AuditLog Settings": "Audit Log Settings",
   "Full Text Search Management": "Full Text Search Management",
   "Import Data": "Import Data",
   "Export Archive Data": "Export Archive Data",
@@ -162,6 +164,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
+  "wide_view": "Wide View",
   "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
@@ -1081,11 +1084,18 @@
     "belonging_to_no_group": "Could not find the groups you belong to.",
     "manage_user_groups": "Manage user groups"
   },
+  "crop_image_modal": {
+    "image_crop": "Image Crop",
+    "crop": "Crop",
+    "reset": "Reset",
+    "cancel": "Cancel"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "The list of selectable permissions could not be found. Please modify the permissions on the parent page first and try again.",
       "need_to_fix_grant": "The permissions associated with this page must be modified in order to use the functionality correctly. <br> Please select from the options below to make the change.",
       "grant_label": {
+        "public": "Public",
         "isForbidden": "Authority not allowed to view",
         "currentPageGrantLabel": "Authorization for this page: ",
         "parentPageGrantLabel": "Authority of parent page: ",

+ 202 - 1
packages/app/public/static/locales/ja_JP/admin/admin.json

@@ -149,6 +149,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "デフォルトのサイドバーモード",
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
+      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
+      "dock_mode_default_open": "初めから開いた状態でページを開く",
+      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+    },
     "layout": "レイアウト",
     "layout_options": {
       "default": "デフォルトのコンテンツ幅",
@@ -200,7 +207,13 @@
     "ctrl_space": "Ctrl+Space でコード補完",
     "custom_script": "カスタムスクリプト",
     "write_java": "システム全体に適用されるJavaScriptを記述できます。",
-    "reflect_change": "変更の反映はページの更新が必要です。"
+    "reflect_change": "変更の反映はページの更新が必要です。",
+    "custom_logo": "カスタムロゴ",
+    "default_logo": "デフォルトのロゴ",
+    "upload_logo": "ロゴをアップロード",
+    "current_logo": "現在のロゴ",
+    "upload_new_logo": "新しいロゴをアップロードする",
+    "delete_logo": "ロゴを削除"
   },
   "export_management": {
     "exporting_collection_list": "エクスポート中のコレクション",
@@ -513,5 +526,193 @@
       "force_update_parents_label": "強制的に足りないユーザーを追加する",
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
+  },
+  "audit_log_management": {
+    "user": "ユーザー",
+    "username": "ユーザー名",
+    "date": "日付",
+    "action": "アクション",
+    "ip": "IPアドレス",
+    "url": "URL",
+    "settings": "設定",
+    "return": "戻る",
+    "clear": "クリア",
+    "activity_expiration_date": "監査ログの有効期限",
+    "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
+    "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
+    "available_action_list": "検索 / 表示 可能なアクション一覧",
+    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
+    "action_list": "アクション一覧",
+    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/ja/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+  },
+  "audit_log_action_category": {
+    "Page": "ページ",
+    "Comment": "コメント",
+    "Tag": "タグ",
+    "Attachment": "添付ファイル",
+    "ShareLink": "シェアリンク",
+    "Search": "検索",
+    "User": "ユーザー",
+    "Admin": "管理者ユーザー"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "ユーザー作成",
+    "USER_LOGIN_WITH_LOCAL": "ID/Password 認証でログイン",
+    "USER_LOGIN_WITH_LDAP": "LDAP 認証でログイン",
+    "USER_LOGIN_WITH_GOOGLE": "Google 認証でログイン",
+    "USER_LOGIN_WITH_GITHUB": "GitHub 認証でログイン",
+    "USER_LOGIN_WITH_TWITTER": "Twitter 認証でログイン",
+    "USER_LOGIN_WITH_OIDC": "OIDC 認証でログイン",
+    "USER_LOGIN_WITH_SAML": "SAML 認証でログイン",
+    "USER_LOGIN_WITH_BASIC": "BASIC 認証でログイン",
+    "USER_LOGIN_FAILURE": "ログイン失敗",
+    "USER_LOGOUT": "ログアウト",
+    "USER_FOGOT_PASSWORD": "パスワードリセットのリクエスト",
+    "USER_RESET_PASSWORD": "パスワードのリセット",
+    "USER_PERSONAL_SETTINGS_UPDATE": "ユーザーの基本情報の更新",
+    "USER_IMAGE_TYPE_UPDATE": "プロフィール画像の変更",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP アカウントの追加",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "LDAP アカウントの切断",
+    "USER_PASSWORD_UPDATE": "パスワードの変更",
+    "USER_API_TOKEN_UPDATE": "API トークンの更新",
+    "USER_EDITOR_SETTINGS_UPDATE": "エディター設定の更新",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "アプリ内通知設定の更新",
+    "PAGE_VIEW": "ページ閲覧",
+    "PAGE_USER_HOME_VIEW": "ページ閲覧(ユーザーホーム)",
+    "PAGE_FORBIDDEN": "ページ閲覧(fobiddenページ)",
+    "PAGE_NOT_FOUND": "ページ閲覧(Not Found ページ)",
+    "PAGE_NOT_CREATABLE": "ページ閲覧(Not Creatable ページ)",
+    "PAGE_LIKE": "いいね",
+    "PAGE_UNLIKE": "いいねの解除",
+    "PAGE_BOOKMARK": "ブックマーク",
+    "PAGE_UNBOOKMARK": "ブックマークの解除",
+    "PAGE_CREATE": "ページの作成",
+    "PAGE_UPDATE": "ページの更新",
+    "PAGE_RENAME": "ページのリネーム",
+    "PAGE_DUPLICATE": "ページの複製",
+    "PAGE_DELETE": "ページの削除",
+    "PAGE_DELETE_COMPLETELY": "ページの完全削除",
+    "PAGE_REVERT": "ページを元に戻す",
+    "PAGE_EMPTY_TRASH": "ゴミ箱を空にする",
+    "PAGE_SUBSCRIBE": "ページをサブスクライブ",
+    "PAGE_UNSUBSCRIBE": "ページをアンサブスクライブ",
+    "PAGE_EXPORT": "マークダウン形式でページをエクスポート",
+    "TAG_UPDATE": "タグの更新",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "アプリ内通知を全て既読",
+    "COMMENT_CREATE": "コメントの作成",
+    "COMMENT_UPDATE": "コメントの更新",
+    "COMMENT_REMOVE": "コメントの削除",
+    "SHARE_LINK_CREATE": "共有リンクの作成",
+    "SHARE_LINK_DELETE": "共有リンクの削除",
+    "SHARE_LINK_DELETE_BY_PAGE": "ページ内の共有リンクを全て削除",
+    "SHARE_LINK_ALL_DELETE": "共有リンクを全て削除",
+    "SHARE_LINK_PAGE_VIEW": "ページ閲覧(共有リンク)",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "ページ閲覧(期限切れの共有リンク)",
+    "SHARE_LINK_NOT_FOUND": "ページ閲覧(存在しない共有リンク)",
+    "ATTACHMENT_ADD": "添付データの追加",
+    "ATTACHMENT_REMOVE": "添付データの削除",
+    "ACTION_ATTACHMENT_DOWNLOAD": "添付データのダウンロード",
+    "SEARCH_PAGE": "ページの検索",
+    "SEARCH_PAGE_VIEW": "ページ閲覧(検索結果ページ)",
+    "ADMIN_APP_SETTING_UPDATE": "アプリ設定の更新",
+    "ADMIN_SITE_URL_UPDATE": "サイトURL設定の更新",
+    "ADMIN_MAIL_SMTP_UPDATE": "メール設定(SMTP)の更新",
+    "ADMIN_MAIL_SES_UPDATE": "メール設定(SES)の更新",
+    "ADMIN_MAIL_TEST_SUBMIT" : "テストメールの送信",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "ファイルアップロード設定の更新",
+    "ADMIN_PLUGIN_UPDATE": "プラグイン設定の更新",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "メンテナンスモードの開始",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "メンテナンスモードの終了",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "セキュリティ設定の更新",
+    "ADMIN_PERMIT_SHARE_LINK": "共有リンクの有効化",
+    "ADMIN_REJECT_SHARE_LINK": "共有リンクの無効化",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "ID/Password 認証を有効",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "ID/Password 認証を無効",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "ID/Password 認証設定を更新",
+    "ADMIN_AUTH_LDAP_ENABLED": "LDAP 認証を有効",
+    "ADMIN_AUTH_LDAP_DISABLED": "LDAP 認証を無効",
+    "ADMIN_AUTH_LDAP_UPDATE": "LDAP 認証設定を更新",
+    "ADMIN_AUTH_SAML_ENABLED": "SAML 認証を有効",
+    "ADMIN_AUTH_SAML_DISABLED": "SAML 認証を無効",
+    "ADMIN_AUTH_SAML_UPDATE": "SAML 認証設定を更新",
+    "ADMIN_AUTH_OIDC_ENABLED": "OIDC 認証を有効",
+    "ADMIN_AUTH_OIDC_DISABLED": "OIDC 認証を無効",
+    "ADMIN_AUTH_OIDC_UPDATE": "OIDC 認証設定の更新",
+    "ADMIN_AUTH_BASIC_ENABLED": "BASIC 認証の有効",
+    "ADMIN_AUTH_BASIC_DISABLED": "BASIC 認証の無効",
+    "ADMIN_AUTH_BASIC_UPDATE": "BASIC 認証設定の更新",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "Google 認証の有効",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "Google 認証の無効",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "Google 認証設定の更新",
+    "ADMIN_AUTH_GITHUB_ENABLED": "GitHub 認証の有効",
+    "ADMIN_AUTH_GITHUB_DISABLED": "GitHub 認証の無効",
+    "ADMIN_AUTH_GITHUB_UPDATE": "GitHub 認証設定の更新",
+    "ADMIN_AUTH_TWITTER_ENABLED": "Twitter 認証の有効",
+    "ADMIN_AUTH_TWITTER_DISABLED": "Twitter 認証の無効",
+    "ADMIN_AUTH_TWITTER_UPDATE": "Twitter 認証設定の更新",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "Line Break 設定の更新",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "インデント設定の更新",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "プレゼンテーション設定の更新",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "XSS 対策設定の更新",
+    "ADMIN_LAYOUT_UPDATE": "レイアウト設定の更新",
+    "ADMIN_THEME_UPDATE": "テーマ設定の更新",
+    "ADMIN_SIDEBAR_UPDATE": "デフォルトのサイドバーモードの設定の更新",
+    "ADMIN_FUNCTION_UPDATE": "機能設定の更新",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "コードハイライト設定の更新",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "カスタムタイトル設定の更新",
+    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "カスタム HTML Header 設定の更新",
+    "ADMIN_CUSTOM_CSS_UPDATE": "カスタム CSS 設定の更新",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "カスタムスクリプト設定の更新",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",
+    "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "アーカイブデータの破棄",
+    "ADMIN_ESA_DATA_IMPORTED": "esa.io からインポート",
+    "ADMIN_ESA_DATA_UPDATED": "esa.io のインポート設定の更新",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa.io の接続テスト",
+    "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team からのインポート",
+    "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team のインポート設定の更新",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team の接続テスト",
+    "ADMIN_ARCHIVE_DATA_CREATE": "アーカイブデータの作成",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード",
+    "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "User trigger notification の通知設定の追加",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "User trigger notification の通知設定の削除",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "Grobal notification の設定の追加",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "Grobal notification の設定の更新",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "Grobal notification の権限の更新",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "Grobal notification の設定の有効",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "Grobal notification の設定の無効",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "Grobal notification の設定の削除",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "Slack ワークスペースの追加",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "Slack ワークスペースの削除",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "Slack bot タイプの変更",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "Slack bot タイプの削除",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "Slack アクセストークンの再発行",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "Slack bot をプライマリーにする",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "Slack bot の権限の更新",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "Custom bot with proxy の Proxy URL の更新",
+    "ADMIN_SLACK_RELATION_TEST": "Slack bot の接続テスト",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "Slack bot without proxy の設定の更新",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "Slack bot without proxy の権限の更新",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Slack bot without proxy の接続テスト",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Slack Incoming Webhooks の設定の更新",
+    "ADMIN_USERS_INVITE": "ユーザーの招待",
+    "ADMIN_USERS_PASSWORD_RESET": "ユーザーのパスワードをリセット",
+    "ADMIN_USERS_ACTIVATE": "ユーザーを承認する",
+    "ADMIN_USERS_DEACTIVATE": "ユーザーを停止する",
+    "ADMIN_USERS_GIVE_ADMIN": "管理者にする",
+    "ADMIN_USERS_REMOVE_ADMIN": "管理者から外す",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "招待メールの再送信",
+    "ADMIN_USERS_REMOVE": "ユーザーの削除",
+    "ADMIN_USER_GROUP_CREATE": "ユーザーグループの作成",
+    "ADMIN_USER_GROUP_UPDATE": "ユーザーグループの更新",
+    "ADMIN_USER_GROUP_DELETE": "ユーザーグループの削除",
+    "ADMIN_USER_GROUP_ADD_USER": "ユーザーグループにユーザーを追加",
+    "ADMIN_SEARCH_CONNECTION": "Elasticsearch の再接続の試行",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "Elasticsearch のインデックスの正規化",
+    "ADMIN_SEARCH_INDICES_REBUILD": "Elasticsearch のインデックスのリビルド"
   }
 }

+ 10 - 0
packages/app/public/static/locales/ja_JP/translation.json

@@ -126,6 +126,8 @@
   "UserGroup": "グループ",
   "ChildUserGroup": "子グループ",
   "UserGroup Management": "グループ管理",
+  "AuditLog": "監査ログ",
+  "AuditLog Settings": "監査ログ設定",
   "Full Text Search Management": "全文検索管理",
   "Import Data": "データインポート",
   "Export Archive Data": "データアーカイブ",
@@ -164,6 +166,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
@@ -1074,11 +1077,18 @@
     "belonging_to_no_group": "所属しているグループが見つかりませんでした。",
     "manage_user_groups": "グループ管理"
   },
+  "crop_image_modal": {
+    "image_crop": "画像の切り抜き",
+    "crop": "トリミング",
+    "reset": "リセット",
+    "cancel": "キャンセル"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "選択可能な権限のリストが見つかりませんでした。まず親ページの権限を修正したのちに再試行してください。",
       "need_to_fix_grant": "正しく機能を使用するためにはこのページに紐づく権限を修正する必要があります。 <br> 下記の選択肢から選んで変更してください。",
       "grant_label": {
+        "public": "公開",
         "isForbidden": "権限の閲覧が許可されていません",
         "currentPageGrantLabel": "このページの権限: ",
         "parentPageGrantLabel": "親のページの権限: ",

+ 203 - 1
packages/app/public/static/locales/zh_CN/admin/admin.json

@@ -148,6 +148,13 @@
     }
   },
   "customize_setting": {
+    "default_sidebar_mode": {
+      "title": "默认的侧边栏模式",
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
+      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
+      "dock_mode_default_open": "从头开始翻页",
+      "dock_mode_default_close": "从头开始打开关闭的页面"
+    },
     "layout": "布局",
     "layout_options": {
       "default": "默认内容宽度 ",
@@ -210,7 +217,13 @@
     "ctrl_space": "Ctrl+Space 自动完成",
     "custom_script": "定制纸条",
     "write_java": "您可以编写应用于整个系统的Javascript。",
-    "reflect_change": "您需要重新加载页面以反映更改。"
+    "reflect_change": "您需要重新加载页面以反映更改。",
+    "custom_logo": "自定义徽标",
+    "default_logo": "默认徽标",
+    "upload_logo": "上传徽标",
+    "current_logo": "当前标志",
+    "upload_new_logo": "上传新徽标",
+    "delete_logo": "删除徽标"
   },
   "importer_management": {
     "beta_warning": "这个函数是Beta。",
@@ -523,5 +536,194 @@
       "force_update_parents_label": "强行添加失踪的用户",
       "force_update_parents_description": "激活这个选项,如果在父组改变后,在祖先组中有缺失的用户,可以强制添加这些用户"
     }
+  },
+  "audit_log_management": {
+    "user": "用户",
+    "username": "帐号",
+    "date": "日期",
+    "action": "行动",
+    "ip": "IP地址",
+    "url": "URL",
+    "settings": "设置",
+    "return": "返回",
+    "clear": "清除",
+    "activity_expiration_date": "审计日志的到期日",
+    "activity_expiration_date_explain": "创建的审计日志会在环境变量中设置的从创建时间算起的秒数后自动删除",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "available_action_list": "搜索/查看 所有可用的行动",
+    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
+    "action_list": "行动清单",
+    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+
+  },
+  "audit_log_action_category": {
+    "Page": "页面",
+    "Comment": "评论",
+    "Tag": "标签",
+    "Attachment": "附件",
+    "ShareLink": "分享链接",
+    "Search": "搜索",
+    "User": "用户",
+    "Admin": "管理"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "用户创建",
+    "USER_LOGIN_WITH_LOCAL": "用ID/密码登录",
+    "USER_LOGIN_WITH_LDAP": "使用 LDAP 登录",
+    "USER_LOGIN_WITH_GOOGLE": "用谷歌登录",
+    "USER_LOGIN_WITH_GITHUB": "使用 GitHub 登录",
+    "USER_LOGIN_WITH_TWITTER": "使用 Twitter 登录",
+    "USER_LOGIN_WITH_OIDC": "使用 OIDC 登录",
+    "USER_LOGIN_WITH_SAML": "使用 SAML 登录",
+    "USER_LOGIN_WITH_BASIC": "使用 BASIC 登录",
+    "USER_LOGIN_FAILURE": "登录失败",
+    "USER_LOGOUT": "注销",
+    "USER_FOGOT_PASSWORD": "要求重置密码",
+    "USER_RESET_PASSWORD": "重置密码",
+    "USER_PERSONAL_SETTINGS_UPDATE": "用户个人设置更新",
+    "USER_IMAGE_TYPE_UPDATE": "用户图片类型更新",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "LDAP 帐户关联",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "LDAP 账户断开连接",
+    "USER_PASSWORD_UPDATE": "密码更新",
+    "USER_API_TOKEN_UPDATE": "API 令牌更新",
+    "USER_EDITOR_SETTINGS_UPDATE": "编辑器设置更新",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "应用内通知设置更新",
+    "PAGE_VIEW": "页面浏览量",
+    "PAGE_USER_HOME_VIEW": "页面浏览量(用户主页)",
+    "PAGE_FORBIDDEN": "页面浏览量(禁止页面)",
+    "PAGE_NOT_FOUND": "页面查看(未找到页面)",
+    "PAGE_NOT_CREATABLE": "页面浏览量(不可创建页面)",
+    "PAGE_LIKE": "喜欢",
+    "PAGE_UNLIKE": "不喜欢",
+    "PAGE_BOOKMARK": "书签",
+    "PAGE_UNBOOKMARK": "取消书签",
+    "PAGE_CREATE": "创建页面",
+    "PAGE_UPDATE": "更新页面",
+    "PAGE_RENAME": "重命名页面",
+    "PAGE_DUPLICATE": "重复页面",
+    "PAGE_DELETE": "删除页面",
+    "PAGE_DELETE_COMPLETELY": "彻底删除页面",
+    "PAGE_REVERT": "还原页面",
+    "PAGE_EMPTY_TRASH": "清空垃圾箱",
+    "PAGE_SUBSCRIBE": "订阅页面",
+    "PAGE_UNSUBSCRIBE": "退订页面",
+    "PAGE_EXPORT": "导出页面",
+    "TAG_UPDATE": "更新标签",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "读取所有应用内通知",
+    "COMMENT_CREATE": "创建评论",
+    "COMMENT_UPDATE": "更新评论",
+    "COMMENT_REMOVE": "删除评论",
+    "SHARE_LINK_CREATE": "创建分享链接",
+    "SHARE_LINK_DELETE": "删除分享链接",
+    "SHARE_LINK_DELETE_BY_PAGE": "删除页面上的所有共享链接",
+    "SHARE_LINK_ALL_DELETE": "删除所有分享链接",
+    "SHARE_LINK_PAGE_VIEW": "页面浏览量(分享链接)",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "页面浏览量(已过期的分享链接)",
+    "SHARE_LINK_NOT_FOUND": "页面浏览量(未找到分享链接)",
+    "ATTACHMENT_ADD": "添加附件",
+    "ATTACHMENT_REMOVE": "删除附件",
+    "ACTION_ATTACHMENT_DOWNLOAD": "下载附件",
+    "SEARCH_PAGE": "页面搜索",
+    "SEARCH_PAGE_VIEW": "页面浏览量(搜索结果页面)",
+    "ADMIN_APP_SETTING_UPDATE": "更新应用设置",
+    "ADMIN_SITE_URL_UPDATE": "更新站点 URL 设置",
+    "ADMIN_MAIL_SMTP_UPDATE": "更新电子邮件(SMTP)设置",
+    "ADMIN_MAIL_SES_UPDATE": "更新电子邮件(SES)设置",
+    "ADMIN_MAIL_TEST_SUBMIT" : "发送测试邮件",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "更新文件上传设置",
+    "ADMIN_PLUGIN_UPDATE": "更新插件设置",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "启用维护模式",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "禁用维护模式",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "更新安全设置",
+    "ADMIN_PERMIT_SHARE_LINK": "启用分享链接",
+    "ADMIN_REJECT_SHARE_LINK": "禁用分享链接",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "启用 ID/密码验证",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "禁用 ID/密码验证",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "更新 ID/密码验证设置",
+    "ADMIN_AUTH_LDAP_ENABLED": "启用 LDAP 身份验证",
+    "ADMIN_AUTH_LDAP_DISABLED": "禁用 LDAP 身份验证",
+    "ADMIN_AUTH_LDAP_UPDATE": "更新 LDAP 身份验证设置",
+    "ADMIN_AUTH_SAML_ENABLED": "启用 SAML 身份验证",
+    "ADMIN_AUTH_SAML_DISABLED": "禁用 SAML 身份验证",
+    "ADMIN_AUTH_SAML_UPDATE": "更新 SAML 身份验证设置",
+    "ADMIN_AUTH_OIDC_ENABLED": "启用 OIDC 身份验证",
+    "ADMIN_AUTH_OIDC_DISABLED": "禁用 OIDC 身份验证",
+    "ADMIN_AUTH_OIDC_UPDATE": "更新 OIDC 设置",
+    "ADMIN_AUTH_BASIC_ENABLED": "启用基本身份验证",
+    "ADMIN_AUTH_BASIC_DISABLED": "禁用基本身份验证",
+    "ADMIN_AUTH_BASIC_UPDATE": "更新基本认证设置",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "启用谷歌身份验证",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "禁用谷歌身份验证",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "更新谷歌授权设置",
+    "ADMIN_AUTH_GITHUB_ENABLED": "启用 GitHub 身份验证",
+    "ADMIN_AUTH_GITHUB_DISABLED": "禁用 GitHub 身份验证",
+    "ADMIN_AUTH_GITHUB_UPDATE": "更新 GitHub 授权设置",
+    "ADMIN_AUTH_TWITTER_ENABLED": "启用 Twitter 身份验证",
+    "ADMIN_AUTH_TWITTER_DISABLED": "禁用 Twitter 身份验证",
+    "ADMIN_AUTH_TWITTER_UPDATE": "更新 Twitter 授权设置",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "更新链接中断设置",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "更新缩进设置",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "更新演示设置",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "更新阻止 XSS 设置",
+    "ADMIN_LAYOUT_UPDATE": "更新布局",
+    "ADMIN_THEME_UPDATE": "更新主题",
+    "ADMIN_SIDEBAR_UPDATE": "更新默认的侧边栏模式",
+    "ADMIN_FUNCTION_UPDATE": "更新函数",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "更新代码高亮",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "更新自定义标题",
+    "ADMIN_CUSTOM_HTML_HEADER_UPDATE": "更新自定义 HTML 标头",
+    "ADMIN_CUSTOM_CSS_UPDATE": "更新自定义 CSS",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "更新自定义脚本",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",
+    "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "丢弃存档数据",
+    "ADMIN_ESA_DATA_IMPORTED": "从 esa.io 导入",
+    "ADMIN_ESA_DATA_UPDATED": "更新 esa.io 导入设置",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "测试与 esa 的连接",
+    "ADMIN_QIITA_DATA_IMPORTED": "从 Qiita:Team 导入",
+    "ADMIN_QIITA_DATA_UPDATED": "更新 Qiita:团队导入设置",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "测试与 Qiita:Team 的连接",
+    "ADMIN_ARCHIVE_DATA_CREATE": "创建归档数据",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据",
+    "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "添加用户触发通知通知设置",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "删除用户触发通知通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "添加全局通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "更新 Grobal 通知设置",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "更新 Grobal 通知权限",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "启用 Grobal 通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "禁用 Grobal 通知设置",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "删除 Grobal 通知设置",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "添加松弛工作区",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "删除 Slack 工作区",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "更改 Slack 机器人类型",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "删除 Slack 机器人类型",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "重新生成 Slack 访问令牌",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "将 Slack 机器人设为主要",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "更新 Slack 机器人权限",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "使用代理更新自定义机器人的代理 URL",
+    "ADMIN_SLACK_RELATION_TEST": "测试与 slack 机器人的连接",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "在没有代理设置的情况下更新 Slack 机器人",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "更新没有代理权限的 Slack 机器人",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "在没有代理的情况下测试与 Slack 机器人的连接",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "更新 Slack Incoming Webhooks 配置",
+    "ADMIN_USERS_INVITE": "用户邀请",
+    "ADMIN_USERS_PASSWORD_RESET": "重置用户密码",
+    "ADMIN_USERS_ACTIVATE": "激活用户",
+    "ADMIN_USERS_DEACTIVATE": "停用用户",
+    "ADMIN_USERS_GIVE_ADMIN": "授予管理员访问权限",
+    "ADMIN_USERS_REMOVE_ADMIN": "删除管理员访问权限",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "重发邀请函",
+    "ADMIN_USERS_REMOVE": "删除用户",
+    "ADMIN_USER_GROUP_CREATE": "创建用户组",
+    "ADMIN_USER_GROUP_UPDATE": "更新用户组",
+    "ADMIN_USER_GROUP_DELETE": "删除用户组",
+    "ADMIN_USER_GROUP_ADD_USER": "添加用户到用户组",
+    "ADMIN_SEARCH_CONNECTION": "重试Elasticsearch连接",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "试图重新连接Elasticsearch",
+    "ADMIN_SEARCH_INDICES_REBUILD": "重建 Elasticsearch 索引"
   }
 }

+ 10 - 0
packages/app/public/static/locales/zh_CN/translation.json

@@ -134,6 +134,8 @@
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
 	"UserGroup Management": "用户组管理",
+  "AuditLog": "审计日志",
+  "AuditLog Settings": "审计日志设置",
 	"Full Text Search Management": "全文搜索管理",
 	"Import Data": "导入数据",
 	"Export Archive Data": "导出主题数据",
@@ -170,6 +172,7 @@
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
@@ -1084,11 +1087,18 @@
     "belonging_to_no_group": "无法找到你所属的团体。",
     "manage_user_groups": "管理用户组"
   },
+  "crop_image_modal": {
+    "image_crop": "图像裁剪",
+    "crop": "修剪",
+    "reset": "重启",
+    "cancel": "取消"
+  },
   "fix_page_grant": {
     "modal": {
       "no_grant_available": "无法找到可选择的权限列表。 请先修改父页的权限,然后再试一次。",
       "need_to_fix_grant": "为了正确使用该功能,需要修改与该页面相关的权限。 <br> 请从以下选项中选择进行更改。",
       "grant_label": {
+        "public": "向公众提供",
         "isForbidden": "无权查看的机构",
         "currentPageGrantLabel": "本页的权限: ",
         "parentPageGrantLabel": "父页的权限: ",

+ 1 - 0
packages/app/regconfig.json

@@ -13,6 +13,7 @@
     "reg-notify-github-plugin": {
       "prCommentBehavior": "new",
       "setCommitStatus": false,
+      "shortDescription": true,
       "clientId": "$REG_NOTIFY_GITHUB_PLUGIN_CLIENTID"
     },
     "reg-notify-slack-plugin": {

+ 2 - 0
packages/app/src/client/admin.jsx

@@ -31,6 +31,7 @@ import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 import AdminHome from '../components/Admin/AdminHome/AdminHome';
 import AppSettingsPage from '../components/Admin/App/AppSettingsPage';
+import { AuditLogManagement } from '../components/Admin/AuditLogManagement';
 import AdminNavigation from '../components/Admin/Common/AdminNavigation';
 import Customize from '../components/Admin/Customize/Customize';
 import ExportArchiveDataPage from '../components/Admin/ExportArchiveDataPage';
@@ -107,6 +108,7 @@ Object.assign(componentMappings, {
   'admin-user-group-detail': <UserGroupDetailPage />,
   'admin-full-text-search-management': <FullTextSearchManagement />,
   'admin-user-group-page': <UserGroupPage />,
+  'admin-audit-log': <AuditLogManagement />,
   'admin-navigation': <AdminNavigation />,
 });
 

+ 6 - 21
packages/app/src/client/app.jsx

@@ -7,13 +7,9 @@ import { I18nextProvider } from 'react-i18next';
 import { SWRConfig } from 'swr';
 import { Provider } from 'unstated';
 
-import CommentContainer from '~/client/services/CommentContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
@@ -49,8 +45,6 @@ import TagPage from '../components/TagPage';
 import TrashPageList from '../components/TrashPageList';
 
 import { appContainer, componentMappings } from './base';
-import { toastError } from './util/apiNotification';
-
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -61,14 +55,9 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 // create unstated container instance
 const pageContainer = new PageContainer(appContainer);
-const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
-const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
-const commentContainer = new CommentContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
-const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
-  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  commentContainer, editorContainer, personalContainer,
+  appContainer, socketIoContainer, pageContainer, editorContainer,
 ];
 
 logger.info('unstated containers have been initialized');
@@ -96,8 +85,6 @@ Object.assign(componentMappings, {
 
   'trash-page-alert': <TrashPageAlert />,
 
-  'fix-page-grant-alert': <FixPageGrantAlert />,
-
   'trash-page-list-container': <TrashPageList />,
 
   'not-found-page': <NotFoundPage />,
@@ -106,8 +93,7 @@ Object.assign(componentMappings, {
 
   'page-timeline': <PageTimeline />,
 
-  'personal-setting': <PersonalSettings crowi={personalContainer} />,
-
+  'personal-setting': <PersonalSettings />,
   'my-drafts': <MyDraftList />,
 
   'grw-fab-container': <Fab />,
@@ -130,11 +116,10 @@ if (pageContainer.state.pageId != null) {
 
     'recent-created-icon': <RecentlyCreatedIcon />,
   });
-
-  // show the Page accessory modal when query of "compare" is requested
-  if (revisionComparerContainer.getRevisionIDsToCompareAsParam().length > 0) {
-    toastError('Sorry, opening PageAccessoriesModal is not implemented yet in v5.');
-  //   pageAccessoriesContainer.openPageAccessoriesModal('pageHistory');
+  if (!pageContainer.state.isEmpty) {
+    Object.assign(componentMappings, {
+      'fix-page-grant-alert': <FixPageGrantAlert />,
+    });
   }
 }
 if (pageContainer.state.creator != null) {

+ 8 - 5
packages/app/src/client/base.jsx

@@ -6,8 +6,9 @@ import AppContainer from '~/client/services/AppContainer';
 import SocketIoContainer from '~/client/services/SocketIoContainer';
 import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
 import PutbackPageModal from '~/components/PutbackPageModal';
+import ShortcutsModal from '~/components/ShortcutsModal';
+import SystemVersion from '~/components/SystemVersion';
 import InterceptorManager from '~/services/interceptor-manager';
-import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 
 import EmptyTrashModal from '../components/EmptyTrashModal';
@@ -21,16 +22,14 @@ import PageDuplicateModal from '../components/PageDuplicateModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PageRenameModal from '../components/PageRenameModal';
 
+import ShowPageAccessoriesModal from './services/ShowPageAccessoriesModal';
+
 const logger = loggerFactory('growi:cli:app');
 
 if (!window) {
   window = {};
 }
 
-// setup xss library
-const xss = new Xss();
-window.xss = xss;
-
 window.globalEmitter = new EventEmitter();
 window.interceptorManager = new InterceptorManager();
 
@@ -61,9 +60,13 @@ const componentMappings = {
   'page-accessories-modal': <PageAccessoriesModal />,
   'descendants-page-list-modal': <DescendantsPageListModal />,
   'page-put-back-modal': <PutbackPageModal />,
+  'shortcuts-modal': <ShortcutsModal />,
 
   'grw-hotkeys-manager': <HotkeysManager />,
+  'system-version': <SystemVersion />,
+
 
+  'show-page-accessories-modal': <ShowPageAccessoriesModal />,
 };
 
 export { appContainer, componentMappings };

+ 3 - 0
packages/app/src/client/interfaces/clearable.ts

@@ -0,0 +1,3 @@
+export interface IClearable {
+  clear: () => void,
+}

+ 0 - 2
packages/app/src/client/legacy/crowi.js

@@ -1,5 +1,3 @@
-const { blinkElem, blinkSectionHeaderAtBoot } = require('../util/blink-section-header');
-
 /* eslint-disable react/jsx-filename-extension */
 require('jquery.cookie');
 

+ 1 - 2
packages/app/src/client/plugin.js

@@ -8,12 +8,11 @@ export default class GrowiPlugin {
    * process plugin entry
    *
    * @param {AppContainer} appContainer
-   * @param {GrowiRenderer} originRenderer The origin instance of GrowiRenderer
    *
    * @memberof CrowiPlugin
    */
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  installAll(appContainer, originRenderer) {
+  installAll(appContainer) {
     // import plugin definitions
     let definitions = [];
     try {

+ 8 - 30
packages/app/src/client/services/AppContainer.js

@@ -1,7 +1,8 @@
 import { Container } from 'unstated';
 
 
-import GrowiRenderer from '../util/GrowiRenderer';
+import GrowiRenderer, { generatePreviewRenderer } from '~/services/renderer/growi-renderer';
+
 import { i18nFactory } from '../util/i18n';
 
 /**
@@ -26,7 +27,6 @@ export default class AppContainer extends Container {
 
     this.containerInstances = {};
     this.componentInstances = {};
-    this.rendererInstances = {};
   }
 
   /**
@@ -45,8 +45,6 @@ export default class AppContainer extends Container {
 
     this.isDocSaved = true;
 
-    this.originRenderer = new GrowiRenderer(this);
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
       this.initPlugins();
@@ -57,18 +55,20 @@ export default class AppContainer extends Container {
 
   initPlugins() {
     const growiPlugin = window.growiPlugin;
-    growiPlugin.installAll(this, this.originRenderer);
+    growiPlugin.installAll(this);
   }
 
   injectToWindow() {
     window.appContainer = this;
 
-    const originRenderer = this.getOriginRenderer();
-    window.growiRenderer = originRenderer;
+    const growiRenderer = new GrowiRenderer(this.getConfig());
+    growiRenderer.init();
+
+    window.growiRenderer = growiRenderer;
 
     // backward compatibility
     window.crowi = this;
-    window.crowiRenderer = originRenderer;
+    window.crowiRenderer = window.growiRenderer;
     window.crowiPlugin = window.growiPlugin;
   }
 
@@ -126,26 +126,4 @@ export default class AppContainer extends Container {
     return this.componentInstances[id];
   }
 
-  getOriginRenderer() {
-    return this.originRenderer;
-  }
-
-  /**
-   * factory method
-   */
-  getRenderer(mode) {
-    if (this.rendererInstances[mode] != null) {
-      return this.rendererInstances[mode];
-    }
-
-    const renderer = new GrowiRenderer(this, this.originRenderer);
-    // setup
-    renderer.initMarkdownItConfigurers(mode);
-    renderer.setup(mode);
-    // register
-    this.rendererInstances[mode] = renderer;
-
-    return renderer;
-  }
-
 }

+ 0 - 167
packages/app/src/client/services/CommentContainer.js

@@ -1,167 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiGet, apiPost, apiPostForm } from '../util/apiv1-client';
-import { apiv3Put } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:services:CommentContainer');
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @extends {Container} unstated Container
- */
-export default class CommentContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.appContainer.registerContainer(this);
-
-    const mainContent = document.querySelector('#content-main');
-
-    if (mainContent == null) {
-      logger.debug('#content-main element is not exists');
-      return;
-    }
-
-    this.state = {
-      comments: [],
-    };
-
-    this.retrieveComments = this.retrieveComments.bind(this);
-    this.checkAndUpdateImageOfCommentAuthers = this.checkAndUpdateImageOfCommentAuthers.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'CommentContainer';
-  }
-
-  getPageContainer() {
-    return this.appContainer.getContainer('PageContainer');
-  }
-
-  findAndSplice(comment) {
-    const comments = this.state.comments;
-
-    const index = comments.indexOf(comment);
-    if (index < 0) {
-      return;
-    }
-    comments.splice(index, 1);
-
-    this.setState({ comments });
-  }
-
-  /**
-   * Load data of comments and store them in state
-   */
-  async retrieveComments() {
-    const { pageId } = this.getPageContainer().state;
-
-    // get data (desc order array)
-    const res = await apiGet('/comments.get', { page_id: pageId });
-    if (res.ok) {
-      const comments = res.comments;
-      this.setState({ comments });
-
-      this.checkAndUpdateImageOfCommentAuthers(comments);
-    }
-  }
-
-  async checkAndUpdateImageOfCommentAuthers(comments) {
-    const noImageCacheUserIds = comments.filter((comment) => {
-      const { creator } = comment;
-      return creator != null && creator.imageUrlCached == null;
-    }).map((comment) => {
-      return comment.creator._id;
-    });
-
-    if (noImageCacheUserIds.length === 0) {
-      return;
-    }
-
-    try {
-      await apiv3Put('/users/update.imageUrlCache', { userIds: noImageCacheUserIds });
-    }
-    catch (err) {
-      // Error alert doesn't apear, because user don't need to notice this error.
-      logger.error(err);
-    }
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  postComment(comment, isMarkdown, replyTo, isSlackEnabled, slackChannels) {
-    const { pageId, revisionId } = this.getPageContainer().state;
-
-    return apiPost('/comments.add', {
-      commentForm: {
-        comment,
-        page_id: pageId,
-        revision_id: revisionId,
-        is_markdown: isMarkdown,
-        replyTo,
-      },
-      slackNotificationForm: {
-        isSlackEnabled,
-        slackChannels,
-      },
-    })
-      .then((res) => {
-        if (res.ok) {
-          return this.retrieveComments();
-        }
-      });
-  }
-
-  /**
-   * Load data of comments and rerender <PageComments />
-   */
-  putComment(comment, isMarkdown, commentId, author) {
-    const { pageId, revisionId } = this.getPageContainer().state;
-
-    return apiPost('/comments.update', {
-      commentForm: {
-        comment,
-        is_markdown: isMarkdown,
-        revision_id: revisionId,
-        comment_id: commentId,
-      },
-    })
-      .then((res) => {
-        if (res.ok) {
-          return this.retrieveComments();
-        }
-      });
-  }
-
-  deleteComment(comment) {
-    return apiPost('/comments.remove', { comment_id: comment._id })
-      .then((res) => {
-        if (res.ok) {
-          this.findAndSplice(comment);
-        }
-      });
-  }
-
-  uploadAttachment(file) {
-    const { pageId, pagePath } = this.getPageContainer().state;
-
-    const endpoint = '/attachments.add';
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('path', pagePath);
-    formData.append('page_id', pageId);
-
-    return apiPostForm(endpoint, formData);
-  }
-
-}

+ 55 - 11
packages/app/src/client/services/ContextExtractor.tsx

@@ -2,7 +2,10 @@ import React, { FC, useEffect, useState } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 
+import { CustomWindow } from '~/interfaces/global';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import { generatePreviewRenderer } from '~/services/renderer/growi-renderer';
+import { useRendererSettings } from '~/stores/renderer';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -13,12 +16,13 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 import {
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
-  useIsDeleted, useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
-  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
+  useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion, useAuditLogEnabled,
+  useActivityExpirationSeconds, useAuditLogAvailableActions, useGrowiRendererConfig,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -29,7 +33,7 @@ const ContextExtractorOnce: FC = () => {
 
   const mainContent = document.querySelector('#content-main');
   const notFoundContentForPt = document.getElementById('growi-pagetree-not-found-context');
-  const notFoundContent = document.getElementById('growi-not-found-context');
+  const notFoundContext = document.getElementById('growi-not-found-context');
   const forbiddenContent = document.getElementById('forbidden-page');
 
   // get csrf token from body element
@@ -57,7 +61,10 @@ const ContextExtractorOnce: FC = () => {
    */
   const revisionId = mainContent?.getAttribute('data-page-revision-id');
   const path = decodeURI(mainContent?.getAttribute('data-path') || '');
+  // assign `null` to avoid returning empty string
   const pageId = mainContent?.getAttribute('data-page-id') || null;
+  const emptyPageId = notFoundContext?.getAttribute('data-page-id') || null;
+
   const revisionCreatedAt = +(mainContent?.getAttribute('data-page-revision-created') || '');
 
   // createdAt
@@ -71,7 +78,6 @@ const ContextExtractorOnce: FC = () => {
   const isIdenticalPath = JSON.parse(mainContent?.getAttribute('data-identical-path') || jsonNull) ?? false;
   const isUserPage = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull) != null;
   const isTrashPage = _isTrashPage(path);
-  const isDeleted = JSON.parse(mainContent?.getAttribute('data-page-is-deleted') || jsonNull) ?? false;
   const isNotCreatable = JSON.parse(mainContent?.getAttribute('data-page-is-not-creatable') || jsonNull) ?? false;
   const isForbidden = forbiddenContent != null;
   const pageUser = JSON.parse(mainContent?.getAttribute('data-page-user') || jsonNull);
@@ -89,8 +95,8 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContent?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
+  const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
   const grant = +(mainContent?.getAttribute('data-page-grant') || 1);
   const grantGroupId = mainContent?.getAttribute('data-page-grant-group') || null;
@@ -105,9 +111,9 @@ const ContextExtractorOnce: FC = () => {
   useCurrentUser(currentUser);
 
   // UserUISettings
-  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser);
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? configByContextHydrate.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
-  useSidebarCollapsed(userUISettings?.isSidebarCollapsed);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed ?? configByContextHydrate.isSidebarClosedAtDockMode);
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 
@@ -119,7 +125,27 @@ const ContextExtractorOnce: FC = () => {
   useIsEnabledAttachTitleHeader(configByContextHydrate.isEnabledAttachTitleHeader);
   useIsIndentSizeForced(configByContextHydrate.isIndentSizeForced);
   useDefaultIndentSize(configByContextHydrate.adminPreferredIndentSize);
-
+  useAuditLogEnabled(configByContextHydrate.auditLogEnabled);
+  useActivityExpirationSeconds(configByContextHydrate.activityExpirationSeconds);
+  useAuditLogAvailableActions(configByContextHydrate.auditLogAvailableActions);
+  useGrowiVersion(configByContextHydrate.crowi.version);
+  useRendererSettings({
+    isEnabledLinebreaks: configByContextHydrate.isEnabledLinebreaks,
+    isEnabledLinebreaksInComments: configByContextHydrate.isEnabledLinebreaksInComments,
+    adminPreferredIndentSize: configByContextHydrate.adminPreferredIndentSize,
+    isIndentSizeForced: configByContextHydrate.isIndentSizeForced,
+  });
+  useGrowiRendererConfig({
+    isEnabledXssPrevention: configByContextHydrate.isEnabledXssPrevention,
+    attrWhiteList: configByContextHydrate.attrWhiteList,
+    tagWhiteList: configByContextHydrate.tagWhiteList,
+    highlightJsStyleBorder: configByContextHydrate.highlightJsStyleBorder,
+    env: {
+      MATHJAX: configByContextHydrate.env.MATHJAX,
+      PLANTUML_URI: configByContextHydrate.env.PLANTUML_URI,
+      BLOCKDIAG_URI: configByContextHydrate.env.BLOCKDIAG_URI,
+    },
+  });
 
   // Page
   useCurrentCreatedAt(createdAt);
@@ -128,13 +154,13 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
-  useIsDeleted(isDeleted);
   useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
   useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);
+  useEmptyPageId(emptyPageId);
   usePageIdOnHackmd(pageIdOnHackmd);
   usePageUser(pageUser);
   useCurrentPagePath(path);
@@ -149,8 +175,8 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
-  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
+  useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
 
   // Navigation
@@ -176,6 +202,24 @@ const ContextExtractorOnce: FC = () => {
   const shouldInitAdminSock = !!currentUser?.isAdmin;
   useSetupGlobalAdminSocket(shouldInitAdminSock);
 
+  // TODO: Remove this code when reveal.js is omitted. see: https://github.com/weseek/growi/pull/6223
+  // Do not access this property from other than reveal.js plugins.
+  (window as CustomWindow).previewRenderer = generatePreviewRenderer(
+    {
+      isEnabledXssPrevention: configByContextHydrate.isEnabledXssPrevention,
+      attrWhiteList: configByContextHydrate.attrWhiteList,
+      tagWhiteList: configByContextHydrate.tagWhiteList,
+      highlightJsStyleBorder: configByContextHydrate.highlightJsStyleBorder,
+      env: {
+        MATHJAX: configByContextHydrate.env.MATHJAX,
+        PLANTUML_URI: configByContextHydrate.env.PLANTUML_URI,
+        BLOCKDIAG_URI: configByContextHydrate.env.BLOCKDIAG_URI,
+      },
+    },
+    null,
+    path,
+  );
+
   return null;
 };
 

+ 3 - 3
packages/app/src/client/services/PageContainer.js

@@ -13,10 +13,10 @@ import { apiv3Post } from '../util/apiv3-client';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
-} from '../util/interceptor/detach-code-blocks';
+} from '../../services/renderer/interceptor/detach-code-blocks';
 import {
   DrawioInterceptor,
-} from '../util/interceptor/drawio-interceptor';
+} from '../../services/renderer/interceptor/drawio-interceptor';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -52,6 +52,7 @@ export default class PageContainer extends Container {
       revisionId,
       revisionCreatedAt: +mainContent.getAttribute('data-page-revision-created'),
       path,
+      isEmpty: mainContent.getAttribute('data-page-is-empty'),
 
       createdAt: mainContent.getAttribute('data-page-created-at'),
       // please use useCurrentUpdatedAt instead
@@ -60,7 +61,6 @@ export default class PageContainer extends Container {
 
       isUserPage: JSON.parse(mainContent.getAttribute('data-page-user')) != null,
       isTrashPage: isTrashPage(path),
-      isDeleted: JSON.parse(mainContent.getAttribute('data-page-is-deleted')),
       isNotCreatable: JSON.parse(mainContent.getAttribute('data-page-is-not-creatable')),
       isPageExist: mainContent.getAttribute('data-page-id') != null,
 

+ 0 - 172
packages/app/src/client/services/PageHistoryContainer.js

@@ -1,172 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-import { apiv3Get } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:PageHistoryContainer');
-
-/**
- * Service container for personal settings page (PageHistory.jsx)
- * @extends {Container} unstated Container
- */
-export default class PageHistoryContainer extends Container {
-
-  constructor(appContainer, pageContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.pageContainer = pageContainer;
-    this.dummyRevisions = 0;
-
-    this.state = {
-      errorMessage: null,
-
-      // set dummy rivisions for using suspense
-      revisions: this.dummyRevisions,
-      latestRevision: this.dummyRevisions,
-      oldestRevision: this.dummyRevisions,
-      diffOpened: {},
-
-      totalPages: 0,
-      activePage: 1,
-      pagingLimit: 10,
-    };
-
-    this.retrieveRevisions = this.retrieveRevisions.bind(this);
-    this.getPreviousRevision = this.getPreviousRevision.bind(this);
-    this.fetchPageRevisionBody = this.fetchPageRevisionBody.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PageHistoryContainer';
-  }
-
-  /**
-   * syncRevisions of selectedPage
-   * @param {number} selectedPage
-   */
-  async retrieveRevisions(selectedPage) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-    const { pagingLimit } = this.state;
-    const page = selectedPage;
-    const pagingLimitForApiParam = pagingLimit + 1;
-
-    if (!pageId) {
-      return;
-    }
-
-    // Get one more for the bottom display
-    const res = await apiv3Get('/revisions/list', {
-      pageId, shareLinkId, page, limit: pagingLimitForApiParam,
-    });
-    const rev = res.data.docs;
-    // set Pagination state
-    this.setState({
-      activePage: selectedPage,
-      totalPages: res.data.totalDocs,
-      pagingLimit,
-    });
-
-    const diffOpened = {};
-
-    let lastId = rev.length - 1;
-
-    // If the number of rev count is the same, the last rev is for diff display, so exclude it.
-    if (rev.length > pagingLimit) {
-      lastId = rev.length - 2;
-    }
-
-    res.data.docs.forEach((revision, i) => {
-      const user = revision.author;
-      if (user) {
-        rev[i].author = user;
-      }
-
-      if (i === 0 || i === lastId) {
-        diffOpened[revision._id] = true;
-      }
-      else {
-        diffOpened[revision._id] = false;
-      }
-    });
-
-    this.setState({ revisions: rev });
-    this.setState({ diffOpened });
-
-    if (selectedPage === 1) {
-      this.setState({ latestRevision: rev[0] });
-    }
-
-    if (selectedPage === res.data.totalPages) {
-      this.setState({ oldestRevision: rev[lastId] });
-    }
-
-    // load 0, and last default
-    if (rev[0]) {
-      this.fetchPageRevisionBody(rev[0]);
-    }
-    if (rev[1]) {
-      this.fetchPageRevisionBody(rev[1]);
-    }
-    if (lastId !== 0 && lastId !== 1 && rev[lastId]) {
-      this.fetchPageRevisionBody(rev[lastId]);
-    }
-
-    return;
-  }
-
-  getPreviousRevision(currentRevision) {
-    let cursor = null;
-    for (const revision of this.state.revisions) {
-      // comparing ObjectId
-      // eslint-disable-next-line eqeqeq
-      if (cursor && cursor._id == currentRevision._id) {
-        cursor = revision;
-        break;
-      }
-
-      cursor = revision;
-    }
-
-    return cursor;
-  }
-
-  /**
-   * fetch page revision body by revision in argument
-   * @param {object} revision
-   */
-  async fetchPageRevisionBody(revision) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    if (revision.body) {
-      return;
-    }
-
-    try {
-      const res = await apiv3Get(`/revisions/${revision._id}`, { pageId, shareLinkId });
-      this.setState({
-        revisions: this.state.revisions.map((rev) => {
-          // comparing ObjectId
-          // eslint-disable-next-line eqeqeq
-          if (rev._id == res.data.revision._id) {
-            return res.data.revision;
-          }
-
-          return rev;
-        }),
-      });
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-  }
-
-
-}

+ 0 - 186
packages/app/src/client/services/PersonalContainer.js

@@ -1,186 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { apiPost } from '../util/apiv1-client';
-import { apiv3Get, apiv3Put } from '../util/apiv3-client';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:PersonalContainer');
-
-/**
- * Service container for personal settings page (PersonalSettings.jsx)
- * @extends {Container} unstated Container
- */
-export default class PersonalContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    this.appContainer = appContainer;
-
-    this.state = {
-      retrieveError: null,
-      name: '',
-      email: '',
-      registrationWhiteList: this.appContainer.getConfig().registrationWhiteList,
-      isEmailPublished: false,
-      lang: 'en_US',
-      isGravatarEnabled: false,
-      externalAccounts: [],
-      apiToken: '',
-      slackMemberId: '',
-    };
-
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'PersonalContainer';
-  }
-
-  /**
-   * retrieve personal data
-   */
-  async retrievePersonalData() {
-    try {
-      const response = await apiv3Get('/personal-setting/');
-      const { currentUser } = response.data;
-      this.setState({
-        name: currentUser.name,
-        email: currentUser.email,
-        isEmailPublished: currentUser.isEmailPublished,
-        lang: currentUser.lang,
-        isGravatarEnabled: currentUser.isGravatarEnabled,
-        apiToken: currentUser.apiToken,
-        slackMemberId: currentUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch personal data');
-    }
-  }
-
-  /**
-   * retrieve external accounts that linked me
-   */
-  async retrieveExternalAccounts() {
-    try {
-      const response = await apiv3Get('/personal-setting/external-accounts');
-      const { externalAccounts } = response.data;
-
-      this.setState({ externalAccounts });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to fetch external accounts');
-    }
-  }
-
-  /**
-   * Change name
-   */
-  changeName(inputValue) {
-    this.setState({ name: inputValue });
-  }
-
-  /**
-   * Change email
-   */
-  changeEmail(inputValue) {
-    this.setState({ email: inputValue });
-  }
-
-  /**
-   * Change Slack Member ID
-   */
-  changeSlackMemberId(inputValue) {
-    this.setState({ slackMemberId: inputValue });
-  }
-
-  /**
-   * Change isEmailPublished
-   */
-  changeIsEmailPublished(boolean) {
-    this.setState({ isEmailPublished: boolean });
-  }
-
-  /**
-   * Change lang
-   */
-  changeLang(lang) {
-    this.setState({ lang });
-  }
-
-  /**
-   * Change isGravatarEnabled
-   */
-  changeIsGravatarEnabled(boolean) {
-    this.setState({ isGravatarEnabled: boolean });
-  }
-
-  /**
-   * Update basic info
-   * @memberOf PersonalContainer
-   * @return {Array} basic info
-   */
-  async updateBasicInfo() {
-    try {
-      const response = await apiv3Put('/personal-setting/', {
-        name: this.state.name,
-        email: this.state.email,
-        isEmailPublished: this.state.isEmailPublished,
-        lang: this.state.lang,
-        slackMemberId: this.state.slackMemberId,
-      });
-      const { updatedUser } = response.data;
-
-      this.setState({
-        name: updatedUser.name,
-        email: updatedUser.email,
-        isEmailPublished: updatedUser.isEmailPublished,
-        lang: updatedUser.lang,
-        slackMemberId: updatedUser.slackMemberId,
-      });
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to update personal data');
-    }
-  }
-
-  /**
-   * Associate LDAP account
-   */
-  async associateLdapAccount(account) {
-    try {
-      await apiv3Put('/personal-setting/associate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to associate ldap account');
-    }
-  }
-
-  /**
-   * Disassociate LDAP account
-   */
-  async disassociateLdapAccount(account) {
-    try {
-      await apiv3Put('/personal-setting/disassociate-ldap', account);
-    }
-    catch (err) {
-      this.setState({ retrieveError: err });
-      logger.error(err);
-      throw new Error('Failed to disassociate ldap account');
-    }
-  }
-
-}

+ 0 - 113
packages/app/src/client/services/RevisionComparerContainer.js

@@ -1,113 +0,0 @@
-import { Container } from 'unstated';
-
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-import { apiv3Get } from '../util/apiv3-client';
-
-const logger = loggerFactory('growi:PageHistoryContainer');
-
-/**
- * Service container for personal settings page (RevisionCompare.jsx)
- * @extends {Container} unstated Container
- */
-export default class RevisionComparerContainer extends Container {
-
-  constructor(appContainer, pageContainer) {
-    super();
-
-    this.appContainer = appContainer;
-    this.pageContainer = pageContainer;
-
-    this.state = {
-      errMessage: null,
-
-      sourceRevision: null,
-      targetRevision: null,
-      latestRevision: null,
-    };
-
-    this.initRevisions = this.initRevisions.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'RevisionComparerContainer';
-  }
-
-  /**
-   * Initialize the revisions
-   */
-  async initRevisions() {
-    const latestRevision = await this.fetchLatestRevision();
-
-    const [sourceRevisionId, targetRevisionId] = this.getRevisionIDsToCompareAsParam();
-    const sourceRevision = sourceRevisionId ? await this.fetchRevision(sourceRevisionId) : latestRevision;
-    const targetRevision = targetRevisionId ? await this.fetchRevision(targetRevisionId) : latestRevision;
-    const compareWithLatest = targetRevisionId ? false : this.state.compareWithLatest;
-
-    this.setState({
-      sourceRevision, targetRevision, latestRevision, compareWithLatest,
-    });
-  }
-
-  /**
-   * Get the IDs of the comparison source and target from "window.location" as an array
-   */
-  getRevisionIDsToCompareAsParam() {
-    const searchParams = {};
-    for (const param of window.location.search?.substr(1)?.split('&')) {
-      const [k, v] = param.split('=');
-      searchParams[k] = v;
-    }
-    if (!searchParams.compare) {
-      return [];
-    }
-
-    return searchParams.compare.split('...') || [];
-  }
-
-  /**
-   * Fetch the latest revision
-   */
-  async fetchLatestRevision() {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    try {
-      const res = await apiv3Get('/revisions/list', {
-        pageId, shareLinkId, page: 1, limit: 1,
-      });
-      return res.data.docs[0];
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-    return null;
-  }
-
-  /**
-   * Fetch the revision of the specified ID
-   * @param {string} revision ID
-   */
-  async fetchRevision(revisionId) {
-    const { pageId, shareLinkId } = this.pageContainer.state;
-
-    try {
-      const res = await apiv3Get(`/revisions/${revisionId}`, {
-        pageId, shareLinkId,
-      });
-      return res.data.revision;
-    }
-    catch (err) {
-      toastError(err);
-      this.setState({ errorMessage: err.message });
-      logger.error(err);
-    }
-    return null;
-  }
-
-}

+ 34 - 0
packages/app/src/client/services/ShowPageAccessoriesModal.tsx

@@ -0,0 +1,34 @@
+import React, { useEffect, useState } from 'react';
+
+import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/modal';
+
+function getURLQueryParamValue(key: string) {
+// window.location.href is page URL;
+  const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
+  return queryStr.get(key);
+}
+
+const queryCompareFormat = new RegExp(/([a-z0-9]){24}...([a-z0-9]){24}/);
+
+const ShowPageAccessoriesModal = (): JSX.Element => {
+  const { data: status, open: openPageAccessories } = usePageAccessoriesModal();
+  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+  useEffect(() => {
+    const pageIdParams = getURLQueryParamValue('compare');
+    if (status == null || status.isOpened === true) {
+      return;
+    }
+    if (isArleadyMounted === true) {
+      return;
+    }
+    if (pageIdParams != null) {
+      if (queryCompareFormat.test(pageIdParams)) {
+        openPageAccessories(PageAccessoriesModalContents.PageHistory);
+      }
+    }
+    setIsArleadyMounted(true);
+  }, [openPageAccessories, status, isArleadyMounted]);
+  return <></>;
+};
+
+export default ShowPageAccessoriesModal;

+ 23 - 0
packages/app/src/client/services/page-operation.ts

@@ -37,6 +37,29 @@ export const toggleBookmark = async(pageId: string, currentValue?: boolean): Pro
   }
 };
 
+// Utility to update body class
+const updateBodyClassByView = (expandContentWidth: boolean): void => {
+  const bodyClasses = document.body.classList;
+  const isLayoutFluid = bodyClasses.contains('growi-layout-fluid');
+
+  if (expandContentWidth && !isLayoutFluid) {
+    bodyClasses.add('growi-layout-fluid');
+  }
+  else if (isLayoutFluid) {
+    bodyClasses.remove('growi-layout-fluid');
+  }
+};
+
+export const updateContentWidth = async(pageId: string, newValue: boolean): Promise<void> => {
+  try {
+    await apiv3Put(`/page/${pageId}/content-width`, { expandContentWidth: newValue });
+    updateBodyClassByView(newValue);
+  }
+  catch (err) {
+    toastError(err);
+  }
+};
+
 export const bookmark = async(pageId: string): Promise<void> => {
   try {
     await apiv3Put('/bookmarks', { pageId, bool: true });

+ 0 - 208
packages/app/src/client/util/GrowiRenderer.js

@@ -1,208 +0,0 @@
-import MarkdownIt from 'markdown-it';
-
-import loggerFactory from '~/utils/logger';
-
-import CsvToTable from './PreProcessor/CsvToTable';
-import EasyGrid from './PreProcessor/EasyGrid';
-import Linker from './PreProcessor/Linker';
-import XssFilter from './PreProcessor/XssFilter';
-import BlockdiagConfigurer from './markdown-it/blockdiag';
-import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
-import EmojiConfigurer from './markdown-it/emoji';
-import FooternoteConfigurer from './markdown-it/footernote';
-import HeaderConfigurer from './markdown-it/header';
-import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
-import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
-import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
-import MathJaxConfigurer from './markdown-it/mathjax';
-import PlantUMLConfigurer from './markdown-it/plantuml';
-import TableConfigurer from './markdown-it/table';
-import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
-import TaskListsConfigurer from './markdown-it/task-lists';
-import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
-
-const logger = loggerFactory('growi:util:GrowiRenderer');
-
-export default class GrowiRenderer {
-
-  /**
-   *
-   * @param {AppContainer} appContainer
-   * @param {GrowiRenderer} originRenderer
-   * @param {string} mode
-   */
-  constructor(appContainer, originRenderer) {
-    this.appContainer = appContainer;
-
-    if (originRenderer != null) {
-      this.preProcessors = originRenderer.preProcessors;
-      this.postProcessors = originRenderer.postProcessors;
-    }
-    else {
-      this.preProcessors = [
-        new EasyGrid(),
-        new Linker(),
-        new CsvToTable(),
-        new XssFilter(appContainer),
-      ];
-      this.postProcessors = [
-      ];
-    }
-
-    this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
-    this.setup = this.setup.bind(this);
-    this.process = this.process.bind(this);
-    this.codeRenderer = this.codeRenderer.bind(this);
-  }
-
-  initMarkdownItConfigurers(mode) {
-    const appContainer = this.appContainer;
-
-    // init markdown-it
-    this.md = new MarkdownIt({
-      html: true,
-      linkify: true,
-      highlight: this.codeRenderer,
-    });
-
-    this.isMarkdownItConfigured = false;
-
-    this.markdownItConfigurers = [
-      new LinkerByRelativePathConfigurer(appContainer),
-      new TaskListsConfigurer(appContainer),
-      new HeaderConfigurer(),
-      new EmojiConfigurer(),
-      new MathJaxConfigurer(appContainer),
-      new DrawioViewerConfigurer(),
-      new PlantUMLConfigurer(appContainer),
-      new BlockdiagConfigurer(appContainer),
-    ];
-
-    // add configurers according to mode
-    switch (mode) {
-      case 'page': {
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(),
-          new TocAndAnchorConfigurer(),
-          new HeaderLineNumberConfigurer(),
-          new HeaderWithEditLinkConfigurer(),
-          new TableWithHandsontableButtonConfigurer(),
-        ]);
-        break;
-      }
-      case 'editor':
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(),
-          new HeaderLineNumberConfigurer(),
-          new TableConfigurer(),
-        ]);
-        break;
-      // case 'comment':
-      //   break;
-      default:
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(),
-        ]);
-        break;
-    }
-  }
-
-  /**
-   * setup with crowi config
-   */
-  setup(mode) {
-    const crowiConfig = this.appContainer.config;
-
-    let isEnabledLinebreaks;
-    switch (mode) {
-      case 'comment':
-        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
-        break;
-      default:
-        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaks;
-        break;
-    }
-
-    this.md.set({
-      breaks: isEnabledLinebreaks,
-    });
-
-    if (!this.isMarkdownItConfigured) {
-      this.markdownItConfigurers.forEach((configurer) => {
-        configurer.configure(this.md);
-      });
-    }
-  }
-
-  preProcess(markdown, context) {
-    let processed = markdown;
-    for (let i = 0; i < this.preProcessors.length; i++) {
-      if (!this.preProcessors[i].process) {
-        continue;
-      }
-      processed = this.preProcessors[i].process(processed, context);
-    }
-
-    return processed;
-  }
-
-  process(markdown, context) {
-    return this.md.render(markdown, context);
-  }
-
-  postProcess(html, context) {
-    let processed = html;
-    for (let i = 0; i < this.postProcessors.length; i++) {
-      if (!this.postProcessors[i].process) {
-        continue;
-      }
-      processed = this.postProcessors[i].process(processed, context);
-    }
-
-    return processed;
-  }
-
-  codeRenderer(code, langExt) {
-    const config = this.appContainer.getConfig();
-    const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
-
-    let citeTag = '';
-    let hljsLang = 'plaintext';
-    let showLinenumbers = false;
-
-    if (langExt) {
-      // https://regex101.com/r/qGs7eZ/3
-      const match = langExt.match(/^([^:=\n]+)?(=([^:=\n]*))?(:([^:=\n]*))?(=([^:=\n]*))?$/);
-
-      const lang = match[1];
-      const fileName = match[5] || null;
-      showLinenumbers = (match[2] != null) || (match[6] != null);
-
-      if (fileName != null) {
-        citeTag = `<cite>${fileName}</cite>`;
-      }
-      if (hljs.getLanguage(lang)) {
-        hljsLang = lang;
-      }
-    }
-
-    let highlightCode = code;
-    try {
-      highlightCode = hljs.highlight(hljsLang, code, true).value;
-
-      // add line numbers
-      if (showLinenumbers) {
-        highlightCode = hljs.lineNumbersValue((highlightCode));
-      }
-    }
-    catch (err) {
-      logger.error(err);
-    }
-
-    return `<pre class="hljs ${noborder}">${citeTag}<code>${highlightCode}</code></pre>`;
-  }
-
-  highlightCode(code, lang) {
-  }
-
-}

+ 0 - 23
packages/app/src/client/util/PreProcessor/XssFilter.js

@@ -1,23 +0,0 @@
-import Xss from '~/services/xss';
-import XssOption from '~/services/xss/xssOption';
-
-export default class XssFilter {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    if (crowi.config.isEnabledXssPrevention) {
-      this.xssOption = new XssOption(crowi.config);
-      this.xss = new Xss(this.xssOption);
-    }
-  }
-
-  process(markdown) {
-    if (this.crowi.config.isEnabledXssPrevention) {
-      return this.xss.process(markdown);
-    }
-
-    return markdown;
-  }
-
-}

+ 1 - 0
packages/app/src/client/util/blink-section-header.ts

@@ -23,5 +23,6 @@ export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
   const elem = document.getElementById(id);
   if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
     blinkElem(elem);
+    return elem;
   }
 };

+ 4 - 7
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -2,19 +2,16 @@
  * reveal.js growi-renderer plugin.
  */
 (function(root, factory) {
-  // get AppContainer instance from parent window
-  const appContainer = window.parent.appContainer;
-
-  const growiRendererPlugin = factory(appContainer);
+  const growiRendererPlugin = factory();
   growiRendererPlugin.initialize();
-}(this, (appContainer) => {
+}(this, () => {
   /* eslint-disable no-useless-escape */
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   /* eslint-enable no-useless-escape */
 
-  const growiRenderer = appContainer.getRenderer('editor');
+  const growiRenderer = window.parent.previewRenderer;
 
   let marked;
 
@@ -61,7 +58,7 @@
         section.setAttribute('data-markdown-parsed', 'true');
         const notes = section.querySelector('aside.notes');
         markdown = marked.getMarkdownFromSlide(section);
-        const context = { markdown };
+        const context = { markdown, currentPathname: decodeURIComponent(window.parent.location.pathname) };
 
         interceptorManager.process('preRender', context)
           .then(() => { return interceptorManager.process('prePreProcess', context) })

+ 39 - 41
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -1,57 +1,55 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 
-class InstalledPluginTable extends React.Component {
-
-  render() {
-    const { t, adminHomeContainer } = this.props;
-
-    const { installedPlugins } = adminHomeContainer.state;
-
-    if (installedPlugins == null) {
-      return <></>;
-    }
-
-    return (
-      <table data-testid="admin-installed-plugin-table" className="table table-bordered">
-        <thead>
-          <tr>
-            <th className="text-center">{t('admin:admin_top.package_name')}</th>
-            <th className="text-center">{t('admin:admin_top.specified_version')}</th>
-            <th className="text-center">{t('admin:admin_top.installed_version')}</th>
-          </tr>
-        </thead>
-        <tbody>
-          {adminHomeContainer.state.installedPlugins.map((plugin) => {
-            return (
-              <tr key={plugin.name}>
-                <td>{plugin.name}</td>
-                <td data-hide-in-vrt className="text-center">{plugin.requiredVersion}</td>
-                <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
-              </tr>
-            );
-          })}
-        </tbody>
-      </table>
-    );
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+const InstalledPluginTable = (props) => {
+  const { t } = useTranslation();
+  const { adminHomeContainer } = props;
+
+  const { installedPlugins } = adminHomeContainer.state;
+
+  if (installedPlugins == null) {
+    return <></>;
   }
 
-}
+  return (
+    <table data-testid="admin-installed-plugin-table" className="table table-bordered">
+      <thead>
+        <tr>
+          <th className="text-center">{t('admin:admin_top.package_name')}</th>
+          <th className="text-center">{t('admin:admin_top.specified_version')}</th>
+          <th className="text-center">{t('admin:admin_top.installed_version')}</th>
+        </tr>
+      </thead>
+      <tbody>
+        {adminHomeContainer.state.installedPlugins.map((plugin) => {
+          return (
+            <tr key={plugin.name}>
+              <td>{plugin.name}</td>
+              <td data-hide-in-vrt className="text-center">{plugin.requiredVersion}</td>
+              <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
+  );
+
+};
 
 InstalledPluginTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
 };
 
+
 /**
  * Wrapper component for using unstated
  */
-const InstalledPluginTableWrapper = withUnstatedContainers(InstalledPluginTable, [AppContainer, AdminHomeContainer]);
+const InstalledPluginTableWrapper = withUnstatedContainers(InstalledPluginTable, [AdminHomeContainer]);
 
-export default withTranslation()(InstalledPluginTableWrapper);
+export default InstalledPluginTableWrapper;

+ 0 - 59
packages/app/src/components/Admin/AdminHome/SystemInfomationTable.jsx

@@ -1,59 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-
-class SystemInformationTable extends React.Component {
-
-  render() {
-    const { adminHomeContainer } = this.props;
-
-    const {
-      growiVersion, nodeVersion, npmVersion, yarnVersion,
-    } = adminHomeContainer.state;
-
-    if (growiVersion == null || nodeVersion == null || npmVersion == null || yarnVersion == null) {
-      return <></>;
-    }
-
-    return (
-      <table data-testid="admin-system-information-table" className="table table-bordered">
-        <tbody>
-          <tr>
-            <th>GROWI</th>
-            <td data-hide-in-vrt>{ growiVersion }</td>
-          </tr>
-          <tr>
-            <th>node.js</th>
-            <td>{ nodeVersion }</td>
-          </tr>
-          <tr>
-            <th>npm</th>
-            <td>{ npmVersion }</td>
-          </tr>
-          <tr>
-            <th>yarn</th>
-            <td>{ yarnVersion }</td>
-          </tr>
-        </tbody>
-      </table>
-    );
-  }
-
-}
-
-SystemInformationTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const SystemInformationTableWrapper = withUnstatedContainers(SystemInformationTable, [AppContainer, AdminHomeContainer]);
-
-export default withTranslation()(SystemInformationTableWrapper);

+ 53 - 0
packages/app/src/components/Admin/AdminHome/SystemInfomationTable.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+
+type Props = {
+  adminHomeContainer: AdminHomeContainer,
+}
+
+const SystemInformationTable = (props: Props) => {
+  const { adminHomeContainer } = props;
+
+  const {
+    growiVersion, nodeVersion, npmVersion, yarnVersion,
+  } = adminHomeContainer.state;
+
+  if (growiVersion == null || nodeVersion == null || npmVersion == null || yarnVersion == null) {
+    return <></>;
+  }
+
+  return (
+    <table data-testid="admin-system-information-table" className="table table-bordered">
+      <tbody>
+        <tr>
+          <th>GROWI</th>
+          <td data-hide-in-vrt>{ growiVersion }</td>
+        </tr>
+        <tr>
+          <th>node.js</th>
+          <td>{ nodeVersion }</td>
+        </tr>
+        <tr>
+          <th>npm</th>
+          <td>{ npmVersion }</td>
+        </tr>
+        <tr>
+          <th>yarn</th>
+          <td>{ yarnVersion }</td>
+        </tr>
+      </tbody>
+    </table>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SystemInformationTableWrapper = withUnstatedContainers(SystemInformationTable, [AdminHomeContainer]);
+
+export default SystemInformationTableWrapper;

+ 139 - 145
packages/app/src/components/Admin/App/AppSetting.jsx

@@ -1,29 +1,25 @@
-import React from 'react';
+import React, { useCallback } from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
+import { useTranslation } from 'react-i18next';
 
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { localeMetadatas } from '~/client/util/i18n';
+import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 
-import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 const logger = loggerFactory('growi:appSettings');
 
-class AppSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
 
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
+const AppSetting = (props) => {
+  const { adminAppContainer } = props;
+  const { t } = useTranslation();
 
+  const submitHandler = useCallback(async() => {
     try {
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('toaster.update_successed', { target: t('App Settings') }));
@@ -32,150 +28,148 @@ class AppSetting extends React.Component {
       toastError(err);
       logger.error(err);
     }
-  }
+  }, [adminAppContainer, t]);
+
+
+  return (
+    <React.Fragment>
+      <div className="form-group row">
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('admin:app_setting.site_name')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            defaultValue={adminAppContainer.state.title || ''}
+            onChange={(e) => {
+              adminAppContainer.changeTitle(e.target.value);
+            }}
+            placeholder="GROWI"
+          />
+          <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
+        </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.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);
+            }}
+            placeholder={t('admin:app_setting.confidential_example')}
+          />
+          <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
+        </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.default_language')}
+        </label>
+        <div className="col-md-6 py-2">
+          {
+            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>
 
-  render() {
-    const { t, adminAppContainer } = this.props;
+      <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_mail_visibility')}
+        </label>
+        <div className="col-md-6 py-2">
 
-    return (
-      <React.Fragment>
-        <div className="form-group row">
-          <label className="text-left text-md-right col-md-3 col-form-label">{t('admin:app_setting.site_name')}</label>
-          <div className="col-md-6">
+          <div className="custom-control custom-radio custom-control-inline">
             <input
-              className="form-control"
-              type="text"
-              defaultValue={adminAppContainer.state.title || ''}
-              onChange={(e) => {
-                adminAppContainer.changeTitle(e.target.value);
-              }}
-              placeholder="GROWI"
+              type="radio"
+              id="radio-email-show"
+              className="custom-control-input"
+              name="mailVisibility"
+              checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
+              onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
             />
-            <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p>
+            <label className="custom-control-label" htmlFor="radio-email-show">{t('Show')}</label>
           </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.confidential_name')}
-          </label>
-          <div className="col-md-6">
+          <div className="custom-control custom-radio custom-control-inline">
             <input
-              className="form-control"
-              type="text"
-              defaultValue={adminAppContainer.state.confidential || ''}
-              onChange={(e) => {
-                adminAppContainer.changeConfidential(e.target.value);
-              }}
-              placeholder={t('admin:app_setting.confidential_example')}
+              type="radio"
+              id="radio-email-hide"
+              className="custom-control-input"
+              name="mailVisibility"
+              checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
+              onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
             />
-            <p className="form-text text-muted">{t('admin:app_setting.header_content')}</p>
+            <label className="custom-control-label" htmlFor="radio-email-hide">{t('Hide')}</label>
           </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.default_language')}
-          </label>
-          <div className="col-md-6 py-2">
-            {
-              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.default_mail_visibility')}
-          </label>
-          <div className="col-md-6 py-2">
-
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radio-email-show"
-                className="custom-control-input"
-                name="mailVisibility"
-                checked={adminAppContainer.state.isEmailPublishedForNewUser === true}
-                onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(true) }}
-              />
-              <label className="custom-control-label" htmlFor="radio-email-show">{t('Show')}</label>
-            </div>
-
-            <div className="custom-control custom-radio custom-control-inline">
-              <input
-                type="radio"
-                id="radio-email-hide"
-                className="custom-control-input"
-                name="mailVisibility"
-                checked={adminAppContainer.state.isEmailPublishedForNewUser === false}
-                onChange={() => { adminAppContainer.changeIsEmailPublishedForNewUserShow(false) }}
-              />
-              <label className="custom-control-label" htmlFor="radio-email-hide">{t('Hide')}</label>
-            </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>
+        <div className="col-md-6">
+          <div className="custom-control custom-checkbox custom-checkbox-info">
+            <input
+              type="checkbox"
+              id="cbFileUpload"
+              className="custom-control-input"
+              name="fileUpload"
+              checked={adminAppContainer.state.fileUpload}
+              onChange={(e) => {
+                adminAppContainer.changeFileUpload(e.target.checked);
+              }}
+            />
+            <label
+              className="custom-control-label"
+              htmlFor="cbFileUpload"
+            >
+              {t('admin:app_setting.enable_files_except_image')}
+            </label>
           </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>
-          <div className="col-md-6">
-            <div className="custom-control custom-checkbox custom-checkbox-info">
-              <input
-                type="checkbox"
-                id="cbFileUpload"
-                className="custom-control-input"
-                name="fileUpload"
-                checked={adminAppContainer.state.fileUpload}
-                onChange={(e) => {
-                  adminAppContainer.changeFileUpload(e.target.checked);
-                }}
-              />
-              <label
-                className="custom-control-label"
-                htmlFor="cbFileUpload"
-              >
-                {t('admin:app_setting.enable_files_except_image')}
-              </label>
-            </div>
-
-            <p className="form-text text-muted">
-              {t('admin:app_setting.attach_enable')}
-            </p>
-          </div>
+          <p className="form-text text-muted">
+            {t('admin:app_setting.attach_enable')}
+          </p>
         </div>
+      </div>
+
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+    </React.Fragment>
+  );
 
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-      </React.Fragment>
-    );
-  }
+};
 
-}
 
 /**
  * Wrapper component for using unstated
@@ -183,8 +177,8 @@ class AppSetting extends React.Component {
 const AppSettingWrapper = withUnstatedContainers(AppSetting, [AdminAppContainer]);
 
 AppSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-export default withTranslation()(AppSettingWrapper);
+
+export default AppSettingWrapper;

+ 0 - 113
packages/app/src/components/Admin/App/AppSettingsPageContents.jsx

@@ -1,113 +0,0 @@
-import React from 'react';
-import { withTranslation } from 'react-i18next';
-import PropTypes from 'prop-types';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppSetting from './AppSetting';
-import SiteUrlSetting from './SiteUrlSetting';
-import MailSetting from './MailSetting';
-import PluginSetting from './PluginSetting';
-import FileUploadSetting from './FileUploadSetting';
-import V5PageMigration from './V5PageMigration';
-import MaintenanceMode from './MaintenanceMode';
-
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-
-class AppSettingsPageContents extends React.Component {
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-    const { isV5Compatible } = adminAppContainer.state;
-
-    return (
-      <div data-testid="admin-app-settings">
-        {
-          // Alert message will be displayed in case that the GROWI is under maintenance
-          adminAppContainer.state.isMaintenanceMode && (
-            <div className="alert alert-danger alert-link" role="alert">
-              <h3 className="alert-heading">
-                {t('admin:maintenance_mode.maintenance_mode')}
-              </h3>
-              <p>
-                {t('admin:maintenance_mode.description')}
-              </p>
-              <hr />
-              <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
-                <i className="fa fa-fw fa-arrow-down ml-1" aria-hidden="true"></i>
-                <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
-              </a>
-            </div>
-          )
-        }
-        {
-          !isV5Compatible
-          && (
-            <div className="row">
-              <div className="col-lg-12">
-                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
-                <V5PageMigration />
-              </div>
-            </div>
-          )
-        }
-
-        <div className="row">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('App Settings')}</h2>
-            <AppSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
-            <SiteUrlSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header" id="mail-settings">{t('admin:app_setting.mail_settings')}</h2>
-            <MailSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.file_upload_settings')}</h2>
-            <FileUploadSetting />
-          </div>
-        </div>
-
-        <div className="row mt-5">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
-            <PluginSetting />
-          </div>
-        </div>
-
-        <div className="row">
-          <div className="col-lg-12">
-            <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
-            <MaintenanceMode />
-          </div>
-        </div>
-
-      </div>
-
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
-
-AppSettingsPageContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default withTranslation()(AppSettingsPageContentsWrapper);

+ 108 - 0
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -0,0 +1,108 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
+import AppSetting from './AppSetting';
+import FileUploadSetting from './FileUploadSetting';
+import MailSetting from './MailSetting';
+import MaintenanceMode from './MaintenanceMode';
+import PluginSetting from './PluginSetting';
+import SiteUrlSetting from './SiteUrlSetting';
+import V5PageMigration from './V5PageMigration';
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
+
+const AppSettingsPageContents = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
+  const { isV5Compatible } = adminAppContainer.state;
+
+  return (
+    <div data-testid="admin-app-settings">
+      {
+        // Alert message will be displayed in case that the GROWI is under maintenance
+        adminAppContainer.state.isMaintenanceMode && (
+          <div className="alert alert-danger alert-link" role="alert">
+            <h3 className="alert-heading">
+              {t('admin:maintenance_mode.maintenance_mode')}
+            </h3>
+            <p>
+              {t('admin:maintenance_mode.description')}
+            </p>
+            <hr />
+            <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
+              <i className="fa fa-fw fa-arrow-down ml-1" aria-hidden="true"></i>
+              <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
+            </a>
+          </div>
+        )
+      }
+      {
+        !isV5Compatible
+          && (
+            <div className="row">
+              <div className="col-lg-12">
+                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <V5PageMigration />
+              </div>
+            </div>
+          )
+      }
+
+      <div className="row">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('App Settings')}</h2>
+          <AppSetting />
+        </div>
+      </div>
+
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('Site URL settings')}</h2>
+          <SiteUrlSetting />
+        </div>
+      </div>
+
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header" id="mail-settings">{t('admin:app_setting.mail_settings')}</h2>
+          <MailSetting />
+        </div>
+      </div>
+
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:app_setting.file_upload_settings')}</h2>
+          <FileUploadSetting />
+        </div>
+      </div>
+
+      <div className="row mt-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">{t('admin:app_setting.plugin_settings')}</h2>
+          <PluginSetting />
+        </div>
+      </div>
+
+      <div className="row">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header" id="maintenance-mode">{t('admin:maintenance_mode.maintenance_mode')}</h2>
+          <MaintenanceMode />
+        </div>
+      </div>
+    </div>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const AppSettingsPageContentsWrapper = withUnstatedContainers(AppSettingsPageContents, [AdminAppContainer]);
+
+export default AppSettingsPageContentsWrapper;

+ 20 - 24
packages/app/src/components/Admin/App/FileUploadSetting.jsx → packages/app/src/components/Admin/App/FileUploadSetting.tsx

@@ -1,26 +1,29 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import React, { useCallback } from 'react';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import AwsSetting from './AwsSetting';
 import GcsSettings from './GcsSettings';
 
-function FileUploadSetting(props) {
 
-  const { t, adminAppContainer } = props;
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
+
+
+const FileUploadSetting = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
   const { fileUploadType } = adminAppContainer.state;
   const fileUploadTypes = ['aws', 'gcs', 'gridfs', 'local'];
 
-  async function submitHandler() {
-    const { t } = props;
-
+  const submitHandler = useCallback(async() => {
     try {
       await adminAppContainer.updateFileUploadSettingHandler();
       toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.file_upload_settings') }));
@@ -28,10 +31,10 @@ function FileUploadSetting(props) {
     catch (err) {
       toastError(err);
     }
-  }
+  }, [adminAppContainer, t]);
 
   return (
-    <React.Fragment>
+    <>
       <p className="card well my-3">
         {t('admin:app_setting.file_upload')}
         <br />
@@ -79,21 +82,14 @@ function FileUploadSetting(props) {
       {fileUploadType === 'gcs' && <GcsSettings />}
 
       <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-
-    </React.Fragment>
+    </>
   );
-}
+};
 
 
 /**
  * Wrapper component for using unstated
  */
-const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AppContainer, AdminAppContainer]);
-
-FileUploadSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
+const FileUploadSettingWrapper = withUnstatedContainers(FileUploadSetting, [AdminAppContainer]);
 
-export default withTranslation()(FileUploadSettingWrapper);
+export default FileUploadSettingWrapper;

+ 10 - 11
packages/app/src/components/Admin/App/GcsSettings.jsx

@@ -1,16 +1,17 @@
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
+
 
-function GcsSetting(props) {
-  const { t, adminAppContainer } = props;
+const GcsSetting = (props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
   const { gcsReferenceFileWithRelayMode, gcsUseOnlyEnvVars } = adminAppContainer.state;
 
   return (
@@ -147,17 +148,15 @@ function GcsSetting(props) {
     </>
   );
 
-}
+};
 
 /**
  * Wrapper component for using unstated
  */
-const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AppContainer, AdminAppContainer]);
+const GcsSettingWrapper = withUnstatedContainers(GcsSetting, [AdminAppContainer]);
 
 GcsSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
 };
 
-export default withTranslation()(GcsSettingWrapper);
+export default GcsSettingWrapper;

+ 16 - 18
packages/app/src/components/Admin/App/MailSetting.jsx → packages/app/src/components/Admin/App/MailSetting.tsx

@@ -1,24 +1,28 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import SmtpSetting from './SmtpSetting';
 import SesSetting from './SesSetting';
+import SmtpSetting from './SmtpSetting';
+
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
 
 
-function MailSetting(props) {
-  const { t, adminAppContainer } = props;
+const MailSetting = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
 
   const transmissionMethods = ['smtp', 'ses'];
 
   async function submitHandler() {
-    const { t } = props;
-
     try {
       await adminAppContainer.updateMailSettingHandler();
       toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.ses_settings') }));
@@ -101,17 +105,11 @@ function MailSetting(props) {
     </React.Fragment>
   );
 
-}
+};
 
 /**
  * Wrapper component for using unstated
  */
-const MailSettingWrapper = withUnstatedContainers(MailSetting, [AppContainer, AdminAppContainer]);
-
-MailSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
+const MailSettingWrapper = withUnstatedContainers(MailSetting, [AdminAppContainer]);
 
-export default withTranslation()(MailSettingWrapper);
+export default MailSettingWrapper;

+ 0 - 79
packages/app/src/components/Admin/App/PluginSetting.jsx

@@ -1,79 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:app:pluginSetting');
-
-class PluginSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
-
-    try {
-      await adminAppContainer.updatePluginSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.plugin_settings') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">{t('admin:app_setting.enable_plugin_loading')}</p>
-
-        <div className="row form-group mb-5">
-          <div className="offset-3 col-6 text-left">
-            <div className="custom-control custom-checkbox custom-checkbox-success">
-              <input
-                id="isEnabledPlugins"
-                className="custom-control-input"
-                type="checkbox"
-                checked={adminAppContainer.state.isEnabledPlugins}
-                onChange={(e) => {
-                  adminAppContainer.changeIsEnabledPlugins(e.target.checked);
-                }}
-              />
-              <label className="custom-control-label" htmlFor="isEnabledPlugins">{t('admin:app_setting.load_plugins')}</label>
-            </div>
-          </div>
-        </div>
-
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-      </React.Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const PluginSettingWrapper = withUnstatedContainers(PluginSetting, [AppContainer, AdminAppContainer]);
-
-PluginSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default withTranslation()(PluginSettingWrapper);

+ 66 - 0
packages/app/src/components/Admin/App/PluginSetting.tsx

@@ -0,0 +1,66 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:app:pluginSetting');
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
+
+const PluginSetting = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
+
+
+  const submitHandler = useCallback(async() => {
+    try {
+      await adminAppContainer.updatePluginSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:app_setting.plugin_settings') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }, [adminAppContainer, t]);
+
+  return (
+    <>
+      <p className="card well">{t('admin:app_setting.enable_plugin_loading')}</p>
+
+      <div className="row form-group mb-5">
+        <div className="offset-3 col-6 text-left">
+          <div className="custom-control custom-checkbox custom-checkbox-success">
+            <input
+              id="isEnabledPlugins"
+              className="custom-control-input"
+              type="checkbox"
+              checked={adminAppContainer.state.isEnabledPlugins}
+              onChange={(e) => {
+                adminAppContainer.changeIsEnabledPlugins(e.target.checked);
+              }}
+            />
+            <label className="custom-control-label" htmlFor="isEnabledPlugins">{t('admin:app_setting.load_plugins')}</label>
+          </div>
+        </div>
+      </div>
+
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+    </>
+  );
+
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const PluginSettingWrapper = withUnstatedContainers(PluginSetting, [AdminAppContainer]);
+
+export default PluginSettingWrapper;

+ 10 - 16
packages/app/src/components/Admin/App/SesSetting.jsx → packages/app/src/components/Admin/App/SesSetting.tsx

@@ -1,16 +1,16 @@
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { withLoadingSppiner } from '../../SuspenseUtils';
+import AdminAppContainer from '~/client/services/AdminAppContainer';
 
+import { withLoadingSppiner } from '../../SuspenseUtils';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
 
-function SmtpSetting(props) {
+const SmtpSetting = (props: Props) => {
   const { adminAppContainer } = props;
 
   return (
@@ -52,17 +52,11 @@ function SmtpSetting(props) {
 
     </React.Fragment>
   );
-}
+};
 
 /**
  * Wrapper component for using unstated
  */
-const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
-
-SmtpSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AdminAppContainer]);
 
-export default withTranslation()(SmtpSettingWrapper);
+export default SmtpSettingWrapper;

+ 0 - 105
packages/app/src/components/Admin/App/SiteUrlSetting.jsx

@@ -1,105 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import loggerFactory from '~/utils/logger';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-const logger = loggerFactory('growi:appSettings');
-
-class SiteUrlSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  async submitHandler() {
-    const { t, adminAppContainer } = this.props;
-
-    try {
-      await adminAppContainer.updateSiteUrlSettingHandler();
-      toastSuccess(t('toaster.update_successed', { target: t('Site URL settings') }));
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  }
-
-  render() {
-    const { t, adminAppContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <p className="card well">{t('admin:app_setting.site_url_desc')}</p>
-        {!adminAppContainer.state.isSetSiteUrl
-          && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.site_url_warn')}</p>)}
-
-        <div className="row form-group">
-          <div className="col-md-9 offset-md-3">
-            <table className="table settings-table">
-              <colgroup>
-                <col className="from-db" />
-                <col className="from-env-vars" />
-              </colgroup>
-              <thead>
-                <tr>
-                  <th>Database</th>
-                  <th>Environment variables</th>
-                </tr>
-              </thead>
-              <tbody>
-                <tr>
-                  <td>
-                    <input
-                      className="form-control"
-                      type="text"
-                      name="settingForm[app:siteUrl]"
-                      defaultValue={adminAppContainer.state.siteUrl || ''}
-                      onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
-                      placeholder="e.g. https://my.growi.org"
-                    />
-                    <p className="form-text text-muted">
-                      {/* eslint-disable-next-line react/no-danger */}
-                      <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.siteurl_help') }} />
-                    </p>
-                  </td>
-                  <td>
-                    <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
-                    <p className="form-text text-muted">
-                      {/* eslint-disable-next-line react/no-danger */}
-                      <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
-                    </p>
-                  </td>
-                </tr>
-              </tbody>
-            </table>
-          </div>
-        </div>
-
-        <AdminUpdateButtonRow onClick={this.submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
-      </React.Fragment>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [AppContainer, AdminAppContainer]);
-
-SiteUrlSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default withTranslation()(SiteUrlSettingWrapper);

+ 93 - 0
packages/app/src/components/Admin/App/SiteUrlSetting.tsx

@@ -0,0 +1,93 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import loggerFactory from '~/utils/logger';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const logger = loggerFactory('growi:appSettings');
+
+
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
+
+const SiteUrlSetting = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
+
+
+  const submitHandler = useCallback(async() => {
+    try {
+      await adminAppContainer.updateSiteUrlSettingHandler();
+      toastSuccess(t('toaster.update_successed', { target: t('Site URL settings') }));
+    }
+    catch (err) {
+      toastError(err);
+      logger.error(err);
+    }
+  }, [adminAppContainer, t]);
+
+  return (
+    <React.Fragment>
+      <p className="card well">{t('admin:app_setting.site_url_desc')}</p>
+      {!adminAppContainer.state.isSetSiteUrl
+          && (<p className="alert alert-danger"><i className="icon-exclamation"></i> {t('admin:app_setting.site_url_warn')}</p>)}
+
+      <div className="row form-group">
+        <div className="col-md-9 offset-md-3">
+          <table className="table settings-table">
+            <colgroup>
+              <col className="from-db" />
+              <col className="from-env-vars" />
+            </colgroup>
+            <thead>
+              <tr>
+                <th>Database</th>
+                <th>Environment variables</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>
+                  <input
+                    className="form-control"
+                    type="text"
+                    name="settingForm[app:siteUrl]"
+                    defaultValue={adminAppContainer.state.siteUrl || ''}
+                    onChange={(e) => { adminAppContainer.changeSiteUrl(e.target.value) }}
+                    placeholder="e.g. https://my.growi.org"
+                  />
+                  <p className="form-text text-muted">
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.siteurl_help') }} />
+                  </p>
+                </td>
+                <td>
+                  <input className="form-control" type="text" value={adminAppContainer.state.envSiteUrl || ''} readOnly />
+                  <p className="form-text text-muted">
+                    {/* eslint-disable-next-line react/no-danger */}
+                    <span dangerouslySetInnerHTML={{ __html: t('admin:app_setting.use_env_var_if_empty', { variable: 'APP_SITE_URL' }) }} />
+                  </p>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <AdminUpdateButtonRow onClick={submitHandler} disabled={adminAppContainer.state.retrieveError != null} />
+    </React.Fragment>
+  );
+};
+
+/**
+ * Wrapper component for using unstated
+ */
+const SiteUrlSettingWrapper = withUnstatedContainers(SiteUrlSetting, [AdminAppContainer]);
+
+export default SiteUrlSettingWrapper;

+ 14 - 17
packages/app/src/components/Admin/App/SmtpSetting.jsx → packages/app/src/components/Admin/App/SmtpSetting.tsx

@@ -1,17 +1,21 @@
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import { useTranslation } from 'react-i18next';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+
 import { withLoadingSppiner } from '../../SuspenseUtils';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import AdminAppContainer from '~/client/services/AdminAppContainer';
+type Props = {
+  adminAppContainer: AdminAppContainer,
+}
 
-function SmtpSetting(props) {
-  const { adminAppContainer, t } = props;
+const SmtpSetting = (props: Props) => {
+  const { t } = useTranslation();
+  const { adminAppContainer } = props;
 
   return (
     <React.Fragment>
@@ -73,17 +77,10 @@ function SmtpSetting(props) {
       </div>
     </React.Fragment>
   );
-}
+};
 
 /**
  * Wrapper component for using unstated
  */
-const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AppContainer, AdminAppContainer]);
-
-SmtpSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminAppContainer: PropTypes.instanceOf(AdminAppContainer).isRequired,
-};
-
-export default withTranslation()(SmtpSettingWrapper);
+const SmtpSettingWrapper = withUnstatedContainers(withLoadingSppiner(SmtpSetting), [AdminAppContainer]);
+export default SmtpSettingWrapper;

+ 76 - 0
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -0,0 +1,76 @@
+import React, { FC, useState, useCallback } from 'react';
+
+import { pagePathUtils } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import { format } from 'date-fns';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
+
+import { IActivityHasId } from '~/interfaces/activity';
+
+type Props = {
+  activityList: IActivityHasId[]
+}
+
+const formatDate = (date) => {
+  return format(new Date(date), 'yyyy/MM/dd HH:mm:ss');
+};
+
+export const ActivityTable : FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const [tooltopOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, [setTooltipOpen]);
+
+  return (
+    <div className="table-responsive text-nowrap h-100">
+      <table className="table table-default table-bordered table-user-list">
+        <thead>
+          <tr>
+            <th scope="col">{t('admin:audit_log_management.user')}</th>
+            <th scope="col">{t('admin:audit_log_management.date')}</th>
+            <th scope="col">{t('admin:audit_log_management.action')}</th>
+            <th scope="col">{t('admin:audit_log_management.ip')}</th>
+            <th scope="col">{t('admin:audit_log_management.url')}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.activityList.map((activity) => {
+            return (
+              <tr data-testid="activity-table" key={activity._id}>
+                <td>
+                  { activity.user != null && (
+                    <>
+                      <UserPicture user={activity.user} className="picture rounded-circle" />
+                      <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
+                    </>
+                  )}
+                </td>
+                <td>{formatDate(activity.createdAt)}</td>
+                <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
+                <td>{activity.ip}</td>
+                <td>
+                  {activity.endpoint}
+                  <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
+                    <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
+                      <i className="fa fa-clipboard" aria-hidden="true"></i>
+                    </button>
+                  </CopyToClipboard>
+                  <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">
+                    copied!
+                  </Tooltip>
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+};

+ 26 - 0
packages/app/src/components/Admin/AuditLog/AuditLogDisableMode.tsx

@@ -0,0 +1,26 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+export const AuditLogDisableMode: FC = () => {
+  const { t } = useTranslation();
+
+  return (
+    <div id="content-main" className="content-main container-lg">
+      <div className="container">
+        <div className="row justify-content-md-center">
+          <div className="col-md-6 mt-5">
+            <div className="text-center">
+              <h1><i className="icon-exclamation large"></i></h1>
+              <h1 className="text-center">{t('AuditLog')}</h1>
+              <h3
+                // eslint-disable-next-line react/no-danger
+                dangerouslySetInnerHTML={{ __html: t('admin:audit_log_management.disable_mode_explain') }}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};

+ 70 - 0
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -0,0 +1,70 @@
+import React, { FC, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Collapse } from 'reactstrap';
+
+import { AllSupportedActions } from '~/interfaces/activity';
+import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores/context';
+
+export const AuditLogSettings: FC = () => {
+  const { t } = useTranslation();
+
+  const [isExpandActionList, setIsExpandActionList] = useState(false);
+
+  const { data: activityExpirationSecondsData } = useActivityExpirationSeconds();
+  const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000;
+
+  const { data: availableActionsData } = useAuditLogAvailableActions();
+  const availableActions = availableActionsData != null ? availableActionsData : [];
+
+  return (
+    <>
+      <h4 className="mt-4">{t('admin:audit_log_management.activity_expiration_date')}</h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.activity_expiration_date_explain')}
+      </p>
+      <p className="alert alert-warning col-6">
+        <i className="icon-exclamation icon-fw">
+        </i><b>FIXED</b><br />
+        <b
+          // eslint-disable-next-line react/no-danger
+          dangerouslySetInnerHTML={{
+            __html: t('admin:audit_log_management.fixed_by_env_var',
+              { key: 'ACTIVITY_EXPIRATION_SECONDS', value: activityExpirationSeconds }),
+          }}
+        />
+      </p>
+
+      <h4 className="mt-4">
+        {t('admin:audit_log_management.available_action_list')}
+        <span className="badge badge-pill badge-secondary ml-2">
+          {`${availableActions.length} / ${AllSupportedActions.length}`}
+        </span>
+        <a
+          className="ml-2"
+          href={t('admin:audit_log_management.docs_url.log_type')}
+          target="_blank"
+          rel="noopener noreferrer"
+        >
+          <i className="icon-fw icon-question" aria-hidden="true"></i>
+        </a>
+      </h4>
+      <p className="form-text text-muted">
+        {t('admin:audit_log_management.available_action_list_explain')}
+      </p>
+      <p className="mt-1">
+        <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
+          <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>
+          { t('admin:audit_log_management.action_list') }
+        </button>
+      </p>
+      <Collapse isOpen={isExpandActionList}>
+        <ul className="list-group">
+          { availableActions.map(action => (
+            <li key={action} className="list-group-item">{t(`admin:audit_log_action.${action}`)}</li>
+          )) }
+        </ul>
+      </Collapse>
+    </>
+  );
+};

+ 73 - 0
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -0,0 +1,73 @@
+import React, { FC, forwardRef, useCallback } from 'react';
+
+import { addDays, format } from 'date-fns';
+import DatePicker from 'react-datepicker';
+import 'react-datepicker/dist/react-datepicker.css';
+
+
+type CustomInputProps = {
+  value?: string
+  onChange?: () => void
+  onFocus?: () => void
+}
+
+const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: CustomInputProps, ref) => {
+  const dateFormat = 'MM/dd/yyyy';
+  const date = new Date();
+  const placeholder = `${format(date, dateFormat)} - ${format(addDays(date, 1), dateFormat)}`;
+
+  return (
+    <div className="input-group admin-audit-log">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="fa fa-fw fa-calendar" />
+        </span>
+      </div>
+      <input
+        ref={ref}
+        type="text"
+        value={props?.value}
+        onFocus={props?.onFocus}
+        onChange={props?.onChange}
+        placeholder={placeholder}
+        className="form-control date-range-picker"
+        aria-describedby="basic-addon1"
+      />
+    </div>
+  );
+});
+
+type DateRangePickerProps = {
+  startDate: Date | null
+  endDate: Date | null
+  onChange: (dateList: Date[] | null[]) => void
+}
+
+export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
+  const { startDate, endDate, onChange } = props;
+
+  const changeHandler = useCallback((dateList: Date[] | null[]) => {
+    if (onChange != null) {
+      const [start, end] = dateList;
+      const isSameTime = (start != null && end != null) && (start.getTime() === end.getTime());
+      if (isSameTime) {
+        onChange([null, null]);
+      }
+      else {
+        onChange(dateList);
+      }
+    }
+  }, [onChange]);
+
+  return (
+    <div className="btn-group mr-2">
+      <DatePicker
+        selectsRange
+        startDate={startDate}
+        endDate={endDate}
+        onChange={changeHandler}
+        customInput={<CustomInput />}
+      />
+    </div>
+  );
+};

+ 137 - 0
packages/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -0,0 +1,137 @@
+import React, {
+  Fragment, useState, useCallback, useRef, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
+} from 'react';
+
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
+import { useTranslation } from 'react-i18next';
+
+import { IClearable } from '~/client/interfaces/clearable';
+import { useSWRxUsernames } from '~/stores/user';
+
+
+const Categories = {
+  activeUser: 'Active User',
+  inactiveUser: 'Inactive User',
+  activitySnapshotUser: 'Activity Snapshot User',
+} as const;
+
+type CategoryType = typeof Categories[keyof typeof Categories]
+
+type UserDataType = {
+  username: string
+  category: CategoryType
+}
+
+type Props = {
+  onChange: (text: string[]) => void
+}
+
+const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Props> = ((props: Props, ref) => {
+  const { onChange } = props;
+  const { t } = useTranslation();
+
+  const typeaheadRef = useRef<IClearable>(null);
+
+  /*
+   * State
+   */
+  const [searchKeyword, setSearchKeyword] = useState<string>('');
+
+  /*
+   * Fetch
+   */
+  const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
+  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
+  const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
+  const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
+  const isLoading = usernameData === undefined && error == null;
+
+  const allUser: UserDataType[] = [];
+  const pushToAllUser = (usernames: string[], category: CategoryType) => {
+    usernames.forEach(username => allUser.push({ username, category }));
+  };
+  pushToAllUser(activeUsernames, Categories.activeUser);
+  pushToAllUser(inactiveUsernames, Categories.inactiveUser);
+  pushToAllUser(activitySnapshotUsernames, Categories.activitySnapshotUser);
+
+  /*
+   * Functions
+   */
+  const changeHandler = useCallback((userData: UserDataType[]) => {
+    if (onChange != null) {
+      const usernames = userData.map(user => user.username);
+      onChange(usernames);
+    }
+  }, [onChange]);
+
+  const searchHandler = useCallback((text: string) => {
+    setSearchKeyword(text);
+  }, []);
+
+  const renderMenu = useCallback((allUser: UserDataType[], menuProps) => {
+    if (allUser == null || allUser.length === 0) {
+      return <></>;
+    }
+
+    let index = 0;
+    const items = Object.values(Categories).map((category) => {
+      const userData = allUser.filter(user => user.category === category);
+      return (
+        <Fragment key={category}>
+          {index !== 0 && <Menu.Divider />}
+          <Menu.Header>{category}</Menu.Header>
+          {userData.map((user) => {
+            const item = (
+              <MenuItem key={index} option={user} position={index}>
+                {user.username}
+              </MenuItem>
+            );
+            index++;
+            return item;
+          })}
+        </Fragment>
+      );
+    });
+
+    return (
+      <Menu {...menuProps}>{items}</Menu>
+    );
+  }, []);
+
+  useImperativeHandle(ref, () => ({
+    clear() {
+      const instance = typeaheadRef?.current;
+      if (instance != null) {
+        instance.clear();
+      }
+    },
+  }));
+
+  return (
+    <div className="input-group mr-2">
+      <div className="input-group-prepend">
+        <span className="input-group-text">
+          <i className="icon-people" />
+        </span>
+      </div>
+      <AsyncTypeahead
+        ref={typeaheadRef}
+        id="search-username-typeahead-asynctypeahead"
+        multiple
+        delay={400}
+        minLength={0}
+        placeholder={t('admin:audit_log_management.username')}
+        caseSensitive={false}
+        isLoading={isLoading}
+        options={allUser}
+        onSearch={searchHandler}
+        onChange={changeHandler}
+        renderMenu={renderMenu}
+        labelKey={(option: UserDataType) => `${option.username}`}
+      />
+    </div>
+  );
+});
+
+export const SearchUsernameTypeahead = forwardRef(SearchUsernameTypeaheadSubstance);

+ 123 - 0
packages/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -0,0 +1,123 @@
+import React, { FC, useMemo, useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import {
+  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
+} from '~/interfaces/activity';
+
+type Props = {
+  actionMap: Map<SupportedActionType, boolean>
+  availableActions: SupportedActionType[]
+  onChangeAction: (action: SupportedActionType) => void
+  onChangeMultipleAction: (actions: SupportedActionType[], isChecked: boolean) => void
+}
+
+export const SelectActionDropdown: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const {
+    actionMap, availableActions, onChangeAction, onChangeMultipleAction,
+  } = props;
+
+  const dropdownItems = useMemo<Array<{actionCategory: SupportedActionCategoryType, actions: SupportedActionType[]}>>(() => {
+    return (
+      [
+        {
+          actionCategory: SupportedActionCategory.PAGE,
+          actions: PageActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.COMMENT,
+          actions: CommentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.TAG,
+          actions: TagActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ATTACHMENT,
+          actions: AttachmentActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SHARE_LINK,
+          actions: ShareLinkActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.IN_APP_NOTIFICATION,
+          actions: InAppNotificationActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.SEARCH,
+          actions: SearchActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.USER,
+          actions: UserActions.filter(action => availableActions.includes(action)),
+        },
+        {
+          actionCategory: SupportedActionCategory.ADMIN,
+          actions: AdminActions.filter(action => availableActions.includes(action)),
+        },
+      ]
+    );
+  }, [availableActions]).filter(item => item.actions.length !== 0);
+
+  const actionCheckboxChangedHandler = useCallback((action) => {
+    if (onChangeAction != null) {
+      onChangeAction(action);
+    }
+  }, [onChangeAction]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions, isChecked) => {
+    if (onChangeMultipleAction != null) {
+      onChangeMultipleAction(actions, isChecked);
+    }
+  }, [onChangeMultipleAction]);
+
+  return (
+    <div className="btn-group mr-2 admin-audit-log">
+      <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown">
+        <i className="fa fa-fw fa-bolt" />{t('admin:audit_log_management.action')}
+      </button>
+      <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
+        {dropdownItems.map(item => (
+          <div key={item.actionCategory}>
+            <div className="dropdown-item">
+              <div className="form-group px-2 m-0">
+                <input
+                  type="checkbox"
+                  className="form-check-input"
+                  defaultChecked
+                  onChange={(e) => { multipleActionCheckboxChangedHandler(item.actions, e.target.checked) }}
+                />
+                <label className="form-check-label">{t(`admin:audit_log_action_category.${item.actionCategory}`)}</label>
+              </div>
+            </div>
+            {
+              item.actions.map(action => (
+                <div className="dropdown-item" key={action}>
+                  <div className="form-group px-4 m-0">
+                    <input
+                      type="checkbox"
+                      className="form-check-input"
+                      id={`checkbox${action}`}
+                      onChange={() => { actionCheckboxChangedHandler(action) }}
+                      checked={actionMap.get(action)}
+                    />
+                    <label
+                      className="form-check-label"
+                      htmlFor={`checkbox${action}`}
+                    >
+                      {t(`admin:audit_log_action.${action}`)}
+                    </label>
+                  </div>
+                </div>
+              ))
+            }
+          </div>
+        ))}
+      </ul>
+    </div>
+  );
+};

+ 202 - 0
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -0,0 +1,202 @@
+import React, {
+  FC, useState, useCallback, useRef,
+} from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import { IClearable } from '~/client/interfaces/clearable';
+import { toastError } from '~/client/util/apiNotification';
+import { SupportedActionType } from '~/interfaces/activity';
+import { useSWRxActivity } from '~/stores/activity';
+import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+
+import PaginationWrapper from '../PaginationWrapper';
+
+import { ActivityTable } from './AuditLog/ActivityTable';
+import { AuditLogDisableMode } from './AuditLog/AuditLogDisableMode';
+import { AuditLogSettings } from './AuditLog/AuditLogSettings';
+import { DateRangePicker } from './AuditLog/DateRangePicker';
+import { SearchUsernameTypeahead } from './AuditLog/SearchUsernameTypeahead';
+import { SelectActionDropdown } from './AuditLog/SelectActionDropdown';
+
+const formatDate = (date: Date | null) => {
+  if (date == null) {
+    return '';
+  }
+  return format(new Date(date), 'yyyy-MM-dd');
+};
+
+const PAGING_LIMIT = 10;
+
+export const AuditLogManagement: FC = () => {
+  const { t } = useTranslation();
+
+  const typeaheadRef = useRef<IClearable>(null);
+
+  const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
+
+  /*
+   * State
+   */
+  const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
+  const [activePage, setActivePage] = useState<number>(1);
+  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [startDate, setStartDate] = useState<Date | null>(null);
+  const [endDate, setEndDate] = useState<Date | null>(null);
+  const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
+  const [actionMap, setActionMap] = useState(
+    new Map<SupportedActionType, boolean>(auditLogAvailableActionsData != null ? auditLogAvailableActionsData.map(action => [action, true]) : []),
+  );
+
+  /*
+   * Fetch
+   */
+  const selectedDate = { startDate: formatDate(startDate), endDate: formatDate(endDate) };
+  const selectedActionList = Array.from(actionMap.entries()).filter(v => v[1]).map(v => v[0]);
+  const searchFilter = { actions: selectedActionList, dates: selectedDate, usernames: selectedUsernames };
+
+  const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
+  const activityList = activityData?.docs != null ? activityData.docs : [];
+  const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const isLoading = activityData === undefined && error == null;
+
+  if (error != null) {
+    toastError('Failed to get Audit Log');
+  }
+
+  const { data: auditLogEnabled } = useAuditLogEnabled();
+
+  /*
+   * Functions
+   */
+  const setActivePageHandler = useCallback((selectedPageNum: number) => {
+    setActivePage(selectedPageNum);
+  }, []);
+
+  const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
+    setActivePage(1);
+    setStartDate(dateList[0]);
+    setEndDate(dateList[1]);
+  }, []);
+
+  const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
+    setActivePage(1);
+    actionMap.set(action, !actionMap.get(action));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
+    setActivePage(1);
+    actions.forEach(action => actionMap.set(action, isChecked));
+    setActionMap(new Map(actionMap.entries()));
+  }, [actionMap, setActionMap]);
+
+  const setUsernamesHandler = useCallback((usernames: string[]) => {
+    setActivePage(1);
+    setSelectedUsernames(usernames);
+  }, []);
+
+  const clearButtonPushedHandler = useCallback(() => {
+    setActivePage(1);
+    setStartDate(null);
+    setEndDate(null);
+    setSelectedUsernames([]);
+    typeaheadRef.current?.clear();
+
+    if (auditLogAvailableActionsData != null) {
+      setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
+    }
+  }, [setActivePage, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
+
+  const reloadButtonPushedHandler = useCallback(() => {
+    setActivePage(1);
+    mutateActivity();
+  }, [mutateActivity]);
+
+  // eslint-disable-next-line max-len
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+
+  if (!auditLogEnabled) {
+    return <AuditLogDisableMode />;
+  }
+
+  return (
+    <div data-testid="admin-auditlog">
+      <button type="button" className="btn btn-outline-secondary mb-4" onClick={() => setIsSettingPage(!isSettingPage)}>
+        {
+          isSettingPage
+            ? <><i className="fa fa-hand-o-left mr-1" />{t('admin:audit_log_management.return')}</>
+            : <><i className="fa icon-settings mr-1" />{t('admin:audit_log_management.settings')}</>
+        }
+      </button>
+
+      <h2 className="admin-setting-header mb-3">
+        <span>
+          {isSettingPage ? t('AuditLog Settings') : t('AuditLog')}
+        </span>
+        { !isSettingPage && (
+          <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={reloadButtonPushedHandler}>
+            <i className="icon icon-reload"></i>
+          </button>
+        )}
+      </h2>
+
+      {isSettingPage ? (
+        <AuditLogSettings />
+      ) : (
+        <>
+          <div className="form-inline mb-3">
+            <SearchUsernameTypeahead
+              ref={typeaheadRef}
+              onChange={setUsernamesHandler}
+            />
+
+            <DateRangePicker
+              startDate={startDate}
+              endDate={endDate}
+              onChange={datePickerChangedHandler}
+            />
+
+            <SelectActionDropdown
+              actionMap={actionMap}
+              availableActions={auditLogAvailableActionsData || []}
+              onChangeAction={actionCheckboxChangedHandler}
+              onChangeMultipleAction={multipleActionCheckboxChangedHandler}
+            />
+
+            <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              {t('admin:audit_log_management.clear')}
+            </button>
+          </div>
+
+          <p
+            className="ml-2"
+            // eslint-disable-next-line react/no-danger
+            dangerouslySetInnerHTML={{ __html: activityCounter }}
+          />
+
+          { isLoading
+            ? (
+              <div className="text-muted text-center mb-5">
+                <i className="fa fa-2x fa-spinner fa-pulse mr-1" />
+              </div>
+            )
+            : (
+              <ActivityTable activityList={activityList} />
+            )
+          }
+
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setActivePageHandler}
+            totalItemsCount={totalActivityNum}
+            pagingLimit={PAGING_LIMIT}
+            align="center"
+            size="sm"
+          />
+        </>
+      )}
+    </div>
+  );
+};

+ 12 - 5
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -1,18 +1,22 @@
 /* eslint-disable no-multi-spaces */
 /* eslint-disable react/jsx-props-no-multi-spaces */
 
+
 import React from 'react';
+
+import { pathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { pathUtils } from '@growi/core';
 
 import AppContainer from '~/client/services/AppContainer';
+
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 const AdminNavigation = (props) => {
-  const { t, appContainer } = props;
+  const { t } = useTranslation();
+  const { appContainer } = props;
   const pathname = window.location.pathname;
 
   const growiCloudUri = appContainer.config.env.GROWI_CLOUD_URI;
@@ -33,6 +37,8 @@ const AdminNavigation = (props) => {
       case 'users':                    return <><i className="icon-fw icon-user"></i>            { t('User_Management') }</>;
       case 'user-groups':              return <><i className="icon-fw icon-people"></i>          { t('UserGroup Management') }</>;
       case 'search':                   return <><i className="icon-fw icon-magnifier"></i>       { t('Full Text Search Management') }</>;
+      // TODO: Consider where to place the "AuditLog"
+      case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('AuditLog')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('Wiki Management Home Page') }</>;
     }
@@ -82,6 +88,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
+        <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
             <a
@@ -127,6 +134,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/users') &&             <MenuLabel menu="users" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
+            {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
           </span>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">
@@ -141,8 +149,7 @@ const AdminNavigation = (props) => {
 const AdminNavigationWrapper = withUnstatedContainers(AdminNavigation, [AppContainer]);
 
 AdminNavigation.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
 };
 
-export default withTranslation()(AdminNavigationWrapper);
+export default AdminNavigationWrapper;

+ 0 - 23
packages/app/src/components/Admin/Common/AdminUpdateButtonRow.jsx

@@ -1,23 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-const AdminUpdateButtonRow = (props) => {
-  const { t } = props;
-
-  return (
-    <div className="row my-3">
-      <div className="mx-auto">
-        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>{ t('Update') }</button>
-      </div>
-    </div>
-  );
-};
-
-AdminUpdateButtonRow.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  onClick: PropTypes.func.isRequired,
-  disabled: PropTypes.bool.isRequired,
-};
-
-export default withTranslation()(AdminUpdateButtonRow);

+ 23 - 0
packages/app/src/components/Admin/Common/AdminUpdateButtonRow.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  onClick: () => void,
+  disabled: boolean,
+
+}
+
+const AdminUpdateButtonRow = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="row my-3">
+      <div className="mx-auto">
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>{ t('Update') }</button>
+      </div>
+    </div>
+  );
+};
+
+export default AdminUpdateButtonRow;

+ 9 - 12
packages/app/src/components/Admin/Common/LabeledProgressBar.jsx → packages/app/src/components/Admin/Common/LabeledProgressBar.tsx

@@ -1,11 +1,16 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
 import { Progress } from 'reactstrap';
 
-const LabeledProgressBar = (props) => {
+type Props = {
+  header: string,
+  currentCount: number,
+  totalCount: number,
+  errorsCount?: number,
+  isInProgress?: boolean,
+}
 
+const LabeledProgressBar = (props: Props): JSX.Element => {
   const {
     header, currentCount, totalCount, errorsCount, isInProgress,
   } = props;
@@ -27,12 +32,4 @@ const LabeledProgressBar = (props) => {
 
 };
 
-LabeledProgressBar.propTypes = {
-  header: PropTypes.string.isRequired,
-  currentCount: PropTypes.number.isRequired,
-  totalCount: PropTypes.number.isRequired,
-  errorsCount: PropTypes.number,
-  isInProgress: PropTypes.bool,
-};
-
-export default withTranslation()(LabeledProgressBar);
+export default LabeledProgressBar;

+ 16 - 7
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -1,23 +1,26 @@
 
 import React, { Fragment } from 'react';
+
 import PropTypes from 'prop-types';
 
-import loggerFactory from '~/utils/logger';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AppContainer from '~/client/services/AppContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toArrayIfNot } from '~/utils/array-utils';
+import loggerFactory from '~/utils/logger';
+
 import { withLoadingSppiner } from '../../SuspenseUtils';
+import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import CustomizeLayoutSetting from './CustomizeLayoutSetting';
-import CustomizeThemeSetting from './CustomizeThemeSetting';
+import CustomizeCssSetting from './CustomizeCssSetting';
 import CustomizeFunctionSetting from './CustomizeFunctionSetting';
+import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeHighlightSetting from './CustomizeHighlightSetting';
-import CustomizeCssSetting from './CustomizeCssSetting';
+import CustomizeLayoutSetting from './CustomizeLayoutSetting';
+import CustomizeLogoSetting from './CustomizeLogoSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
-import CustomizeHeaderSetting from './CustomizeHeaderSetting';
+import CustomizeSidebarSetting from './CustomizeSidebarSetting';
+import CustomizeThemeSetting from './CustomizeThemeSetting';
 import CustomizeTitle from './CustomizeTitle';
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
@@ -53,6 +56,9 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeThemeSetting />
       </div>
+      <div className="mb-5">
+        <CustomizeSidebarSetting />
+      </div>
       <div className="mb-5">
         <CustomizeFunctionSetting />
       </div>
@@ -71,6 +77,9 @@ function Customize(props) {
       <div className="mb-5">
         <CustomizeScriptSetting />
       </div>
+      <div className="mb-5">
+        <CustomizeLogoSetting />
+      </div>
     </div>
   );
 }

+ 0 - 79
packages/app/src/components/Admin/Customize/CustomizeCssSetting.jsx

@@ -1,79 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import { Card, CardBody } from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-import CustomCssEditor from '../CustomCssEditor';
-
-class CustomizeCssSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateCustomizeCss();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_css') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.custom_css')}</h2>
-
-            <Card className="card well my-3">
-              <CardBody className="px-0 py-2">
-                { t('admin:customize_setting.write_css') }<br />
-                { t('admin:customize_setting.reflect_change') }
-              </CardBody>
-            </Card>
-
-            <div className="form-group">
-              <CustomCssEditor
-                value={adminCustomizeContainer.state.currentCustomizeCss || ''}
-                onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeCss(inputValue) }}
-              />
-              <p className="form-text text-muted text-right">
-                <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
-                {t('admin:customize_setting.ctrl_space')}
-              </p>
-            </div>
-
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeCssSettingWrapper = withUnstatedContainers(CustomizeCssSetting, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeCssSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeCssSettingWrapper);

+ 68 - 0
packages/app/src/components/Admin/Customize/CustomizeCssSetting.tsx

@@ -0,0 +1,68 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomCssEditor from '../CustomCssEditor';
+
+type Props = {
+  appContainer: AppContainer,
+  adminCustomizeContainer: AdminCustomizeContainer
+}
+
+const CustomizeCssSetting = (props: Props): JSX.Element => {
+
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await adminCustomizeContainer.updateCustomizeCss();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_css') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminCustomizeContainer]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.custom_css')}</h2>
+
+          <Card className="card well my-3">
+            <CardBody className="px-0 py-2">
+              { t('admin:customize_setting.write_css') }<br />
+              { t('admin:customize_setting.reflect_change') }
+            </CardBody>
+          </Card>
+
+          <div className="form-group">
+            <CustomCssEditor
+              value={adminCustomizeContainer.state.currentCustomizeCss || ''}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeCss(inputValue) }}
+            />
+            <p className="form-text text-muted text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
+              {t('admin:customize_setting.ctrl_space')}
+            </p>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+
+};
+
+const CustomizeCssSettingWrapper = withUnstatedContainers(CustomizeCssSetting, [AppContainer, AdminCustomizeContainer]);
+
+export default CustomizeCssSettingWrapper;

+ 0 - 39
packages/app/src/components/Admin/Customize/CustomizeFunctionOption.jsx

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

+ 37 - 0
packages/app/src/components/Admin/Customize/CustomizeFunctionOption.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+
+type Props = {
+  optionId: string
+  label: string,
+  isChecked: boolean,
+  onChecked: () => void,
+  children: React.ReactNode,
+}
+
+const CustomizeFunctionOption = (props: Props): JSX.Element => {
+
+  const {
+    optionId, label, isChecked, onChecked, children,
+  } = props;
+
+  return (
+    <React.Fragment>
+      <div className="custom-control custom-checkbox custom-checkbox-success">
+        <input
+          className="custom-control-input"
+          type="checkbox"
+          id={optionId}
+          checked={isChecked}
+          onChange={onChecked}
+        />
+        <label className="custom-control-label" htmlFor={optionId}>
+          <strong>{label}</strong>
+        </label>
+      </div>
+      {children}
+    </React.Fragment>
+  );
+
+};
+
+export default CustomizeFunctionOption;

+ 0 - 174
packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.jsx

@@ -1,174 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import { Card, CardBody } from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-import CustomizeFunctionOption from './CustomizeFunctionOption';
-import PagingSizeUncontrolledDropdown from './PagingSizeUncontrolledDropdown';
-
-class CustomizeFunctionSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-    };
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateCustomizeFunction();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.function') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.function')}</h2>
-            <Card className="card well my-3">
-              <CardBody className="px-0 py-2">
-                {t('admin:customize_setting.function_desc')}
-              </CardBody>
-            </Card>
-
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isSavedStatesOfTabChanges"
-                  label={t('admin:customize_setting.function_options.tab_switch')}
-                  isChecked={adminCustomizeContainer.state.isSavedStatesOfTabChanges}
-                  onChecked={() => { adminCustomizeContainer.switchSavedStatesOfTabChanges() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.tab_switch_desc1')}<br />
-                    {t('admin:customize_setting.function_options.tab_switch_desc2')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isEnabledAttachTitleHeader"
-                  label={t('admin:customize_setting.function_options.attach_title_header')}
-                  isChecked={adminCustomizeContainer.state.isEnabledAttachTitleHeader}
-                  onChecked={() => { adminCustomizeContainer.switchEnabledAttachTitleHeader() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.attach_title_header_desc')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
-            <PagingSizeUncontrolledDropdown
-              label={t('admin:customize_setting.function_options.list_num_s')}
-              desc={t('admin:customize_setting.function_options.list_num_desc_s')}
-              toggleLabel={adminCustomizeContainer.state.pageLimitationS || 20}
-              dropdownItemSize={[10, 20, 50, 100]}
-              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationS}
-            />
-            <PagingSizeUncontrolledDropdown
-              label={t('admin:customize_setting.function_options.list_num_m')}
-              desc={t('admin:customize_setting.function_options.list_num_desc_m')}
-              toggleLabel={adminCustomizeContainer.state.pageLimitationM || 10}
-              dropdownItemSize={[5, 10, 20, 50, 100]}
-              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationM}
-            />
-            <PagingSizeUncontrolledDropdown
-              label={t('admin:customize_setting.function_options.list_num_l')}
-              desc={t('admin:customize_setting.function_options.list_num_desc_l')}
-              toggleLabel={adminCustomizeContainer.state.pageLimitationL || 50}
-              dropdownItemSize={[20, 50, 100, 200]}
-              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationL}
-            />
-            <PagingSizeUncontrolledDropdown
-              label={t('admin:customize_setting.function_options.list_num_xl')}
-              desc={t('admin:customize_setting.function_options.list_num_desc_xl')}
-              toggleLabel={adminCustomizeContainer.state.pageLimitationXL || 20}
-              dropdownItemSize={[5, 10, 20, 50, 100]}
-              onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationXL}
-            />
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isEnabledStaleNotification"
-                  label={t('admin:customize_setting.function_options.stale_notification')}
-                  isChecked={adminCustomizeContainer.state.isEnabledStaleNotification}
-                  onChecked={() => { adminCustomizeContainer.switchEnableStaleNotification() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.stale_notification_desc')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isAllReplyShown"
-                  label={t('admin:customize_setting.function_options.show_all_reply_comments')}
-                  isChecked={adminCustomizeContainer.state.isAllReplyShown || false}
-                  onChecked={() => { adminCustomizeContainer.switchIsAllReplyShown() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.show_all_reply_comments_desc')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <CustomizeFunctionOption
-                  optionId="isSearchScopeChildrenAsDefault"
-                  label={t('admin:customize_setting.function_options.select_search_scope_children_as_default')}
-                  isChecked={adminCustomizeContainer.state.isSearchScopeChildrenAsDefault || false}
-                  onChecked={() => { adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault() }}
-                >
-                  <p className="form-text text-muted">
-                    {t('admin:customize_setting.function_options.select_search_scope_children_as_default_desc')}
-                  </p>
-                </CustomizeFunctionOption>
-              </div>
-            </div>
-
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeFunctionSettingWrapper = withUnstatedContainers(CustomizeFunctionSetting, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeFunctionSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeFunctionSettingWrapper);

+ 163 - 0
packages/app/src/components/Admin/Customize/CustomizeFunctionSetting.tsx

@@ -0,0 +1,163 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import AppContainer from '~/client/services/AppContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+import CustomizeFunctionOption from './CustomizeFunctionOption';
+import PagingSizeUncontrolledDropdown from './PagingSizeUncontrolledDropdown';
+
+type Props = {
+  appContainer: AppContainer,
+  adminCustomizeContainer: AdminCustomizeContainer
+}
+
+const CustomizeFunctionSetting = (props: Props): JSX.Element => {
+
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+
+  const onClickSubmit = useCallback(async() => {
+
+    try {
+      await adminCustomizeContainer.updateCustomizeFunction();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.function') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminCustomizeContainer]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.function')}</h2>
+          <Card className="card well my-3">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.function_desc')}
+            </CardBody>
+          </Card>
+
+
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <CustomizeFunctionOption
+                optionId="isSavedStatesOfTabChanges"
+                label={t('admin:customize_setting.function_options.tab_switch')}
+                isChecked={adminCustomizeContainer.state.isSavedStatesOfTabChanges}
+                onChecked={() => { adminCustomizeContainer.switchSavedStatesOfTabChanges() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_setting.function_options.tab_switch_desc1')}<br />
+                  {t('admin:customize_setting.function_options.tab_switch_desc2')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <CustomizeFunctionOption
+                optionId="isEnabledAttachTitleHeader"
+                label={t('admin:customize_setting.function_options.attach_title_header')}
+                isChecked={adminCustomizeContainer.state.isEnabledAttachTitleHeader}
+                onChecked={() => { adminCustomizeContainer.switchEnabledAttachTitleHeader() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_setting.function_options.attach_title_header_desc')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
+
+          <PagingSizeUncontrolledDropdown
+            label={t('admin:customize_setting.function_options.list_num_s')}
+            desc={t('admin:customize_setting.function_options.list_num_desc_s')}
+            toggleLabel={adminCustomizeContainer.state.pageLimitationS || 20}
+            dropdownItemSize={[10, 20, 50, 100]}
+            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationS}
+          />
+          <PagingSizeUncontrolledDropdown
+            label={t('admin:customize_setting.function_options.list_num_m')}
+            desc={t('admin:customize_setting.function_options.list_num_desc_m')}
+            toggleLabel={adminCustomizeContainer.state.pageLimitationM || 10}
+            dropdownItemSize={[5, 10, 20, 50, 100]}
+            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationM}
+          />
+          <PagingSizeUncontrolledDropdown
+            label={t('admin:customize_setting.function_options.list_num_l')}
+            desc={t('admin:customize_setting.function_options.list_num_desc_l')}
+            toggleLabel={adminCustomizeContainer.state.pageLimitationL || 50}
+            dropdownItemSize={[20, 50, 100, 200]}
+            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationL}
+          />
+          <PagingSizeUncontrolledDropdown
+            label={t('admin:customize_setting.function_options.list_num_xl')}
+            desc={t('admin:customize_setting.function_options.list_num_desc_xl')}
+            toggleLabel={adminCustomizeContainer.state.pageLimitationXL || 20}
+            dropdownItemSize={[5, 10, 20, 50, 100]}
+            onChangeDropdownItem={adminCustomizeContainer.switchPageListLimitationXL}
+          />
+
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <CustomizeFunctionOption
+                optionId="isEnabledStaleNotification"
+                label={t('admin:customize_setting.function_options.stale_notification')}
+                isChecked={adminCustomizeContainer.state.isEnabledStaleNotification}
+                onChecked={() => { adminCustomizeContainer.switchEnableStaleNotification() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_setting.function_options.stale_notification_desc')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
+
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <CustomizeFunctionOption
+                optionId="isAllReplyShown"
+                label={t('admin:customize_setting.function_options.show_all_reply_comments')}
+                isChecked={adminCustomizeContainer.state.isAllReplyShown || false}
+                onChecked={() => { adminCustomizeContainer.switchIsAllReplyShown() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_setting.function_options.show_all_reply_comments_desc')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
+
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <CustomizeFunctionOption
+                optionId="isSearchScopeChildrenAsDefault"
+                label={t('admin:customize_setting.function_options.select_search_scope_children_as_default')}
+                isChecked={adminCustomizeContainer.state.isSearchScopeChildrenAsDefault || false}
+                onChecked={() => { adminCustomizeContainer.switchIsSearchScopeChildrenAsDefault() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_setting.function_options.select_search_scope_children_as_default_desc')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+
+};
+
+const CustomizeFunctionSettingWrapper = withUnstatedContainers(CustomizeFunctionSetting, [AppContainer, AdminCustomizeContainer]);
+
+export default CustomizeFunctionSettingWrapper;

+ 0 - 89
packages/app/src/components/Admin/Customize/CustomizeHeaderSetting.jsx

@@ -1,89 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import { Card, CardBody } from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-import CustomHeaderEditor from '../CustomHeaderEditor';
-
-class CustomizeHeaderSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateCustomizeHeader();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_header') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.custom_header')}</h2>
-
-            <Card className="card well my-3">
-              <CardBody className="px-0 py-2">
-                <span
-                  // eslint-disable-next-line react/no-danger
-                  dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_header_detail') }}
-                />
-              </CardBody>
-            </Card>
-            <div className="form-text text-muted">
-              { t('Example') }:
-              <pre className="hljs">
-                {/* eslint-disable-next-line react/no-unescaped-entities */}
-                <code className="text-wrap">&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js"
-                  defer&gt;&lt;/script&gt;
-                </code>
-              </pre>
-            </div>
-
-            <div className="form-group">
-              <CustomHeaderEditor
-                value={adminCustomizeContainer.state.currentCustomizeHeader || ''}
-                onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeHeader(inputValue) }}
-              />
-              <p className="form-text text-muted text-right">
-                <i className="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
-                {t('admin:customize_setting.ctrl_space')}
-              </p>
-            </div>
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeHeaderSettingWrapper = withUnstatedContainers(CustomizeHeaderSetting, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeHeaderSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeHeaderSettingWrapper);

+ 76 - 0
packages/app/src/components/Admin/Customize/CustomizeHeaderSetting.tsx

@@ -0,0 +1,76 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomHeaderEditor from '../CustomHeaderEditor';
+
+type Props = {
+  adminCustomizeContainer: AdminCustomizeContainer
+}
+
+const CustomizeHeaderSetting = (props: Props): JSX.Element => {
+
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await adminCustomizeContainer.updateCustomizeHeader();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_header') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminCustomizeContainer]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.custom_header')}</h2>
+
+          <Card className="card well my-3">
+            <CardBody className="px-0 py-2">
+              <span
+                // eslint-disable-next-line react/no-danger
+                dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.custom_header_detail') }}
+              />
+            </CardBody>
+          </Card>
+          <div className="form-text text-muted">
+            { t('Example') }:
+            <pre className="hljs">
+              {/* eslint-disable-next-line react/no-unescaped-entities */}
+              <code className="text-wrap">&lt;script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.13.0/build/languages/yaml.min.js"
+                defer&gt;&lt;/script&gt;
+              </code>
+            </pre>
+          </div>
+
+          <div className="form-group">
+            <CustomHeaderEditor
+              value={adminCustomizeContainer.state.currentCustomizeHeader || ''}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeHeader(inputValue) }}
+            />
+            <p className="form-text text-muted text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true"></i>
+              {t('admin:customize_setting.ctrl_space')}
+            </p>
+          </div>
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+
+};
+
+const CustomizeHeaderSettingWrapper = withUnstatedContainers(CustomizeHeaderSetting, [AdminCustomizeContainer]);
+
+export default CustomizeHeaderSettingWrapper;

+ 0 - 156
packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.jsx

@@ -1,156 +0,0 @@
-/* eslint-disable no-useless-escape */
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
-} from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-class CustomizeHighlightSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isDropdownOpen: false,
-    };
-
-    this.onToggleDropdown = this.onToggleDropdown.bind(this);
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  onToggleDropdown() {
-    this.setState({ isDropdownOpen: !this.state.isDropdownOpen });
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateHighlightJsStyle();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.code_highlight') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderHljsDemo() {
-    const { adminCustomizeContainer } = this.props;
-
-    /* eslint-disable max-len */
-    const html = `<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">MersenneTwister</span>(<span class="hljs-params">seed</span>) </span>{
-  <span class="hljs-keyword">if</span> (<span class="hljs-built_in">arguments</span>.length == <span class="hljs-number">0</span>) {
-    seed = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().getTime();
-  }
-
-  <span class="hljs-keyword">this</span>._mt = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Array</span>(<span class="hljs-number">624</span>);
-  <span class="hljs-keyword">this</span>.setSeed(seed);
-}</span>`;
-    /* eslint-enable max-len */
-
-    return (
-      <pre className={`hljs ${!adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled && 'hljs-no-border'}`}>
-        {/* eslint-disable-next-line react/no-danger */}
-        <code dangerouslySetInnerHTML={{ __html: html }}></code>
-      </pre>
-    );
-  }
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-    const options = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
-    const menuItem = [];
-
-    Object.entries(options).forEach((option) => {
-      const styleId = option[0];
-      const styleName = option[1].name;
-      const isBorderEnable = option[1].border;
-
-      menuItem.push(
-        <DropdownItem
-          key={styleId}
-          role="presentation"
-          onClick={() => adminCustomizeContainer.switchHighlightJsStyle(styleId, styleName, isBorderEnable)}
-        >
-          <a role="menuitem">{styleName}</a>
-        </DropdownItem>,
-      );
-    });
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.code_highlight')}</h2>
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <div className="my-0">
-                  <label>{t('admin:customize_setting.theme')}</label>
-                </div>
-                <Dropdown isOpen={this.state.isDropdownOpen} toggle={this.onToggleDropdown}>
-                  <DropdownToggle className="text-right col-6" caret>
-                    <span className="float-left">{adminCustomizeContainer.state.currentHighlightJsStyleName}</span>
-                  </DropdownToggle>
-                  <DropdownMenu className="dropdown-menu" role="menu">
-                    {menuItem}
-                  </DropdownMenu>
-                </Dropdown>
-                <p className="form-text text-warning">
-                  {/* eslint-disable-next-line react/no-danger */}
-                  <span dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.nocdn_desc') }} />
-                </p>
-              </div>
-            </div>
-
-            <div className="form-group row">
-              <div className="offset-md-3 col-md-6 text-left">
-                <div className="custom-control custom-switch custom-checkbox-success">
-                  <input
-                    type="checkbox"
-                    className="custom-control-input"
-                    id="highlightBorder"
-                    checked={adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled}
-                    onChange={() => { adminCustomizeContainer.switchHighlightJsStyleBorder() }}
-                  />
-                  <label className="custom-control-label" htmlFor="highlightBorder">
-                    <strong>Border</strong>
-                  </label>
-                </div>
-              </div>
-            </div>
-
-            <div className="form-text text-muted">
-              <label>Examples:</label>
-              <div className="wiki">
-                {this.renderHljsDemo()}
-              </div>
-            </div>
-
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeHighlightSettingWrapper = withUnstatedContainers(CustomizeHighlightSetting, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeHighlightSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeHighlightSettingWrapper);

+ 145 - 0
packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx

@@ -0,0 +1,145 @@
+/* eslint-disable no-useless-escape */
+import React, { useCallback, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+} from 'reactstrap';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+type Props = {
+  adminCustomizeContainer: AdminCustomizeContainer
+}
+
+type HljsDemoProps = {
+  isHighlightJsStyleBorderEnabled: boolean
+}
+
+const HljsDemo = React.memo((props: HljsDemoProps): JSX.Element => {
+
+  const { isHighlightJsStyleBorderEnabled } = props;
+
+  /* eslint-disable max-len */
+  const html = `<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">MersenneTwister</span>(<span class="hljs-params">seed</span>) </span>{
+<span class="hljs-keyword">if</span> (<span class="hljs-built_in">arguments</span>.length == <span class="hljs-number">0</span>) {
+  seed = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().getTime();
+}
+
+<span class="hljs-keyword">this</span>._mt = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Array</span>(<span class="hljs-number">624</span>);
+<span class="hljs-keyword">this</span>.setSeed(seed);
+}</span>`;
+  /* eslint-enable max-len */
+
+  return (
+    <pre className={`hljs ${!isHighlightJsStyleBorderEnabled && 'hljs-no-border'}`}>
+      {/* eslint-disable-next-line react/no-danger */}
+      <code dangerouslySetInnerHTML={{ __html: html }}></code>
+    </pre>
+  );
+});
+
+const CustomizeHighlightSetting = (props: Props): JSX.Element => {
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+  const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+  const options = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
+
+  const onToggleDropdown = useCallback(() => {
+    setIsDropdownOpen(!isDropdownOpen);
+  }, [isDropdownOpen]);
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await adminCustomizeContainer.updateHighlightJsStyle();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.code_highlight') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminCustomizeContainer]);
+
+  const renderMenuItems = useCallback(() => {
+
+    const items = Object.entries(options).map((option) => {
+      const styleId = option[0];
+      const styleName = option[1].name;
+      const isBorderEnable = option[1].border;
+
+      return (
+        <DropdownItem
+          key={styleId}
+          role="presentation"
+          onClick={() => adminCustomizeContainer.switchHighlightJsStyle(styleId, styleName, isBorderEnable)}
+        >
+          <a role="menuitem">{styleName}</a>
+        </DropdownItem>
+      );
+    });
+    return items;
+  }, [adminCustomizeContainer, options]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.code_highlight')}</h2>
+
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <div className="my-0">
+                <label>{t('admin:customize_setting.theme')}</label>
+              </div>
+              <Dropdown isOpen={isDropdownOpen} toggle={onToggleDropdown}>
+                <DropdownToggle className="text-right col-6" caret>
+                  <span className="float-left">{adminCustomizeContainer.state.currentHighlightJsStyleName}</span>
+                </DropdownToggle>
+                <DropdownMenu className="dropdown-menu" role="menu">
+                  {renderMenuItems()}
+                </DropdownMenu>
+              </Dropdown>
+              <p className="form-text text-warning">
+                {/* eslint-disable-next-line react/no-danger */}
+                <span dangerouslySetInnerHTML={{ __html: t('admin:customize_setting.nocdn_desc') }} />
+              </p>
+            </div>
+          </div>
+
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <div className="custom-control custom-switch custom-checkbox-success">
+                <input
+                  type="checkbox"
+                  className="custom-control-input"
+                  id="highlightBorder"
+                  checked={adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled}
+                  onChange={() => { adminCustomizeContainer.switchHighlightJsStyleBorder() }}
+                />
+                <label className="custom-control-label" htmlFor="highlightBorder">
+                  <strong>Border</strong>
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <div className="form-text text-muted">
+            <label>Examples:</label>
+            <div className="wiki">
+              <HljsDemo isHighlightJsStyleBorderEnabled={adminCustomizeContainer.state.isHighlightJsStyleBorderEnabled} />
+            </div>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+const CustomizeHighlightSettingWrapper = withUnstatedContainers(CustomizeHighlightSetting, [AdminCustomizeContainer]);
+
+export default CustomizeHighlightSettingWrapper;

+ 4 - 12
packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.jsx → packages/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -1,9 +1,7 @@
 import React, { useCallback, useEffect, useState } from 'react';
 
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
@@ -11,8 +9,8 @@ import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
 const isDarkMode = isDarkModeByUtil();
 const colorText = isDarkMode ? 'dark' : 'light';
 
-const CustomizeLayoutSetting = (props) => {
-  const { t, appContainer } = props;
+const CustomizeLayoutSetting = (): JSX.Element => {
+  const { t } = useTranslation();
 
   const [isContainerFluid, setIsContainerFluid] = useState(false);
   const [retrieveError, setRetrieveError] = useState();
@@ -85,10 +83,4 @@ const CustomizeLayoutSetting = (props) => {
   );
 };
 
-CustomizeLayoutSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeLayoutSetting);
+export default CustomizeLayoutSetting;

+ 185 - 0
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -0,0 +1,185 @@
+import React, { useCallback, useEffect, useState } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
+import {
+  apiv3Delete, apiv3Get, apiv3PostForm, apiv3Put,
+} from '~/client/util/apiv3-client';
+import ImageCropModal from '~/components/Common/ImageCropModal';
+
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+const DEFAULT_LOGO = '/images/logo.svg';
+
+const CustomizeLogoSetting = (): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
+  const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
+  const [isDefaultLogo, setIsDefaultLogo] = useState<boolean>(true);
+  const [retrieveError, setRetrieveError] = useState<string | null>(null);
+  const [customizedLogoSrc, setCustomizedLogoSrc] = useState< string | null >(null);
+
+  const retrieveData = useCallback(async() => {
+    try {
+      const response = await apiv3Get('/customize-setting/customize-logo');
+      const { isDefaultLogo: _isDefaultLogo, customizedLogoSrc } = response.data;
+      const isDefaultLogo = _isDefaultLogo ?? true;
+
+      setIsDefaultLogo(isDefaultLogo);
+      setCustomizedLogoSrc(customizedLogoSrc);
+    }
+    catch (err) {
+      setRetrieveError(err);
+      throw new Error('Failed to fetch data');
+    }
+  }, []);
+
+  useEffect(() => {
+    retrieveData();
+  }, [retrieveData]);
+
+  const onSelectFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    if (e.target.files != null && e.target.files.length > 0) {
+      const reader = new FileReader();
+      reader.addEventListener('load', () => setUploadLogoSrc(reader.result));
+      reader.readAsDataURL(e.target.files[0]);
+      setIsImageCropModalShow(true);
+    }
+  }, []);
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      const response = await apiv3Put('/customize-setting/customize-logo', {
+        isDefaultLogo,
+        customizedLogoSrc,
+      });
+      const { customizedParams } = response.data;
+      setIsDefaultLogo(customizedParams.isDefaultLogo);
+      setCustomizedLogoSrc(customizedParams.customizedLogoSrc);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, isDefaultLogo, customizedLogoSrc]);
+
+
+  const onClickDeleteBtn = useCallback(async() => {
+    try {
+      await apiv3Delete('/customize-setting/delete-brand-logo');
+      setCustomizedLogoSrc(null);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+      setRetrieveError(err);
+      throw new Error('Failed to delete logo');
+    }
+  }, [t]);
+
+  const onCropCompleted = useCallback(async(croppedImage) => {
+    try {
+      const formData = new FormData();
+      formData.append('file', croppedImage);
+      const { data } = await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
+      setCustomizedLogoSrc(data.attachment.filePathProxied);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.current_logo') }));
+    }
+    catch (err) {
+      toastError(err);
+      setRetrieveError(err);
+      throw new Error('Failed to upload brand logo');
+    }
+    setIsImageCropModalShow(false);
+  }, [t]);
+
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <div className="mb-5">
+            <h2 className="border-bottom my-4 admin-setting-header">{t('admin:customize_setting.custom_logo')}</h2>
+            <div className="row">
+              <div className="col-md-6 col-12 mb-3 mb-md-0">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioDefaultLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={isDefaultLogo}
+                      onChange={() => { setIsDefaultLogo(true) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioDefaultLogo">
+                      {t('admin:customize_setting.default_logo')}
+                    </label>
+                  </div>
+                </h4>
+                <img src={DEFAULT_LOGO} width="64" />
+              </div>
+              <div className="col-md-6 col-12">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioUploadLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={!isDefaultLogo}
+                      onChange={() => { setIsDefaultLogo(false) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioUploadLogo">
+                      { t('admin:customize_setting.upload_logo') }
+                    </label>
+                  </div>
+                </h4>
+                <div className="row mb-3">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('admin:customize_setting.current_logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <p><img src={customizedLogoSrc || DEFAULT_LOGO} className="picture picture-lg " id="settingBrandLogo" width="64" /></p>
+                    {(customizedLogoSrc != null) && (
+                      <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
+                        { t('admin:customize_setting.delete_logo') }
+                      </button>
+                    )}
+                  </div>
+                </div>
+                <div className="row">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('admin:customize_setting.upload_new_logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />
+                  </div>
+                </div>
+              </div>
+            </div>
+            <AdminUpdateButtonRow onClick={onClickSubmit} disabled={retrieveError != null} />
+          </div>
+        </div>
+      </div>
+
+      <ImageCropModal
+        isShow={isImageCropModalShow}
+        src={uploadLogoSrc}
+        onModalClose={() => setIsImageCropModalShow(false)}
+        onCropCompleted={onCropCompleted}
+        isCircular={false}
+      />
+    </React.Fragment>
+  );
+
+
+};
+
+
+export default CustomizeLogoSetting;

+ 0 - 120
packages/app/src/components/Admin/Customize/CustomizeScriptSetting.jsx

@@ -1,120 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import { Card, CardBody } from 'reactstrap';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-import CustomScriptEditor from '../CustomScriptEditor';
-
-class CustomizeScriptSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateCustomizeScript();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_script') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  getExampleCode() {
-    return `console.log($('.main-container'));
-    window.addEventListener('load', (event) => {
-      console.log('config: ', appContainer.config);
-    });
-    `;
-  }
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.custom_script')}</h2>
-            <Card className="card well">
-              <CardBody className="px-0 py-2">
-                {t('admin:customize_setting.write_java')}<br />
-                {t('admin:customize_setting.reflect_change')}
-              </CardBody>
-            </Card>
-
-            <div className="form-text text-muted">
-              Placeholders:<br />
-              (Available after <code>load</code> event)
-            </div>
-            <table className="table table-borderless table-sm form-text text-muted offset-1 col-11">
-              <tbody>
-                <tr>
-                  <th className="text-right"><code>$</code></th>
-                  <td>jQuery instance</td>
-                </tr>
-                <tr>
-                  <th className="text-right"><code>appContainer</code></th>
-                  <td>GROWI App <a href="https://github.com/jamiebuilds/unstated">unstated container</a></td>
-                </tr>
-                <tr>
-                  <th className="text-right"><code>growiRenderer</code></th>
-                  <td>GROWI Renderer origin instance</td>
-                </tr>
-                <tr>
-                  <th className="text-right"><code>growiPlugin</code></th>
-                  <td>GROWI Plugin Manager instance</td>
-                </tr>
-                <tr>
-                  <th className="text-right"><code>Crowi</code></th>
-                  <td>Crowi legacy instance (jQuery based)</td>
-                </tr>
-              </tbody>
-            </table>
-
-            <div className="form-text text-muted">
-              Examples:
-              <pre className="hljs"><code>{this.getExampleCode()}</code></pre>
-            </div>
-
-            <div className="form-group">
-              <CustomScriptEditor
-                value={adminCustomizeContainer.state.currentCustomizeScript || ''}
-                onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeScript(inputValue) }}
-              />
-              <p className="form-text text-muted text-right">
-                <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
-                {t('admin:customize_setting.ctrl_space')}
-              </p>
-            </div>
-
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeScriptSettingWrapper = withUnstatedContainers(CustomizeScriptSetting, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeScriptSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeScriptSettingWrapper);

+ 107 - 0
packages/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -0,0 +1,107 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CustomScriptEditor from '../CustomScriptEditor';
+
+type Props = {
+  adminCustomizeContainer: AdminCustomizeContainer
+}
+
+const CustomizeScriptSetting = (props: Props): JSX.Element => {
+
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await adminCustomizeContainer.updateCustomizeScript();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_script') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminCustomizeContainer]);
+
+  const getExampleCode = useCallback(() => {
+    return `console.log($('.main-container'));
+    window.addEventListener('load', (event) => {
+      console.log('config: ', appContainer.config);
+    });
+    `;
+  }, []);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.custom_script')}</h2>
+          <Card className="card well">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.write_java')}<br />
+              {t('admin:customize_setting.reflect_change')}
+            </CardBody>
+          </Card>
+
+          <div className="form-text text-muted">
+            Placeholders:<br />
+            (Available after <code>load</code> event)
+          </div>
+          <table className="table table-borderless table-sm form-text text-muted offset-1 col-11">
+            <tbody>
+              <tr>
+                <th className="text-right"><code>$</code></th>
+                <td>jQuery instance</td>
+              </tr>
+              <tr>
+                <th className="text-right"><code>appContainer</code></th>
+                <td>GROWI App <a href="https://github.com/jamiebuilds/unstated">unstated container</a></td>
+              </tr>
+              <tr>
+                <th className="text-right"><code>growiRenderer</code></th>
+                <td>GROWI Renderer origin instance</td>
+              </tr>
+              <tr>
+                <th className="text-right"><code>growiPlugin</code></th>
+                <td>GROWI Plugin Manager instance</td>
+              </tr>
+              <tr>
+                <th className="text-right"><code>Crowi</code></th>
+                <td>Crowi legacy instance (jQuery based)</td>
+              </tr>
+            </tbody>
+          </table>
+
+          <div className="form-text text-muted">
+            Examples:
+            <pre className="hljs"><code>{getExampleCode()}</code></pre>
+          </div>
+
+          <div className="form-group">
+            <CustomScriptEditor
+              value={adminCustomizeContainer.state.currentCustomizeScript || ''}
+              onChange={(inputValue) => { adminCustomizeContainer.changeCustomizeScript(inputValue) }}
+            />
+            <p className="form-text text-muted text-right">
+              <i className="fa fa-fw fa-keyboard-o" aria-hidden="true" />
+              {t('admin:customize_setting.ctrl_space')}
+            </p>
+          </div>
+
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+
+};
+
+const CustomizeScriptSettingWrapper = withUnstatedContainers(CustomizeScriptSetting, [AdminCustomizeContainer]);
+
+export default CustomizeScriptSettingWrapper;

+ 118 - 0
packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -0,0 +1,118 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+import { Card, CardBody } from 'reactstrap';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { isDarkMode as isDarkModeByUtil } from '~/client/util/color-scheme';
+import { useSWRxSidebarConfig } from '~/stores/ui';
+
+const CustomizeSidebarsetting = (): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    update, isSidebarDrawerMode, isSidebarClosedAtDockMode, setIsSidebarDrawerMode, setIsSidebarClosedAtDockMode,
+  } = useSWRxSidebarConfig();
+
+  const isDarkMode = isDarkModeByUtil();
+  const colorText = isDarkMode ? 'dark' : 'light';
+  const drawerIconFileName = `/images/customize-settings/drawer-${colorText}.svg`;
+  const dockIconFileName = `/images/customize-settings/dock-${colorText}.svg`;
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await update();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.default_sidebar_mode.title') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, update]);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+
+          <h2 className="admin-setting-header">{t('admin:customize_setting.default_sidebar_mode.title')}</h2>
+
+          <Card className="card well my-3">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.default_sidebar_mode.desc')}
+            </CardBody>
+          </Card>
+
+          <div className="d-flex justify-content-around mt-5">
+            <div id="layoutOptions" className="card-deck">
+              <div
+                className={`card customize-layout-card ${isSidebarDrawerMode ? 'border-active' : ''}`}
+                onClick={() => setIsSidebarDrawerMode(true)}
+                role="button"
+              >
+                <img src={drawerIconFileName} />
+                <div className="card-body text-center">
+                  Drawer Mode
+                </div>
+              </div>
+              <div
+                className={`card customize-layout-card ${!isSidebarDrawerMode ? 'border-active' : ''}`}
+                onClick={() => setIsSidebarDrawerMode(false)}
+                role="button"
+              >
+                <img src={dockIconFileName} />
+                <div className="card-body  text-center">
+                  Dock Mode
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <Card className="card well my-5">
+            <CardBody className="px-0 py-2">
+              {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_desc')}
+            </CardBody>
+          </Card>
+
+          <div className="px-3">
+            <div className="custom-control custom-radio my-3">
+              <input
+                type="radio"
+                id="is-open"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={!isSidebarDrawerMode && !isSidebarClosedAtDockMode}
+                disabled={isSidebarDrawerMode}
+                onChange={() => setIsSidebarClosedAtDockMode(false)}
+              />
+              <label className="custom-control-label" htmlFor="is-open">
+                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_open')}
+              </label>
+            </div>
+            <div className="custom-control custom-radio my-3">
+              <input
+                type="radio"
+                id="is-closed"
+                className="custom-control-input"
+                name="mailVisibility"
+                checked={!isSidebarDrawerMode && isSidebarClosedAtDockMode}
+                disabled={isSidebarDrawerMode}
+                onChange={() => setIsSidebarClosedAtDockMode(true)}
+              />
+              <label className="custom-control-label" htmlFor="is-closed">
+                {t('admin:customize_setting.default_sidebar_mode.dock_mode_default_close')}
+              </label>
+            </div>
+          </div>
+
+          <div className="row my-3">
+            <div className="mx-auto">
+              <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>
+            </div>
+          </div>
+
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+export default CustomizeSidebarsetting;

+ 78 - 80
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -1,102 +1,100 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
+
 
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
 import ThemeColorBox from './ThemeColorBox';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 
-class CustomizeThemeOptions extends React.Component {
+/* eslint-disable no-multi-spaces */
+const lightNDarkTheme = [{
+  name: 'default',    bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
+}, {
+  name: 'mono-blue',  bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
+}, {
+  name: 'hufflepuff',  bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
+}, {
+  name: 'fire-red',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
+}, {
+  name: 'jade-green',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
+}];
 
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-    const { currentLayout, currentTheme } = adminCustomizeContainer.state;
+const uniqueTheme = [{
+  name: 'nature',     bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', theme: '#460039',
+}, {
+  name: 'wood',       bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', theme: '#aaa45f',
+}, {
+  name: 'island',     bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', theme: 'rgba(183, 226, 219, 1)',
+}, {
+  name: 'christmas',  bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', theme: '#d3c665',
+}, {
+  name: 'antarctic',  bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', theme: '#fa9913',
+}, {
+  name: 'spring',     bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', theme: '#67a856',
+}, {
+  name: 'future',     bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
+}, {
+  name: 'halloween',  bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
+}, {
+  name: 'kibela',  bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
+}, {
+  name: 'blackboard',  bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
+}];
 
-    /* eslint-disable no-multi-spaces */
-    const lightNDarkTheme = [{
-      name: 'default',    bg: '#ffffff', topbar: '#2a2929', sidebar: '#122c55', theme: '#209fd8',
-    }, {
-      name: 'mono-blue',  bg: '#F7FBFD', topbar: '#2a2929', sidebar: '#00587A', theme: '#00587A',
-    }, {
-      name: 'hufflepuff',  bg: '#EFE2CF', topbar: '#2a2929', sidebar: '#EAAB20', theme: '#993439',
-    }, {
-      name: 'fire-red',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#EA5532',
-    }, {
-      name: 'jade-green',  bg: '#FDFDFD', topbar: '#2c2c2c', sidebar: '#BFBFBF', theme: '#38B48B',
-    }];
 
-    const uniqueTheme = [{
-      name: 'nature',     bg: '#f9fff3', topbar: '#234136', sidebar: '#118050', theme: '#460039',
-    }, {
-      name: 'wood',       bg: '#fffefb', topbar: '#2a2929', sidebar: '#aaa45f', theme: '#aaa45f',
-    }, {
-      name: 'island',     bg: '#cef2ef', topbar: '#2a2929', sidebar: '#0c2a44', theme: 'rgba(183, 226, 219, 1)',
-    }, {
-      name: 'christmas',  bg: '#fffefb', topbar: '#b3000c', sidebar: '#30882c', theme: '#d3c665',
-    }, {
-      name: 'antarctic',  bg: '#ffffff', topbar: '#2a2929', sidebar: '#000080', theme: '#fa9913',
-    }, {
-      name: 'spring',     bg: '#ffffff', topbar: '#d3687c', sidebar: '#ffb8c6', theme: '#67a856',
-    }, {
-      name: 'future',     bg: '#16282d', topbar: '#2a2929', sidebar: '#00b5b7', theme: '#00b5b7',
-    }, {
-      name: 'halloween',  bg: '#030003', topbar: '#aa4a04', sidebar: '#162b33', theme: '#e9af2b',
-    }, {
-      name: 'kibela',  bg: '#f4f5f6', topbar: '#1256a3', sidebar: '#5882fa', theme: '#b5cbf79c',
-    }, {
-      name: 'blackboard',  bg: '#223729', topbar: '#563E23', sidebar: '#7B5932', theme: '#DA8506',
-    }];
-    /* eslint-enable no-multi-spaces */
+const CustomizeThemeOptions = (props) => {
 
-    return (
-      <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
-        {/* Light and Dark Themes */}
-        <div>
-          <h3>{t('admin:customize_setting.theme_desc.light_and_dark')}</h3>
-          <div className="d-flex flex-wrap">
-            {lightNDarkTheme.map((theme) => {
-              return (
-                <ThemeColorBox
-                  key={theme.name}
-                  isSelected={currentTheme === theme.name}
-                  onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
-                  {...theme}
-                />
-              );
-            })}
-          </div>
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+  const { currentLayout, currentTheme } = adminCustomizeContainer.state;
+
+  return (
+    <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
+      {/* Light and Dark Themes */}
+      <div>
+        <h3>{t('admin:customize_setting.theme_desc.light_and_dark')}</h3>
+        <div className="d-flex flex-wrap">
+          {lightNDarkTheme.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={currentTheme === theme.name}
+                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                {...theme}
+              />
+            );
+          })}
         </div>
-        {/* Unique Theme */}
-        <div className="mt-3">
-          <h3>{t('admin:customize_setting.theme_desc.unique')}</h3>
-          <div className="d-flex flex-wrap">
-            {uniqueTheme.map((theme) => {
-              return (
-                <ThemeColorBox
-                  key={theme.name}
-                  isSelected={currentTheme === theme.name}
-                  onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
-                  {...theme}
-                />
-              );
-            })}
-          </div>
+      </div>
+      {/* Unique Theme */}
+      <div className="mt-3">
+        <h3>{t('admin:customize_setting.theme_desc.unique')}</h3>
+        <div className="d-flex flex-wrap">
+          {uniqueTheme.map((theme) => {
+            return (
+              <ThemeColorBox
+                key={theme.name}
+                isSelected={currentTheme === theme.name}
+                onSelected={() => adminCustomizeContainer.switchThemeType(theme.name)}
+                {...theme}
+              />
+            );
+          })}
         </div>
       </div>
-    );
-  }
+    </div>
+  );
 
-}
+};
 
-const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AppContainer, AdminCustomizeContainer]);
+const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AdminCustomizeContainer]);
 
 CustomizeThemeOptions.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
 };
 
-export default withTranslation()(CustomizeThemeOptionsWrapper);
+export default CustomizeThemeOptionsWrapper;

+ 0 - 72
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.jsx

@@ -1,72 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import AppContainer from '~/client/services/AppContainer';
-
-import CustomizeThemeOptions from './CustomizeThemeOptions';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
-
-class CustomizeThemeSetting extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickSubmit = this.onClickSubmit.bind(this);
-  }
-
-  async onClickSubmit() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    try {
-      await adminCustomizeContainer.updateCustomizeTheme();
-      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderDevAlert() {
-    if (process.env.NODE_ENV === 'development') {
-      return (
-        <div className="alert alert-warning">
-          <strong>DEBUG MESSAGE:</strong> Live preview for theme is disabled in development mode.
-        </div>
-      );
-    }
-  }
-
-
-  render() {
-    const { t, adminCustomizeContainer } = this.props;
-
-    return (
-      <React.Fragment>
-        <div className="row">
-          <div className="col-12">
-            <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
-            {this.renderDevAlert()}
-            <CustomizeThemeOptions />
-            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
-          </div>
-        </div>
-      </React.Fragment>
-    );
-  }
-
-}
-
-const CustomizeThemeSettingWrapper = withUnstatedContainers(CustomizeThemeSetting, [AppContainer, AdminCustomizeContainer]);
-
-CustomizeThemeSetting.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
-};
-
-export default withTranslation()(CustomizeThemeSettingWrapper);

+ 58 - 0
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -0,0 +1,58 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+
+import CustomizeThemeOptions from './CustomizeThemeOptions';
+
+type Props = {
+  adminCustomizeContainer: AdminCustomizeContainer
+}
+
+const CustomizeThemeSetting = (props: Props): JSX.Element => {
+
+  const { adminCustomizeContainer } = props;
+  const { t } = useTranslation();
+
+  const onClickSubmit = useCallback(async() => {
+    try {
+      await adminCustomizeContainer.updateCustomizeTheme();
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [t, adminCustomizeContainer]);
+
+  const renderDevAlert = useCallback(() => {
+    if (process.env.NODE_ENV === 'development') {
+      return (
+        <div className="alert alert-warning">
+          <strong>DEBUG MESSAGE:</strong> Live preview for theme is disabled in development mode.
+        </div>
+      );
+    }
+  }, []);
+
+  return (
+    <React.Fragment>
+      <div className="row">
+        <div className="col-12">
+          <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
+          {renderDevAlert()}
+          <CustomizeThemeOptions />
+          <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        </div>
+      </div>
+    </React.Fragment>
+  );
+};
+
+const CustomizeThemeSettingWrapper = withUnstatedContainers(CustomizeThemeSetting, [AdminCustomizeContainer]);
+
+export default CustomizeThemeSettingWrapper;

+ 2 - 3
packages/app/src/components/Admin/Customize/PagingSizeUncontrolledDropdown.jsx

@@ -1,6 +1,6 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 import {
   UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
@@ -47,7 +47,6 @@ const PagingSizeUncontrolledDropdown = (props) => {
 
 
 PagingSizeUncontrolledDropdown.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
   label: PropTypes.string,
   toggleLabel: PropTypes.number,
   dropdownItemSize: PropTypes.array,
@@ -55,4 +54,4 @@ PagingSizeUncontrolledDropdown.propTypes = {
   onChangeDropdownItem: PropTypes.func,
 };
 
-export default withTranslation()(PagingSizeUncontrolledDropdown);
+export default PagingSizeUncontrolledDropdown;

+ 8 - 3
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -226,10 +226,15 @@ class ElasticsearchManagement extends React.Component {
 
 }
 
+const ElasticsearchManagementWrapperFC = (props) => {
+  const { t } = useTranslation();
+  return <ElasticsearchManagement t={t} {...props} />;
+};
+
 /**
  * Wrapper component for using unstated
  */
-const ElasticsearchManagementWrapper = withUnstatedContainers(ElasticsearchManagement, [AppContainer, AdminSocketIoContainer]);
+const ElasticsearchManagementWrapper = withUnstatedContainers(ElasticsearchManagementWrapperFC, [AppContainer, AdminSocketIoContainer]);
 
 ElasticsearchManagement.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -237,4 +242,4 @@ ElasticsearchManagement.propTypes = {
   adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 };
 
-export default withTranslation()(ElasticsearchManagementWrapper);
+export default ElasticsearchManagementWrapper;

+ 0 - 47
packages/app/src/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.jsx

@@ -1,47 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class NormalizeIndicesControls extends React.PureComponent {
-
-  render() {
-    const { t, isNormalized, isRebuildingProcessing } = this.props;
-
-    const isEnabled = (isNormalized != null) && !isNormalized && !isRebuildingProcessing;
-
-    return (
-      <>
-        <button
-          type="submit"
-          className={`btn ${isEnabled ? 'btn-outline-info' : 'btn-outline-secondary'}`}
-          onClick={() => { this.props.onNormalizingRequested() }}
-          disabled={!isEnabled}
-        >
-          { t('full_text_search_management.normalize_button') }
-        </button>
-
-        <p className="form-text text-muted">
-          { t('full_text_search_management.normalize_description') }<br />
-        </p>
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const NormalizeIndicesControlsWrapper = withUnstatedContainers(NormalizeIndicesControls, []);
-
-NormalizeIndicesControls.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  isRebuildingProcessing: PropTypes.bool.isRequired,
-  onNormalizingRequested: PropTypes.func.isRequired,
-  isNormalized: PropTypes.bool,
-};
-
-export default withTranslation()(NormalizeIndicesControlsWrapper);

+ 35 - 0
packages/app/src/components/Admin/ElasticsearchManagement/NormalizeIndicesControls.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  isRebuildingProcessing: boolean,
+  onNormalizingRequested: () => void,
+  isNormalized?: boolean,
+}
+
+const NormalizeIndicesControls = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { isNormalized, isRebuildingProcessing } = props;
+
+  const isEnabled = (isNormalized != null) && !isNormalized && !isRebuildingProcessing;
+
+  return (
+    <>
+      <button
+        type="submit"
+        className={`btn ${isEnabled ? 'btn-outline-info' : 'btn-outline-secondary'}`}
+        onClick={() => { props.onNormalizingRequested() }}
+        disabled={!isEnabled}
+      >
+        { t('full_text_search_management.normalize_button') }
+      </button>
+
+      <p className="form-text text-muted">
+        { t('full_text_search_management.normalize_description') }<br />
+      </p>
+    </>
+  );
+};
+
+export default NormalizeIndicesControls;

+ 10 - 6
packages/app/src/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -1,11 +1,11 @@
 import React from 'react';
+
 import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 
+import { withUnstatedContainers } from '../../UnstatedUtils';
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 
 class RebuildIndexControls extends React.Component {
@@ -107,15 +107,19 @@ class RebuildIndexControls extends React.Component {
 
 }
 
+const RebuildIndexControlsFC = (props) => {
+  const { t } = useTranslation();
+  return <RebuildIndexControls t={t} {...props} />;
+};
+
 
 /**
  * Wrapper component for using unstated
  */
-const RebuildIndexControlsWrapper = withUnstatedContainers(RebuildIndexControls, [AppContainer, AdminSocketIoContainer]);
+const RebuildIndexControlsWrapper = withUnstatedContainers(RebuildIndexControlsFC, [AdminSocketIoContainer]);
 
 RebuildIndexControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminSocketIoContainer: PropTypes.instanceOf(AdminSocketIoContainer).isRequired,
 
   isRebuildingProcessing: PropTypes.bool.isRequired,
@@ -125,4 +129,4 @@ RebuildIndexControls.propTypes = {
   onRebuildingRequested: PropTypes.func.isRequired,
 };
 
-export default withTranslation()(RebuildIndexControlsWrapper);
+export default RebuildIndexControlsWrapper;

+ 0 - 46
packages/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.jsx

@@ -1,46 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class ReconnectControls extends React.PureComponent {
-
-  render() {
-    const { t, isEnabled, isProcessing } = this.props;
-
-    return (
-      <>
-        <button
-          type="submit"
-          className={`btn ${isEnabled ? 'btn-outline-success' : 'btn-outline-secondary'}`}
-          onClick={() => { this.props.onReconnectingRequested() }}
-          disabled={!isEnabled}
-        >
-          { isProcessing && <i className="fa fa-spinner fa-pulse mr-2"></i> }
-          { t('full_text_search_management.reconnect_button') }
-        </button>
-
-        <p className="form-text text-muted">
-          { t('full_text_search_management.reconnect_description') }<br />
-        </p>
-      </>
-    );
-  }
-
-}
-
-/**
- * Wrapper component for using unstated
- */
-const ReconnectControlsWrapper = withUnstatedContainers(ReconnectControls, []);
-
-ReconnectControls.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  isEnabled: PropTypes.bool,
-  isProcessing: PropTypes.bool,
-  onReconnectingRequested: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(ReconnectControlsWrapper);

+ 36 - 0
packages/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx

@@ -0,0 +1,36 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+  isEnabled?: boolean,
+  isProcessing?: boolean,
+  onReconnectingRequested: () => void,
+}
+
+const ReconnectControls = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { isEnabled, isProcessing } = props;
+
+  return (
+    <>
+      <button
+        type="submit"
+        className={`btn ${isEnabled ? 'btn-outline-success' : 'btn-outline-secondary'}`}
+        onClick={() => { props.onReconnectingRequested() }}
+        disabled={!isEnabled}
+      >
+        { isProcessing && <i className="fa fa-spinner fa-pulse mr-2"></i> }
+        { t('full_text_search_management.reconnect_button') }
+      </button>
+
+      <p className="form-text text-muted">
+        { t('full_text_search_management.reconnect_description') }<br />
+      </p>
+    </>
+  );
+
+};
+
+export default ReconnectControls;

+ 8 - 8
packages/app/src/components/Admin/ElasticsearchManagement/StatusTable.jsx

@@ -1,8 +1,7 @@
 import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
+import PropTypes from 'prop-types';
+import { useTranslation } from 'react-i18next';
 
 class StatusTable extends React.PureComponent {
 
@@ -161,10 +160,11 @@ class StatusTable extends React.PureComponent {
 
 }
 
-/**
- * Wrapper component for using unstated
- */
-const StatusTableWrapper = withUnstatedContainers(StatusTable, []);
+const StatusTableWrapperFC = (props) => {
+  const { t } = useTranslation();
+
+  return <StatusTable t={t} {...props} />;
+};
 
 StatusTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
@@ -179,4 +179,4 @@ StatusTable.propTypes = {
   aliasesData: PropTypes.object,
 };
 
-export default withTranslation()(StatusTableWrapper);
+export default StatusTableWrapperFC;

+ 0 - 66
packages/app/src/components/Admin/ExportArchiveData/ArchiveFilesTable.jsx

@@ -1,66 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-
-import ArchiveFilesTableMenu from './ArchiveFilesTableMenu';
-
-class ArchiveFilesTable extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="table-responsive">
-        <table className="table table-bordered">
-          <thead>
-            <tr>
-              <th>{t('admin:export_management.file')}</th>
-              <th>{t('admin:export_management.growi_version')}</th>
-              <th>{t('admin:export_management.collections')}</th>
-              <th>{t('admin:export_management.exported_at')}</th>
-              <th></th>
-            </tr>
-          </thead>
-          <tbody>
-            {this.props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
-              return (
-                <tr key={fileName}>
-                  <th>{fileName}</th>
-                  <td>{meta.version}</td>
-                  <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
-                  <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
-                  <td>
-                    <ArchiveFilesTableMenu
-                      fileName={fileName}
-                      onZipFileStatRemove={this.props.onZipFileStatRemove}
-                    />
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </div>
-    );
-  }
-
-}
-
-ArchiveFilesTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  zipFileStats: PropTypes.arrayOf(PropTypes.object).isRequired,
-  onZipFileStatRemove: PropTypes.func.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ArchiveFilesTableWrapper = withUnstatedContainers(ArchiveFilesTable, [AppContainer]);
-
-export default withTranslation()(ArchiveFilesTableWrapper);

+ 51 - 0
packages/app/src/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx

@@ -0,0 +1,51 @@
+import React from 'react';
+
+import { format } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+import ArchiveFilesTableMenu from './ArchiveFilesTableMenu';
+
+type ArchiveFilesTableProps = {
+  zipFileStats: any[],
+  onZipFileStatRemove: (fileName: string) => void,
+}
+
+const ArchiveFilesTable = (props: ArchiveFilesTableProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  return (
+    <div className="table-responsive">
+      <table className="table table-bordered">
+        <thead>
+          <tr>
+            <th>{t('admin:export_management.file')}</th>
+            <th>{t('admin:export_management.growi_version')}</th>
+            <th>{t('admin:export_management.collections')}</th>
+            <th>{t('admin:export_management.exported_at')}</th>
+            <th></th>
+          </tr>
+        </thead>
+        <tbody>
+          {props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
+            return (
+              <tr key={fileName}>
+                <th>{fileName}</th>
+                <td>{meta.version}</td>
+                <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+                <td>
+                  <ArchiveFilesTableMenu
+                    fileName={fileName}
+                    onZipFileStatRemove={props.onZipFileStatRemove}
+                  />
+                </td>
+              </tr>
+            );
+          })}
+        </tbody>
+      </table>
+    </div>
+  );
+};
+
+export default ArchiveFilesTable;

+ 0 - 46
packages/app/src/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.jsx

@@ -1,46 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-// import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-class ArchiveFilesTableMenu extends React.Component {
-
-  render() {
-    const { t } = this.props;
-
-    return (
-      <div className="btn-group admin-user-menu dropdown">
-        <button type="button" className="btn btn-sm btn-outline-secondary dropdown-toggle" data-toggle="dropdown">
-          <i className="icon-settings"></i> <span className="caret"></span>
-        </button>
-        <ul className="dropdown-menu" role="menu">
-          <li className="dropdown-header">{t('admin:export_management.export_menu')}</li>
-          <button type="button" className="dropdown-item" onClick={() => { window.location.href = `/admin/export/${this.props.fileName}` }}>
-            <i className="icon-cloud-download" /> {t('admin:export_management.download')}
-          </button>
-          <button type="button" className="dropdown-item" role="button" onClick={() => this.props.onZipFileStatRemove(this.props.fileName)}>
-            <span className="text-danger"><i className="icon-trash" /> {t('admin:export_management.delete')}</span>
-          </button>
-        </ul>
-      </div>
-    );
-  }
-
-}
-
-ArchiveFilesTableMenu.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  fileName: PropTypes.string.isRequired,
-  onZipFileStatRemove: PropTypes.func.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const ArchiveFilesTableMenuWrapper = withUnstatedContainers(ArchiveFilesTableMenu, [AppContainer]);
-
-export default withTranslation()(ArchiveFilesTableMenuWrapper);

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