Procházet zdrojové kódy

Merge branch 'support/apply-nextjs-2' into feat/render-page-list-and-timeline

yohei0125 před 3 roky
rodič
revize
6d855ba12c
100 změnil soubory, kde provedl 1564 přidání a 1095 odebrání
  1. 3 0
      .github/workflows/reusable-app-prod.yml
  2. 4 1
      .vscode/settings.json
  3. 21 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 2 2
      package.json
  6. 1 0
      packages/app/_obsolete/config/webpack.common.js
  7. 0 2
      packages/app/_obsolete/src/client/app.jsx
  8. 1 0
      packages/app/config/ci/.env.local.for-ci
  9. 2 2
      packages/app/docker/README.md
  10. 5 1
      packages/app/next.config.js
  11. 13 10
      packages/app/package.json
  12. 7 5
      packages/app/public/static/locales/en_US/admin.json
  13. 2 0
      packages/app/public/static/locales/en_US/translation.json
  14. 6 4
      packages/app/public/static/locales/ja_JP/admin.json
  15. 2 0
      packages/app/public/static/locales/ja_JP/translation.json
  16. 7 4
      packages/app/public/static/locales/zh_CN/admin.json
  17. 2 0
      packages/app/public/static/locales/zh_CN/translation.json
  18. 1 0
      packages/app/regconfig.json
  19. 5 0
      packages/app/src/client/services/AdminAppContainer.js
  20. 5 0
      packages/app/src/client/services/AdminBasicSecurityContainer.js
  21. 5 0
      packages/app/src/client/services/AdminCustomizeContainer.js
  22. 5 0
      packages/app/src/client/services/AdminExternalAccountsContainer.js
  23. 5 0
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  24. 5 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  25. 5 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  26. 5 0
      packages/app/src/client/services/AdminHomeContainer.js
  27. 5 0
      packages/app/src/client/services/AdminImportContainer.js
  28. 5 0
      packages/app/src/client/services/AdminLdapSecurityContainer.js
  29. 5 0
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  30. 5 0
      packages/app/src/client/services/AdminMarkDownContainer.js
  31. 5 0
      packages/app/src/client/services/AdminNotificationContainer.js
  32. 5 1
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  33. 5 1
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  34. 5 0
      packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  35. 5 1
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  36. 8 3
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  37. 5 0
      packages/app/src/client/services/AdminUsersContainer.js
  38. 7 4
      packages/app/src/client/services/ContextExtractor.tsx
  39. 23 0
      packages/app/src/client/services/page-operation.ts
  40. 18 2
      packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  41. 28 20
      packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  42. 8 11
      packages/app/src/components/Admin/AuditLogManagement.tsx
  43. 3 1
      packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx
  44. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  45. 2 3
      packages/app/src/components/Admin/UserManagement.jsx
  46. 6 6
      packages/app/src/components/Admin/Users/UserInviteModal.jsx
  47. 20 10
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  48. 3 0
      packages/app/src/components/CommonStyles/katex.module.scss
  49. 5 0
      packages/app/src/components/IdenticalPathPage.module.scss
  50. 4 1
      packages/app/src/components/IdenticalPathPage.tsx
  51. 3 0
      packages/app/src/components/Layout/Admin.module.scss
  52. 2 8
      packages/app/src/components/Layout/AdminLayout.tsx
  53. 7 2
      packages/app/src/components/Layout/BasicLayout.tsx
  54. 31 0
      packages/app/src/components/Navbar/AuthorInfo.module.scss
  55. 3 1
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  56. 10 32
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  57. 11 3
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  58. 10 2
      packages/app/src/components/Navbar/PageEditorModeManager.module.scss
  59. 71 6
      packages/app/src/components/Navbar/SubNavButtons.tsx
  60. 5 0
      packages/app/src/components/Page/DisplaySwitcher.module.scss
  61. 10 10
      packages/app/src/components/Page/DisplaySwitcher.tsx
  62. 1 1
      packages/app/src/components/Page/RenderTagLabels.tsx
  63. 76 1
      packages/app/src/components/Page/RevisionRenderer.tsx
  64. 18 0
      packages/app/src/components/Page/TagLabels.module.scss
  65. 4 2
      packages/app/src/components/Page/TagLabels.tsx
  66. 0 108
      packages/app/src/components/PageAccessoriesModalControl.jsx
  67. 4 1
      packages/app/src/components/PageAlert/PageAlerts.tsx
  68. 1 1
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  69. 104 102
      packages/app/src/components/PageEditor.tsx
  70. 26 22
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  71. 93 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss
  72. 0 406
      packages/app/src/components/PageEditor/Editor.jsx
  73. 363 0
      packages/app/src/components/PageEditor/Editor.tsx
  74. 1 8
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  75. 6 69
      packages/app/src/components/PageEditor/Preview.tsx
  76. 5 7
      packages/app/src/components/PageEditorByHackmd.jsx
  77. 1 1
      packages/app/src/components/PageList/PageListItemL.tsx
  78. 5 1
      packages/app/src/components/PagePathNav.tsx
  79. 35 32
      packages/app/src/components/SavePageControls.jsx
  80. 2 1
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  81. 0 1
      packages/app/src/components/SearchPage/SearchResultList.tsx
  82. 2 2
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  83. 2 0
      packages/app/src/components/Sidebar/RecentChanges.tsx
  84. 2 9
      packages/app/src/components/Skelton.tsx
  85. 11 0
      packages/app/src/components/TableOfContents.module.scss
  86. 19 13
      packages/app/src/components/TableOfContents.tsx
  87. 0 59
      packages/app/src/components/TagPage.tsx
  88. 1 1
      packages/app/src/components/UncontrolledCodeMirror.tsx
  89. 1 0
      packages/app/src/components/UnstatedUtils.tsx
  90. 21 0
      packages/app/src/interfaces/customize.ts
  91. 17 0
      packages/app/src/interfaces/editor-methods.ts
  92. 15 4
      packages/app/src/interfaces/page-operation.ts
  93. 4 0
      packages/app/src/interfaces/services/renderer.ts
  94. 0 28
      packages/app/src/interfaces/unstated-container.ts
  95. 19 14
      packages/app/src/pages/[[...path]].page.tsx
  96. 91 19
      packages/app/src/pages/admin/[[...path]].page.tsx
  97. 137 0
      packages/app/src/pages/tags.page.tsx
  98. 2 0
      packages/app/src/pages/utils/commons.ts
  99. 4 1
      packages/app/src/server/models/obsolete-page.js
  100. 1 16
      packages/app/src/server/models/page-operation.ts

+ 3 - 0
.github/workflows/reusable-app-prod.yml

@@ -210,6 +210,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 }}

+ 4 - 1
.vscode/settings.json

@@ -21,5 +21,8 @@
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": true,
     "source.fixAll.markdownlint": true
-  }
+  },
+  "githubPullRequests.ignoredPullRequestBranches": [
+    "master"
+  ]
 }

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.0...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [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

+ 1 - 1
lerna.json

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

+ 2 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -76,7 +76,7 @@
     "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",

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

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

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

@@ -39,7 +39,6 @@ import { PageTimeline } from '../components/PageTimeline';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import { SearchPage } from '../components/SearchPage';
 import Sidebar from '../components/Sidebar';
-import TagPage from '../components/TagPage';
 import TrashPageList from '../components/TrashPageList';
 
 import { appContainer, componentMappings } from './base';
@@ -75,7 +74,6 @@ Object.assign(componentMappings, {
   'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'tags-page': <TagPage />,
 
   'grw-page-status-alert-container': <PageStatusAlert />,
 

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

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

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.0`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
-* [`5.1.0-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.0/docker/Dockerfile)
+* [`5.1.1`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.1/docker/Dockerfile)
+* [`5.1.1-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.1/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)

+ 5 - 1
packages/app/next.config.js

@@ -33,7 +33,9 @@ const setupTranspileModules = () => {
     'unified',
     'comma-separated-tokens',
     'decode-named-character-reference',
+    'hastscript',
     'html-void-elements',
+    'longest-streak',
     'property-information',
     'space-separated-tokens',
     'trim-lines',
@@ -41,7 +43,9 @@ const setupTranspileModules = () => {
     'vfile',
     'zwitch',
     'emoticon',
-    ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'micromark-', 'unist-']),
+    'direction', // for hast-util-select
+    'bcp-47-match', // for hast-util-select
+    ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'unist-']),
   ];
 
   logger.info('{bold:Listing scoped packages for transpiling:}');

+ 13 - 10
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.1-RC.0",
+  "version": "5.1.2-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -57,17 +57,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.1.1-RC.0",
-    "@growi/core": "^5.1.1-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.1-RC.0",
-    "@growi/plugin-lsx": "^5.1.1-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.1.1-RC.0",
-    "@growi/slack": "^5.1.1-RC.0",
+    "@growi/codemirror-textlint": "^5.1.2-RC.0",
+    "@growi/core": "^5.1.2-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.2-RC.0",
+    "@growi/plugin-lsx": "^5.1.2-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.1.2-RC.0",
+    "@growi/slack": "^5.1.2-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -81,7 +81,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",
     "bson-objectid": "^2.0.3",
     "bunyan": "^1.8.15",
     "check-node-version": "^4.1.0",
@@ -108,6 +108,7 @@
     "express-webpack-assets": "^0.1.0",
     "extensible-custom-error": "^0.0.7",
     "graceful-fs": "^4.1.11",
+    "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "i18next-chained-backend": "^3.0.2",
@@ -155,6 +156,7 @@
     "react-multiline-clamp": "^2.0.0",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
+    "rehype-katex": "^6.0.2",
     "rehype-raw": "^6.1.1",
     "rehype-sanitize": "^5.0.1",
     "rehype-slug": "^5.0.1",
@@ -162,6 +164,7 @@
     "remark-breaks": "^3.0.2",
     "remark-emoji": "^3.0.2",
     "remark-gfm": "^3.0.1",
+    "remark-math": "^5.1.1",
     "rimraf": "^3.0.0",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
@@ -184,7 +187,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.1-RC.0",
+    "@growi/ui": "^5.1.2-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",

+ 7 - 5
packages/app/public/static/locales/en_US/admin.json

@@ -422,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": {
@@ -537,15 +537,17 @@
     "url": "URL",
     "settings": "Settings",
     "return": "Return",
-    "clear": "Clear search criteria",
-    "reload": "Reload",
+    "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 search / view in the Audit Log",
+    "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."
+    "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",

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

@@ -168,6 +168,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",
@@ -545,6 +546,7 @@
     "create_failed": "Failed to create {{target}}",
     "update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin",

+ 6 - 4
packages/app/public/static/locales/ja_JP/admin.json

@@ -536,15 +536,17 @@
     "url": "URL",
     "settings": "設定",
     "return": "戻る",
-    "clear": "検索条件のクリア",
-    "reload": "再読み込み",
+    "clear": "クリア",
     "activity_expiration_date": "監査ログの有効期限",
     "activity_expiration_date_explain": "作成された監査ログは、作成時間から環境変数に設定した秒数後に自動的に削除されます",
     "fixed_by_env_var": "環境変数により固定されています <code>{{key}}={{value}}</code>.",
     "available_action_list": "検索 / 表示 可能なアクション一覧",
-    "available_action_list_explain": "監査ログで 検索 / 表示 可能なアクション一覧です",
+    "available_action_list_explain": "現在の設定で検索 / 表示 可能なアクション一覧です",
     "action_list": "アクション一覧",
-    "disable_mode_explain": "現在、監査ログは無効になっています。有効にする場合は環境変数 <code>AUDIT_LOG_ENABLED</code> を true に設定してください。"
+    "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": "ページ",

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

@@ -170,6 +170,7 @@
   "No bookmarks yet": "No bookmarks yet",
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
+  "wide_view": "ワイドビュー",
   "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
@@ -545,6 +546,7 @@
     "create_failed": "{{target}}の作成に失敗しました",
     "update_successed": "{{target}}を更新しました",
     "update_failed": "{{target}}の更新に失敗しました",
+    "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "give_user_admin": "{{username}}を管理者に設定しました",
     "remove_user_admin": "{{username}}を管理者から外しました",

+ 7 - 4
packages/app/public/static/locales/zh_CN/admin.json

@@ -546,15 +546,18 @@
     "url": "URL",
     "settings": "设置",
     "return": "返回",
-    "clear": "清除搜索标准",
-    "reload": "重新加载",
+    "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": "可以在审计日志中 搜索/查看 的行动列表",
+    "available_action_list_explain": "在当前配置中可以搜索/查看的行动列表",
     "action_list": "行动清单",
-    "disable_mode_explain": "审计日志当前已禁用。 要启用它,请将环境变量 <code>AUDIT_LOG_ENABLED</code> 设置为 true。"
+    "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": "页面",

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

@@ -176,6 +176,7 @@
   "No bookmarks yet": "暂无书签",
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
+  "wide_view": "视野开阔",
 	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
@@ -523,6 +524,7 @@
     "create_failed": "Failed to create {{target}}",
 		"update_successed": "Succeeded to update {{target}}",
     "update_failed": "Failed to update {{target}}",
+    "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
 		"give_user_admin": "Succeeded to give {{username}} admin",
     "remove_user_admin": "Succeeded to remove {{username}} admin ",

+ 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": {

+ 5 - 0
packages/app/src/client/services/AdminAppContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
@@ -11,6 +12,10 @@ export default class AdminAppContainer extends Container {
   constructor() {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
       retrieveError: null,
       title: '',

+ 5 - 0
packages/app/src/client/services/AdminBasicSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -16,6 +17,10 @@ export default class AdminBasicSecurityContainer extends Container {
   constructor() {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
       isSameUsernameTreatedAsIdenticalUser: false,
     };

+ 5 - 0
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminCustomizeContainer extends Container {
   constructor() {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
       retrieveError: null,
       isEnabledTimeline: false,

+ 5 - 0
packages/app/src/client/services/AdminExternalAccountsContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminExternalAccountsContainer extends Container {
   constructor() {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
       externalAccounts: [],
       totalAccounts: 0,

+ 5 - 0
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import {
@@ -18,6 +19,10 @@ export default class AdminGeneralSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
       retrieveError: null,
       sessionMaxAge: null,

+ 5 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 
@@ -18,6 +18,10 @@ export default class AdminGitHubSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.dummyGithubClientId = 0;
     this.dummyGithubClientIdForError = 1;
 

+ 5 - 1
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 
@@ -18,6 +18,10 @@ export default class AdminGoogleSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.dummyGoogleClientId = 0;
     this.dummyGoogleClientIdForError = 1;
 

+ 5 - 0
packages/app/src/client/services/AdminHomeContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminHomeContainer extends Container {
   constructor() {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.copyStateValues = {
       DEFAULT: 'default',
       DONE: 'done',

+ 5 - 0
packages/app/src/client/services/AdminImportContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminImportContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminLdapSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -16,6 +17,10 @@ export default class AdminLdapSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
@@ -15,6 +16,10 @@ export default class AdminLocalSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.dummyRegistrationMode = 0;
     this.dummyRegistrationModeForError = 1;

+ 5 - 0
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
@@ -11,6 +12,10 @@ export default class AdminMarkDownContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminNotificationContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import {
@@ -13,6 +14,10 @@ export default class AdminNotificationContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 1
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 
@@ -18,6 +18,10 @@ export default class AdminOidcSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 1
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 
@@ -18,6 +18,10 @@ export default class AdminSamlSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
@@ -11,6 +12,10 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 5 - 1
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 
@@ -18,6 +18,10 @@ export default class AdminTwitterSecurityContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 8 - 3
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -2,15 +2,16 @@
  * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
  */
 
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 
+import {
+  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
+} from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
 
-import {
-  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
-} from '~/client/util/apiv3-client';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
@@ -24,6 +25,10 @@ export default class AdminUserGroupDetailContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     const rootElem = document.getElementById('admin-user-group-detail');

+ 5 - 0
packages/app/src/client/services/AdminUsersContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 
@@ -19,6 +20,10 @@ export default class AdminUsersContainer extends Container {
   constructor(appContainer) {
     super();
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
 
     this.state = {

+ 7 - 4
packages/app/src/client/services/ContextExtractor.tsx

@@ -9,7 +9,7 @@ import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
-  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useSelectedGrant,
 } from '~/stores/ui';
 import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
 
@@ -133,6 +133,9 @@ const ContextExtractorOnce: FC = () => {
     plantumlUri: configByContextHydrate.env.PLANTUML_URI,
     blockdiagUri: configByContextHydrate.env.BLOCKDIAG_URI,
   });
+  // useNoCdn(configByContextHydrate.env.NO_CDN);
+  // useUploadableImage(configByContextHydrate.upload.image);
+  // useUploadableFile(configByContextHydrate.upload.file);
 
   // Page
   useDeleteUsername(deleteUsername);
@@ -170,9 +173,9 @@ const ContextExtractorOnce: FC = () => {
   useIsDeviceSmallerThanMd();
 
   // Editor
-  useSelectedGrant(grant);
-  useSelectedGrantGroupId(grantGroupId);
-  useSelectedGrantGroupName(grantGroupName);
+  // useSelectedGrant(grant);
+  // useSelectedGrantGroupId(grantGroupId);
+  // useSelectedGrantGroupName(grantGroupName);
 
   // SearchResult
   useIsDeviceSmallerThanLg();

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

@@ -36,6 +36,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 });

+ 18 - 2
packages/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -3,6 +3,7 @@ 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 = () => {
@@ -34,8 +35,23 @@ export const AuditLogSettings: FC = () => {
         />
       </p>
 
-      <h4 className="mt-4">{t('admin:audit_log_management.available_action_list')}</h4>
-      <p className="form-text text-muted">{t('admin:audit_log_management.available_action_list_explain')}</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>

+ 28 - 20
packages/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,29 +1,39 @@
-import React, {
-  FC, useRef, forwardRef, useCallback,
-} from 'react';
+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';
 
-import { useTranslation } from 'react-i18next';
-
 
 type CustomInputProps = {
-  buttonRef: React.Ref<HTMLButtonElement>
-  onClick?: () => void;
+  value?: string
+  onChange?: () => void
+  onFocus?: () => void
 }
 
-const CustomInput = forwardRef<HTMLButtonElement, CustomInputProps>((props: CustomInputProps) => {
-  const { t } = useTranslation();
+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 (
-    <button
-      type="button"
-      className="btn btn-outline-secondary dropdown-toggle"
-      ref={props.buttonRef}
-      onClick={props.onClick}
-    >
-      <i className="fa fa-fw fa-calendar" /> {t('admin:audit_log_management.date')}
-    </button>
+    <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>
   );
 });
 
@@ -38,8 +48,6 @@ type DateRangePickerProps = {
 export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePickerProps) => {
   const { startDate, endDate, onChange } = props;
 
-  const buttonRef = useRef(null);
-
   const changeHandler = useCallback((dateList: Date[] | null[]) => {
     if (onChange != null) {
       const [start, end] = dateList;
@@ -60,7 +68,7 @@ export const DateRangePicker: FC<DateRangePickerProps> = (props: DateRangePicker
         startDate={startDate}
         endDate={endDate}
         onChange={changeHandler}
-        customInput={<CustomInput buttonRef={buttonRef} />}
+        customInput={<CustomInput />}
       />
     </div>
   );

+ 8 - 11
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -135,6 +135,11 @@ export const AuditLogManagement: FC = () => {
         <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 ? (
@@ -160,17 +165,9 @@ export const AuditLogManagement: FC = () => {
               onChangeMultipleAction={multipleActionCheckboxChangedHandler}
             />
 
-            <div className="ml-auto">
-              <button type="button" className="btn btn-outline-secondary btn-sm mr-2" onClick={clearButtonPushedHandler}>
-                <span className="icon-refresh mr-1" />
-                {t('admin:audit_log_management.clear')}
-              </button>
-
-              <button type="button" className="btn btn-outline-secondary btn-sm" onClick={reloadButtonPushedHandler}>
-                <i className="icon icon-reload mr-1" />
-                {t('admin:audit_log_management.reload')}
-              </button>
-            </div>
+            <button type="button" className="btn btn-link" onClick={clearButtonPushedHandler}>
+              {t('admin:audit_log_management.clear')}
+            </button>
           </div>
 
           <p

+ 3 - 1
packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx

@@ -1,6 +1,7 @@
 /* eslint-disable no-useless-escape */
 import React, { useCallback, useState } from 'react';
 
+
 import { useTranslation } from 'next-i18next';
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
@@ -8,6 +9,7 @@ import {
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IHighlightJsCssSelectorOptions } from '~/interfaces/customize';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -48,7 +50,7 @@ const CustomizeHighlightSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
   const [isDropdownOpen, setIsDropdownOpen] = useState(false);
-  const options = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
+  const options: IHighlightJsCssSelectorOptions = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
 
   const onToggleDropdown = useCallback(() => {
     setIsDropdownOpen(!isDropdownOpen);

+ 2 - 2
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -82,12 +82,12 @@ const SecurityManagementContents = () => {
   return (
     <div data-testid="admin-security">
       <div className="mb-5">
-        {/* <SecuritySetting /> */}
+        <SecuritySetting />
       </div>
 
       {/* Shared Link List */}
       <div className="mb-5">
-        {/* <ShareLinkSetting /> */}
+        <ShareLinkSetting />
       </div>
 
 

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

@@ -10,7 +10,7 @@ import PaginationWrapper from '../PaginationWrapper';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-// import InviteUserControl from './Users/InviteUserControl';
+import InviteUserControl from './Users/InviteUserControl';
 import PasswordResetModal from './Users/PasswordResetModal';
 import UserTable from './Users/UserTable';
 
@@ -150,8 +150,7 @@ class UserManagement extends React.Component {
           />
         )}
         <p>
-          {/* show  */}
-          {/* <InviteUserControl /> */}
+          <InviteUserControl />
           <a className="btn btn-outline-secondary ml-2" href="/admin/users/external-accounts" role="button">
             <i className="icon-user-follow" aria-hidden="true"></i>
             {t('admin:user_management.external_account')}

+ 6 - 6
packages/app/src/components/Admin/Users/UserInviteModal.jsx

@@ -8,9 +8,10 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/apiNotification';
+import { useIsMailerSetup } from '~/stores/context';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -99,9 +100,8 @@ class UserInviteModal extends React.Component {
   }
 
   renderModalFooter() {
-    const { t, appContainer } = this.props;
+    const { t, isMailerSetup } = this.props;
     const { isCreateUserButtonPushed } = this.state;
-    const { isMailerSetup } = appContainer.config;
 
     return (
       <>
@@ -282,18 +282,18 @@ class UserInviteModal extends React.Component {
 
 const UserInviteModalWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <UserInviteModal t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <UserInviteModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 
 /**
  * Wrapper component for using unstated
  */
-const UserInviteModalWrapper = withUnstatedContainers(UserInviteModalWrapperFC, [AppContainer, AdminUsersContainer]);
+const UserInviteModalWrapper = withUnstatedContainers(UserInviteModalWrapperFC, [AdminUsersContainer]);
 
 
 UserInviteModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 };
 

+ 20 - 10
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -11,6 +11,7 @@ import {
 import { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
@@ -22,6 +23,7 @@ export const MenuItemType = {
   DELETE: 'delete',
   REVERT: 'revert',
   PATH_RECOVERY: 'pathRecovery',
+  SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
@@ -41,6 +43,7 @@ type CommonProps = {
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
   isInstantRename?: boolean,
   alignRight?: boolean,
@@ -57,13 +60,14 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading,
-    pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemRenderer: AdditionalMenuItems, isInstantRename, alignRight,
+    pageId, isLoading, pageInfo, isEnableActions, forceHideMenuItems, operationProcessData,
+    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
+    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
+    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+    additionalMenuItemRenderer: AdditionalMenuItems,
+    isInstantRename, alignRight,
   } = props;
 
-
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
     if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
@@ -136,7 +140,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
     // PathRecovery
     // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData?.Rename != null ? operationProcessData?.Rename.isProcessable : false;
+    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
 
     contents = (
       <>
@@ -148,8 +152,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           </DropdownItem>
         ) }
 
+        { AdditionalMenuItemsOnTop && (
+          <>
+            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+            <DropdownItem divider />
+          </>
+        ) }
+
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && !pageInfo.isEmpty && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -253,9 +264,8 @@ type PageItemControlSubstanceProps = CommonProps & {
 export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
 
   const {
-    pageId, pageInfo: presetPageInfo, fetchOnInit,
-    children,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId, pageInfo: presetPageInfo, fetchOnInit, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
+    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);

+ 3 - 0
packages/app/src/components/CommonStyles/katex.module.scss

@@ -0,0 +1,3 @@
+.katex-container :global {
+  @import '~katex/dist/katex.min';
+}

+ 5 - 0
packages/app/src/components/IdenticalPathPage.module.scss

@@ -0,0 +1,5 @@
+@use '~/styles/molecules/page-accessories-control';
+
+.grw-page-accessories-control :global {
+  @extend %grw-page-accessories-control;
+}

+ 4 - 1
packages/app/src/components/IdenticalPathPage.tsx

@@ -11,6 +11,9 @@ import PageListIcon from './Icons/PageListIcon';
 import { PageListItemL } from './PageList/PageListItemL';
 
 
+import styles from './IdenticalPathPage.module.scss';
+
+
 type IdenticalPathAlertProps = {
   path? : string | null,
 }
@@ -67,7 +70,7 @@ export const IdenticalPathPage = (): JSX.Element => {
     <div className="d-flex flex-column flex-lg-row-reverse">
 
       <div className="grw-side-contents-container">
-        <div className="grw-page-accessories-control pb-1">
+        <div className={`pb-1 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
           { currentPath != null && !isSharedUser && (
             <button
               type="button"

+ 3 - 0
packages/app/src/components/Layout/Admin.module.scss

@@ -227,6 +227,9 @@ $slack-work-space-name-card-border: #efc1f6;
       max-height: 500px;
       overflow-y: auto;
     }
+    .date-range-picker {
+      width: 188px;
+    }
   }
 
   #layoutOptions {

+ 2 - 8
packages/app/src/components/Layout/AdminLayout.tsx

@@ -1,9 +1,6 @@
 import React, { ReactNode } from 'react';
 
 import dynamic from 'next/dynamic';
-import { Provider } from 'unstated';
-
-import { AdminInjectableContainers } from '~/interfaces/unstated-container';
 
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
@@ -21,12 +18,11 @@ type Props = {
    */
   selectedNavOpt: string
   children?: ReactNode
-  injectableContainers: AdminInjectableContainers
 }
 
 
 const AdminLayout = ({
-  children, title, selectedNavOpt, injectableContainers,
+  children, title, selectedNavOpt,
 }: Props): JSX.Element => {
 
   const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
@@ -46,9 +42,7 @@ const AdminLayout = ({
               <AdminNavigation selected={selectedNavOpt} />
             </div>
             <div className="col-lg-9">
-              <Provider inject={injectableContainers}>
-                {children}
-              </Provider>
+              {children}
             </div>
           </div>
         </div>

+ 7 - 2
packages/app/src/components/Layout/BasicLayout.tsx

@@ -11,10 +11,13 @@ import { RawLayout } from './RawLayout';
 type Props = {
   title: string
   className?: string,
+  expandContainer?: boolean,
   children?: ReactNode
 }
 
-export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
+export const BasicLayout = ({
+  children, title, className, expandContainer,
+}: Props): JSX.Element => {
 
   // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
   // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
@@ -28,8 +31,10 @@ export const BasicLayout = ({ children, title, className }: Props): JSX.Element
   const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
   const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 
+  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
+
   return (
-    <RawLayout title={title} className={className}>
+    <RawLayout title={title} className={myClassName}>
       <GrowiNavbar />
 
       <div className="page-wrapper d-flex d-print-block">

+ 31 - 0
packages/app/src/components/Navbar/AuthorInfo.module.scss

@@ -0,0 +1,31 @@
+@use '~/styles/bootstrap/init' as bs;
+
+$author-font-size: 12px;
+$date-font-size: 11px;
+
+.grw-author-info :global {
+  li {
+    font-size: $author-font-size;
+    list-style: none;
+  }
+
+  .text-date {
+    font-size: $date-font-size;
+  }
+
+  .picture {
+    width: 22px;
+    height: 22px;
+    border: 1px solid bs.$gray-300;
+
+    &.picture-xs {
+      width: 14px;
+      height: 14px;
+    }
+  }
+}
+
+.grw-author-info-skelton :global {
+  width: 139px;
+  height: calc((#{$author-font-size} + #{$date-font-size}) * #{bs.$line-height-base});
+}

+ 3 - 1
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -39,6 +39,8 @@ import { Skelton } from '../Skelton';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { SubNavButtonsProps } from './SubNavButtons';
 
+import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
+
 
 type AdditionalMenuItemsProps = {
   pageId: string,
@@ -155,7 +157,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const PageEditorModeManager = dynamic(
     () => import('./PageEditorModeManager'),
-    { ssr: false, loading: () => <Skelton width={213} height={33.99} /> },
+    { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
   );
   const SubNavButtons = dynamic<SubNavButtonsProps>(
     () => import('./SubNavButtons').then(mod => mod.SubNavButtons),

+ 10 - 32
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -88,42 +88,20 @@
       font-size: 16px;
     }
 
-    ul.authors {
-      li {
-        font-size: 12px;
-        list-style: none;
-      }
+    .user-list-popover {
+      max-width: 200px;
 
-      .text-date {
-        font-size: 11px;
-      }
+      .user-list-content {
+        direction: rtl;
 
-      .picture {
-        width: 22px;
-        height: 22px;
-        border: 1px solid bs.$gray-300;
-
-        &.picture-xs {
-          width: 14px;
-          height: 14px;
+        .liker-user-count,
+        .seen-user-count {
+          font-size: 12px;
+          font-weight: bolder;
         }
       }
-
-      .user-list-popover {
-        max-width: 200px;
-
-        .user-list-content {
-          direction: rtl;
-
-          .liker-user-count,
-          .seen-user-count {
-            font-size: 12px;
-            font-weight: bolder;
-          }
-        }
-        .cls-1 {
-          isolation: isolate;
-        }
+      .cls-1 {
+        isolation: isolate;
       }
     }
   }

+ 11 - 3
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -14,6 +14,8 @@ import { Skelton } from '../Skelton';
 import DrawerToggler from './DrawerToggler';
 
 
+import TagLabelsStyles from '../Page/TagLabels.module.scss';
+import AuthorInfoStyles from './AuthorInfo.module.scss';
 import styles from './GrowiSubNavigation.module.scss';
 
 
@@ -37,8 +39,14 @@ type Props = {
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
 
-  const TagLabels = dynamic(() => import('../Page/TagLabels'), { ssr: false, loading: () => <Skelton width={137} height={21.99} additionalClass='py-1' /> });
-  const AuthorInfo = dynamic(() => import('./AuthorInfo'), { ssr: false, loading: () => <Skelton width={139} height={32.84} additionalClass='py-1' /> });
+  const TagLabels = dynamic(() => import('../Page/TagLabels'), {
+    ssr: false,
+    loading: () => <Skelton additionalClass={`${TagLabelsStyles['grw-tag-labels-skelton']} py-1`} />,
+  });
+  const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+    ssr: false,
+    loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
+  });
 
   const { data: editorMode } = useEditorMode();
 
@@ -95,7 +103,7 @@ export const GrowiSubNavigation = (props: Props): JSX.Element => {
 
         {/* Page Authors */}
         { (showPageAuthors && !isCompactMode) && (
-          <ul className="authors text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3">
+          <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
             <li className="pb-1">
               <AuthorInfo user={creator as IUser} date={createdAt} locate="subnav" />
             </li>

+ 10 - 2
packages/app/src/components/Navbar/PageEditorModeManager.module.scss

@@ -2,6 +2,8 @@
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/mixins';
 
+$btn-line-height: 1.2rem;
+
 .grw-page-editor-mode-manager :global {
   .btn {
     width: 70px;
@@ -11,7 +13,7 @@
 
     &.view-button,
     &.edit-button {
-      line-height: 1.2rem;
+      line-height: $btn-line-height;
       .grw-page-editor-mode-manager-icon {
         @include bs.media-breakpoint-down(sm) {
           font-size: 1.2rem;
@@ -19,7 +21,7 @@
       }
     }
     &.hackmd-button {
-      line-height: 1.2rem;
+      line-height: $btn-line-height;
       .grw-page-editor-mode-manager-icon {
         @include bs.media-breakpoint-down(sm) {
           font-size: 1.2rem;
@@ -32,3 +34,9 @@
     }
   }
 }
+
+.grw-page-editor-mode-manager-skelton :global {
+
+  width: 213px;
+  height: calc($btn-line-height + bs.$btn-padding-y*2 + bs.$btn-border-width*2);
+}

+ 71 - 6
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -1,10 +1,14 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
-import dynamic from 'next/dynamic';
+import { useTranslation } from 'next-i18next';
+import { DropdownItem } from 'reactstrap';
 
-import { toggleBookmark, toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import {
-  IPageInfoAll, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
+  toggleBookmark, toggleLike, toggleSubscribe, updateContentWidth,
+} from '~/client/services/page-operation';
+import { toastError } from '~/client/util/apiNotification';
+import {
+  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta, isIPageInfoForEntity, isIPageInfoForOperation,
 } from '~/interfaces/page';
 import { useIsGuestUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
@@ -22,6 +26,43 @@ import SubscribeButton from '../SubscribeButton';
 import SeenUserInfo from '../User/SeenUserInfo';
 
 
+type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
+  onClickMenuItem: (newValue: boolean) => void,
+}
+
+const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    pageInfo, onClickMenuItem,
+  } = props;
+
+  if (!isIPageInfoForEntity(pageInfo)) {
+    return <></>;
+  }
+
+  return (
+    <DropdownItem
+      onClick={() => onClickMenuItem(!pageInfo.expandContentWidth)}
+      className="grw-page-control-dropdown-item"
+    >
+      <div className="custom-control custom-switch ml-1">
+        <input
+          id="switchContentWidth"
+          className="custom-control-input"
+          type="checkbox"
+          checked={pageInfo.expandContentWidth}
+          onChange={() => {}}
+        />
+        <label className="custom-control-label" htmlFor="switchContentWidth">
+          { t('wide_view') }
+        </label>
+      </div>
+    </DropdownItem>
+  );
+};
+
+
 type CommonProps = {
   isCompactMode?: boolean,
   disableSeenUserInfoPopover?: boolean,
@@ -38,7 +79,7 @@ type SubNavButtonsSubstanceProps = CommonProps & {
   shareLinkId?: string | null,
   revisionId: string | null,
   path?: string | null,
-  pageInfo: IPageInfoAll,
+  pageInfo: IPageInfoForOperation,
 }
 
 const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element => {
@@ -143,11 +184,34 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
     onClickDeleteMenuItem(pageToDelete);
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
+  const switchContentWidthClickHandler = useCallback(async(newValue: boolean) => {
+    if (isGuestUser == null || isGuestUser) {
+      return;
+    }
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return;
+    }
+    try {
+      await updateContentWidth(pageId, newValue);
+      mutatePageInfo();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
+
+  const additionalMenuItemOnTopRenderer = useMemo(() => {
+    if (!isIPageInfoForEntity(pageInfo)) {
+      return undefined;
+    }
+    const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => <WideViewMenuItem {...props} onClickMenuItem={switchContentWidthClickHandler} />;
+    return wideviewMenuItemRenderer;
+  }, [pageInfo, switchContentWidthClickHandler]);
+
   if (!isIPageInfoForOperation(pageInfo)) {
     return <></>;
   }
 
-
   const {
     sumOfLikers, sumOfSeenUsers, isLiked, bookmarkCount, isBookmarked,
   } = pageInfo;
@@ -195,6 +259,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
           forceHideMenuItems={forceHideMenuItemsWithBookmark}
+          additionalMenuItemOnTopRenderer={additionalMenuItemOnTopRenderer}
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}

+ 5 - 0
packages/app/src/components/Page/DisplaySwitcher.module.scss

@@ -0,0 +1,5 @@
+@use '~/styles/molecules/page-accessories-control';
+
+.grw-page-accessories-control :global {
+  @extend %grw-page-accessories-control;
+}

+ 10 - 10
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -7,7 +7,7 @@ import { TabContent, TabPane } from 'reactstrap';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound, useIsNotCreatable,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -17,12 +17,12 @@ import CountBadge from '../Common/CountBadge';
 import PageListIcon from '../Icons/PageListIcon';
 import NotFoundPage from '../NotFoundPage';
 import { Page } from '../Page';
-// import PageEditor from '../PageEditor';
 // import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import UserInfo from '../User/UserInfo';
 
-import styles from '../TableOfContents.module.scss';
+
+import styles from './DisplaySwitcher.module.scss';
 
 
 const WIKI_HEADER_LINK = 120;
@@ -33,6 +33,7 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
   const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
   const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
   const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
@@ -48,6 +49,7 @@ const DisplaySwitcher = (): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
+  const { data: isNotCreatable } = useIsNotCreatable();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
   const { data: editorMode } = useEditorMode();
@@ -71,12 +73,12 @@ const DisplaySwitcher = (): JSX.Element => {
               { isNotFound && <NotFoundPage /> }
             </div>
 
-            { !isNotFound && !currentPage?.isEmpty && (
+            { !isNotFound && (
               <div className="grw-side-contents-container">
                 <div className="grw-side-contents-sticky-container">
 
                   {/* Page list */}
-                  <div className="grw-page-accessories-control">
+                  <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
                     { currentPagePath != null && !isSharedUser && (
                       <button
                         type="button"
@@ -96,7 +98,7 @@ const DisplaySwitcher = (): JSX.Element => {
                   {/* Comments */}
                   {/* { getCommentListDom != null && !isTopPagePath && ( */}
                   { !isTopPagePath && (
-                    <div className="grw-page-accessories-control mt-2">
+                    <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
                       <button
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
@@ -110,9 +112,7 @@ const DisplaySwitcher = (): JSX.Element => {
                   ) }
 
                   <div className="d-none d-lg-block">
-                    <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>
-                      <TableOfContents />
-                    </div>
+                    <TableOfContents />
                     <ContentLinkButtons />
                   </div>
 
@@ -125,7 +125,7 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
-              {/* <PageEditor /> */}
+              <PageEditor />
             </div>
           </TabPane>
         ) }

+ 1 - 1
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -35,7 +35,7 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
 
       <div id="edit-tags-btn-wrapper-for-tooltip">
         <a
-          className={`btn btn-link btn-edit-tags p-0 text-muted ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          className={`btn btn-link btn-edit-tags p-0 text-muted d-flex ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
           onClick={openEditorHandler}
         >
           { isTagsEmpty && <>{ t('Add tags for this page') }</>}

+ 76 - 1
packages/app/src/components/Page/RevisionRenderer.tsx

@@ -13,6 +13,8 @@ import loggerFactory from '~/utils/logger';
 
 // import RevisionBody from './RevisionBody';
 
+import katexStyles from '../CommonStyles/katex.module.scss';
+
 
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
@@ -100,7 +102,10 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   } = props;
 
   return (
-    <ReactMarkdown {...rendererOptions} className={`wiki ${additionalClassName ?? ''}`}>
+    <ReactMarkdown
+      {...rendererOptions}
+      className={`wiki katex-container ${katexStyles['katex-container']} ${additionalClassName ?? ''}`}
+    >
       {markdown}
     </ReactMarkdown>
   );
@@ -122,6 +127,76 @@ const RevisionRenderer = (props: Props): JSX.Element => {
   // }, [editorSettings?.renderDrawioInRealtime, markdown, pagePath]);
 
 
+  // const renderHtml = useCallback(async() => {
+  //   if (interceptorManager == null) {
+  //     return;
+  //   }
+
+  //   const context = currentRenderingContext;
+
+  //   await interceptorManager.process('preRender', context);
+  //   await interceptorManager.process('prePreProcess', context);
+  //   context.markdown = growiRenderer.preProcess(context.markdown, context);
+  //   await interceptorManager.process('postPreProcess', context);
+  //   context.parsedHTML = growiRenderer.process(context.markdown, context);
+  //   await interceptorManager.process('prePostProcess', context);
+  //   context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context);
+
+  //   const isMarkdownEmpty = context.markdown.trim().length === 0;
+  //   if (highlightKeywords != null && !isMarkdownEmpty) {
+  //     context.parsedHTML = getHighlightedBody(context.parsedHTML, highlightKeywords);
+  //   }
+  //   await interceptorManager.process('postPostProcess', context);
+  //   await interceptorManager.process('preRenderHtml', context);
+
+  //   setHtml(context.parsedHTML);
+  // }, [currentRenderingContext, growiRenderer, highlightKeywords, interceptorManager]);
+
+  // useEffect(() => {
+  //   if (interceptorManager == null) {
+  //     return;
+  //   }
+
+  //   renderHtml()
+  //     .then(() => {
+  //       // const HeaderLink = document.getElementsByClassName('revision-head-link');
+  //       // const HeaderLinkArray = Array.from(HeaderLink);
+  //       // addSmoothScrollEvent(HeaderLinkArray as HTMLAnchorElement[], blinkElem);
+
+  //       // interceptorManager.process('postRenderHtml', currentRenderingContext);
+  //     });
+
+  // }, [currentRenderingContext, interceptorManager, renderHtml]);
+
+  // const config = props.appContainer.getConfig();
+  // const isMathJaxEnabled = !!config.env.MATHJAX;
+
+  // return (
+  //   <RevisionBody
+  //     html={html}
+  //     isMathJaxEnabled={isMathJaxEnabled}
+  //     additionalClassName={props.additionalClassName}
+  //     renderMathJaxOnInit
+  //   />
+  // );
+
+  // const [html, setHtml] = useState('');
+
+  // const { data: interceptorManager } = useInterceptorManager();
+  // const { data: editorSettings } = useEditorSettings();
+  // const { data: currentPathname } = useCurrentPathname();
+
+  // const currentRenderingContext = useMemo(() => {
+  //   return {
+  //     markdown,
+  //     parsedHTML: '',
+  //     pagePath,
+  //     renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
+  //     currentPathname: decodeURIComponent(currentPathname ?? '/'),
+  //   };
+  // }, [editorSettings?.renderDrawioInRealtime, markdown, pagePath]);
+
+
   // const renderHtml = useCallback(async() => {
   //   if (interceptorManager == null) {
   //     return;

+ 18 - 0
packages/app/src/components/Page/TagLabels.module.scss

@@ -0,0 +1,18 @@
+@use '~/styles/bootstrap/init' as bs;
+
+$grw-tag-label-font-size: 12px;
+
+.grw-tag-labels :global {
+  .grw-tag-label {
+    font-size: $grw-tag-label-font-size;
+    font-weight: normal;
+    border-radius: bs.$border-radius;
+  }
+}
+
+
+.grw-tag-labels-skelton :global {
+  width: 137px;
+  height: calc(#{$grw-tag-label-font-size} + #{bs.$badge-padding-y} * 2);
+  font-size: $grw-tag-label-font-size; // set font-size to use the same em value in bs.$badge-padding-y(https://getbootstrap.jp/docs/5.0/components/badge/#variables)
+}

+ 4 - 2
packages/app/src/components/Page/TagLabels.tsx

@@ -3,6 +3,8 @@ import React, { FC, useState } from 'react';
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 
+import styles from './TagLabels.module.scss';
+
 type Props = {
   tags?: string[],
   isGuestUser: boolean,
@@ -25,7 +27,7 @@ const TagLabels:FC<Props> = (props: Props) => {
 
   return (
     <>
-      <form className="grw-tag-labels form-inline">
+      <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`}>
         <i className="tag-icon icon-tag mr-2"></i>
         { tags == null
           ? (
@@ -39,7 +41,7 @@ const TagLabels:FC<Props> = (props: Props) => {
             />
           )
         }
-      </form>
+      </div>
 
       <TagEditModal
         tags={tags}

+ 0 - 108
packages/app/src/components/PageAccessoriesModalControl.jsx

@@ -1,108 +0,0 @@
-import React, { Fragment, useMemo } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useCurrentPageId } from '~/stores/context';
-
-import AttachmentIcon from './Icons/AttachmentIcon';
-import HistoryIcon from './Icons/HistoryIcon';
-import PageListIcon from './Icons/PageListIcon';
-import ShareLinkIcon from './Icons/ShareLinkIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-
-const PageAccessoriesModalControl = (props) => {
-  const { t } = useTranslation();
-  const {
-    pageAccessoriesContainer, isGuestUser, isSharedUser,
-  } = props;
-  const isLinkSharingDisabled = pageAccessoriesContainer.appContainer.config.disableLinkSharing;
-
-  const { data: pageId } = useCurrentPageId();
-
-  const accessoriesBtnList = useMemo(() => {
-    return [
-      {
-        name: 'pagelist',
-        Icon: <PageListIcon />,
-        disabled: isSharedUser,
-        i18n: t('page_list'),
-      },
-      {
-        name: 'timeline',
-        Icon: <TimeLineIcon />,
-        disabled: isSharedUser,
-        i18n: t('Timeline View'),
-      },
-      {
-        name: 'pageHistory',
-        Icon: <HistoryIcon />,
-        disabled: isGuestUser || isSharedUser,
-        i18n: t('History'),
-      },
-      {
-        name: 'attachment',
-        Icon: <AttachmentIcon />,
-        i18n: t('attachment_data'),
-      },
-      {
-        name: 'shareLink',
-        Icon: <ShareLinkIcon />,
-        disabled: isGuestUser || isSharedUser || isLinkSharingDisabled,
-        i18n: t('share_links.share_link_management'),
-      },
-    ];
-  }, [t, isGuestUser, isSharedUser, isLinkSharingDisabled]);
-
-  return (
-    <div className="grw-page-accessories-control d-flex flex-nowrap align-items-center justify-content-end justify-content-lg-between">
-      {accessoriesBtnList.map((accessory) => {
-
-        let tooltipMessage;
-        if (accessory.disabled) {
-          tooltipMessage = t('Not available for guest');
-          if (accessory.name === 'shareLink' && isLinkSharingDisabled) {
-            tooltipMessage = t('Link sharing is disabled');
-          }
-        }
-        else {
-          tooltipMessage = accessory.i18n;
-        }
-
-        return (
-          <Fragment key={accessory.name}>
-            <div id={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`}>
-              <button
-                type="button"
-                className={`btn btn-link grw-btn-page-accessories ${accessory.disabled ? 'disabled' : ''}`}
-                onClick={() => pageAccessoriesContainer.openPageAccessoriesModal(accessory.name)}
-              >
-                {accessory.Icon}
-              </button>
-            </div>
-            <UncontrolledTooltip placement="top" target={`shareLink-btn-wrapper-for-tooltip-for-${accessory.name}`} fade={false}>
-              {tooltipMessage}
-            </UncontrolledTooltip>
-          </Fragment>
-        );
-      })}
-    </div>
-  );
-};
-
-PageAccessoriesModalControl.propTypes = {
-  pageAccessoriesContainer: PropTypes.any,
-
-  isGuestUser: PropTypes.bool.isRequired,
-  isSharedUser: PropTypes.bool.isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageAccessoriesModalControlWrapper = withUnstatedContainers(PageAccessoriesModalControl, []);
-
-export default PageAccessoriesModalControlWrapper;

+ 4 - 1
packages/app/src/components/PageAlert/PageAlerts.tsx

@@ -2,6 +2,8 @@ import React from 'react';
 
 import dynamic from 'next/dynamic';
 
+import { useIsNotFound } from '~/stores/context';
+
 import { FixPageGrantAlert } from './FixPageGrantAlert';
 import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
@@ -12,12 +14,13 @@ const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.
 
 export const PageAlerts = (): JSX.Element => {
 
+  const { data: isNotFound } = useIsNotFound();
 
   return (
     <div className="row d-edit-none">
       <div className="col-sm-12">
         {/* alerts */}
-        <FixPageGrantAlert />
+        { !isNotFound && <FixPageGrantAlert /> }
         <PageGrantAlert />
         <TrashPageAlert />
         <PageStaleAlert />

+ 1 - 1
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -34,7 +34,7 @@ export const TrashPageAlert = (): JSX.Element => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
-  const lastUpdateUserName = pageData?.lastUpdateUser.name;
+  const lastUpdateUserName = pageData?.lastUpdateUser?.name;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
 

+ 104 - 102
packages/app/src/components/PageEditor.tsx

@@ -8,31 +8,32 @@ import { envUtils } from '@growi/core';
 import detectIndent from 'detect-indent';
 import { throttle, debounce } from 'throttle-debounce';
 
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
+// import AppContainer from '~/client/services/AppContainer';
+// import EditorContainer from '~/client/services/EditorContainer';
+// import PageContainer from '~/client/services/PageContainer';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPageId,
+  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
-  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useIsMobile, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 
-import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+// import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-import { withUnstatedContainers } from './UnstatedUtils';
+// import { withUnstatedContainers } from './UnstatedUtils';
 
 
 // TODO: remove this when omitting unstated is completed
@@ -52,26 +53,26 @@ type EditorRef = {
 }
 
 type Props = {
-  appContainer: AppContainer,
-  pageContainer: PageContainer,
-  editorContainer: EditorContainer,
-
-  isEditable: boolean,
-
-  editorMode: string,
-  isSlackEnabled: boolean,
-  slackChannels: string,
-  isMobile?: boolean,
-
-  grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
-  mutateGrant: (grant: number) => void,
-
-  isTextlintEnabled?: boolean,
-  isIndentSizeForced?: boolean,
-  indentSize?: number,
-  mutateCurrentIndentSize: (indent: number) => void,
+  // appContainer: AppContainer,
+  // pageContainer: PageContainer,
+  // editorContainer: EditorContainer,
+
+  // isEditable: boolean,
+
+  // editorMode: string,
+  // isSlackEnabled: boolean,
+  // slackChannels: string,
+  // isMobile?: boolean,
+
+  // grant: number,
+  // grantGroupId?: string,
+  // grantGroupName?: string,
+  // mutateGrant: (grant: number) => void,
+
+  // isTextlintEnabled?: boolean,
+  // isIndentSizeForced?: boolean,
+  // indentSize?: number,
+  // mutateCurrentIndentSize: (indent: number) => void,
 };
 
 // for scrolling
@@ -80,9 +81,9 @@ let isOriginOfScrollSyncEditor = false;
 let isOriginOfScrollSyncPreview = false;
 
 const PageEditor = (props: Props): JSX.Element => {
-  const {
-    appContainer, pageContainer, editorContainer,
-  } = props;
+  // const {
+  //   appContainer, pageContainer, editorContainer,
+  // } = props;
 
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
@@ -92,76 +93,75 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
-  const { data: grantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isUploadableFile } = useIsUploadableFile();
+  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: currentPage } = useSWRxCurrentPage();
 
   const { data: rendererOptions } = usePreviewOptions();
 
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
+  const [markdown, setMarkdown] = useState<string>('');
+
+  useEffect(() => {
+    if (currentPage != null) {
+      setMarkdown(currentPage.revision?.body);
+    }
+  }, [currentPage, currentPage?.revision?.body]);
 
 
   const editorRef = useRef<EditorRef>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
   const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, value => setMarkdown(value))), []);
-  const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
-    editorContainer.saveDraft(pageContainer.state.path, markdown);
-  }), [editorContainer, markdown, pageContainer.state.path]);
+  // const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
+  //   editorContainer.saveDraft(pageContainer.state.path, markdown);
+  // }), [editorContainer, markdown, pageContainer.state.path]);
 
   const markdownChangedHandler = useCallback((value: string): void => {
     setMarkdownWithDebounce(value);
     // only when the first time to edit
-    if (!pageContainer.state.revisionId) {
-      saveDraftWithDebounce();
-    }
-  }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
+    // if (!pageContainer.state.revisionId) {
+    //   saveDraftWithDebounce();
+    // }
+  // }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
+  }, [setMarkdownWithDebounce]);
 
 
   const saveWithShortcut = useCallback(async() => {
-    if (grant == null) {
+    if (grantData == null) {
       return;
     }
 
     const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
 
-    const optionsToSave = getOptionsToSave(isSlackEnabled ?? false, slackChannels, grant, grantGroupId, grantGroupName, pageTags || []);
+    const optionsToSave = getOptionsToSave(
+      isSlackEnabled ?? false, slackChannels,
+      grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
+      pageTags || [],
+    );
 
     try {
       // disable unsaved warning
       mutateIsEnabledUnsavedWarning(false);
 
       // eslint-disable-next-line no-unused-vars
-      const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
+      // const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
       logger.debug('success to save');
 
-      pageContainer.showSuccessToastr();
+      // pageContainer.showSuccessToastr();
 
       // update state of EditorContainer
-      editorContainer.setState({ tags });
+      // editorContainer.setState({ tags });
     }
     catch (error) {
       logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
+      // pageContainer.showErrorToastr(error);
     }
-  }, [
-    editorContainer,
-    editorMode,
-    grant,
-    grantGroupId,
-    grantGroupName,
-    isSlackEnabled,
-    slackChannelsData,
-    markdown,
-    pageContainer,
-    pageTags,
-    mutateIsEnabledUnsavedWarning,
-  ]);
+  }, [grantData, isSlackEnabled, slackChannelsData, pageTags, mutateIsEnabledUnsavedWarning]);
 
 
   /**
@@ -184,10 +184,10 @@ const PageEditor = (props: Props): JSX.Element => {
       }
 
       const formData = new FormData();
-      const { pageId, path } = pageContainer.state;
+      // const { pageId, path } = pageContainer.state;
       formData.append('file', file);
-      if (path != null) {
-        formData.append('path', path);
+      if (currentPagePath != null) {
+        formData.append('path', currentPagePath);
       }
       if (pageId != null) {
         formData.append('page_id', pageId);
@@ -208,18 +208,19 @@ const PageEditor = (props: Props): JSX.Element => {
       // when if created newly
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+        // pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
         mutateGrant(res.page.grant);
       }
     }
     catch (e) {
       logger.error('failed to upload', e);
-      pageContainer.showErrorToastr(e);
+      // pageContainer.showErrorToastr(e);
     }
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [editorMode, mutateGrant, pageContainer]);
+  // }, [editorMode, mutateGrant, pageContainer]);
+  }, [editorMode, mutateGrant]);
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -274,7 +275,9 @@ const PageEditor = (props: Props): JSX.Element => {
 
     // turn on the flag
     isOriginOfScrollSyncEditor = true;
-    scrollSyncHelper.scrollPreviewToRevealOverflowing(previewRef.current, line);
+    if (previewRef.current != null) {
+      scrollSyncHelper.scrollPreviewToRevealOverflowing(previewRef.current, line);
+    }
   }, []);
   const scrollPreviewByCursorMovingWithThrottle = useMemo(() => throttle(20, scrollPreviewByCursorMoving), [scrollPreviewByCursorMoving]);
 
@@ -314,14 +317,14 @@ const PageEditor = (props: Props): JSX.Element => {
 
 
   // register dummy instance to get markdown
-  useEffect(() => {
-    const pageEditorInstance = {
-      getMarkdown: () => {
-        return markdown;
-      },
-    };
-    appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
-  }, [appContainer, markdown]);
+  // useEffect(() => {
+  //   const pageEditorInstance = {
+  //     getMarkdown: () => {
+  //       return markdown;
+  //     },
+  //   };
+  //   appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
+  // }, [appContainer, markdown]);
 
   // initial caret line
   useEffect(() => {
@@ -369,23 +372,23 @@ const PageEditor = (props: Props): JSX.Element => {
   }, []);
 
   // Displays an alert if there is a difference with pageContainer's markdown
-  useEffect(() => {
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    if (pageContainer.state.markdown! !== markdown) {
-      mutateIsEnabledUnsavedWarning(true);
-    }
-  }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
+  // useEffect(() => {
+  //   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  //   if (pageContainer.state.markdown! !== markdown) {
+  //     mutateIsEnabledUnsavedWarning(true);
+  //   }
+  // }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
 
   // Detect indent size from contents (only when users are allowed to change it)
-  useEffect(() => {
-    const currentPageMarkdown = pageContainer.state.markdown;
-    if (!isIndentSizeForced && currentPageMarkdown != null) {
-      const detectedIndent = detectIndent(currentPageMarkdown);
-      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
-        mutateCurrentIndentSize(detectedIndent.amount);
-      }
-    }
-  }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
+  // useEffect(() => {
+  //   const currentPageMarkdown = pageContainer.state.markdown;
+  //   if (!isIndentSizeForced && currentPageMarkdown != null) {
+  //     const detectedIndent = detectIndent(currentPageMarkdown);
+  //     if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+  //       mutateCurrentIndentSize(detectedIndent.amount);
+  //     }
+  //   }
+  // }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
 
 
   if (!isEditable) {
@@ -396,12 +399,10 @@ const PageEditor = (props: Props): JSX.Element => {
     return <></>;
   }
 
-  const config = props.appContainer.getConfig();
-  const isUploadable = config.upload.image || config.upload.file;
-  const isUploadableFile = config.upload.file;
-  const isMathJaxEnabled = !!config.env.MATHJAX;
+  // const config = props.appContainer.getConfig();
+  // const isUploadable = config.upload.image || config.upload.file;
+  const isUploadable = isUploadableImage || isUploadableFile;
 
-  const noCdn = envUtils.toBoolean(config.env.NO_CDN);
 
   // TODO: omit no-explicit-any -- 2022.06.02 Yuki Takei
   // It is impossible to avoid the error
@@ -410,13 +411,14 @@ const PageEditor = (props: Props): JSX.Element => {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const EditorAny = Editor as any;
 
+  // console.log('EditorAny', markdown);
+
   return (
     <div className="d-flex flex-wrap">
       <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
         <EditorAny
           ref={editorRef}
           value={markdown}
-          noCdn={noCdn}
           isMobile={isMobile}
           isUploadable={isUploadable}
           isUploadableFile={isUploadableFile}
@@ -434,17 +436,16 @@ const PageEditor = (props: Props): JSX.Element => {
           markdown={markdown}
           rendererOptions={rendererOptions}
           ref={previewRef}
-          // isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={false}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
         />
       </div>
-      <ConflictDiffModal
+      {/* <ConflictDiffModal
         isOpen={pageContainer.state.isConflictDiffModalOpen}
         onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
         pageContainer={pageContainer}
         markdownOnEdit={markdown}
-      />
+      /> */}
     </div>
   );
 };
@@ -452,6 +453,7 @@ const PageEditor = (props: Props): JSX.Element => {
 /**
    * Wrapper component for using unstated
    */
-const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+// const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
 
-export default PageEditorWrapper;
+// export default PageEditorWrapper;
+export default PageEditor;

+ 26 - 22
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -16,14 +16,14 @@ import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
-import DrawioModal from './DrawioModal';
+// import DrawioModal from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
-import GridEditModal from './GridEditModal';
+// import GridEditModal from './GridEditModal';
 import geu from './GridEditorUtil';
-import HandsontableModal from './HandsontableModal';
-import LinkEditModal from './LinkEditModal';
+// import HandsontableModal from './HandsontableModal';
+// import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import mlu from './MarkdownLinkUtil';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
@@ -32,6 +32,8 @@ import pasteHelper from './PasteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import SimpleCheatsheet from './SimpleCheatsheet';
 
+import styles from './CodeMirrorEditor.module.scss';
+
 // Textlint
 window.JSHINT = JSHINT;
 window.kuromojin = { dicPath: '/static/dict' };
@@ -50,7 +52,6 @@ require('codemirror/addon/edit/matchtags');
 require('codemirror/addon/edit/closetag');
 require('codemirror/addon/edit/continuelist');
 require('codemirror/addon/hint/show-hint');
-require('codemirror/addon/hint/show-hint.css');
 require('codemirror/addon/search/searchcursor');
 require('codemirror/addon/search/match-highlighter');
 require('codemirror/addon/selection/active-line');
@@ -58,12 +59,10 @@ require('codemirror/addon/scroll/annotatescrollbar');
 require('codemirror/addon/scroll/scrollpastend');
 require('codemirror/addon/fold/foldcode');
 require('codemirror/addon/fold/foldgutter');
-require('codemirror/addon/fold/foldgutter.css');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/addon/display/placeholder');
 require('codemirror/addon/lint/lint');
-require('codemirror/addon/lint/lint.css');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/drawio-fold.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
@@ -790,19 +789,19 @@ class CodeMirrorEditor extends AbstractEditor {
   }
 
   showGridEditorHandler() {
-    this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
+    // this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
   }
 
   showLinkEditHandler() {
-    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+    // this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
   }
 
   showHandsonTableHandler() {
-    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
+    // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
 
   showDrawioHandler() {
-    this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
+    // this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
 
 
@@ -988,8 +987,11 @@ class CodeMirrorEditor extends AbstractEditor {
       gutters.push('CodeMirror-lint-markers');
     }
 
+    console.log(' this.state.value', this.state.value);
+    console.log(' this.props.value', this.props.value);
+
     return (
-      <React.Fragment>
+      <div className={`grw-codemirror-editor ${styles['grw-codemirror-editor']}`}>
 
         <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
@@ -1000,7 +1002,9 @@ class CodeMirrorEditor extends AbstractEditor {
             editor.on('paste', this.pasteHandler);
             editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
           }}
-          value={this.state.value}
+          // temporary set props.value
+          // value={this.state.value}
+          value={this.props.value}
           options={{
             indentUnit: this.props.indentSize,
             theme: this.props.editorSettings.theme ?? 'elegant',
@@ -1052,25 +1056,25 @@ class CodeMirrorEditor extends AbstractEditor {
         { this.renderCheatsheetOverlay() }
         { this.renderEmojiPicker() }
 
-        <GridEditModal
+        {/* <GridEditModal
           ref={this.gridEditModal}
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
-        />
-        <LinkEditModal
+        /> */}
+        {/* <LinkEditModal
           ref={this.linkEditModal}
           onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
-        />
-        <HandsontableModal
+        /> */}
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
-        />
-        <DrawioModal
+        /> */}
+        {/* <DrawioModal
           ref={this.drawioModal}
           onSave={this.onSaveForDrawio}
-        />
+        /> */}
 
-      </React.Fragment>
+      </div>
     );
   }
 

+ 93 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss

@@ -0,0 +1,93 @@
+@use '~/styles/variables' as var;
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-codemirror-editor :global {
+  @import '~codemirror/lib/codemirror';
+
+  // addons
+  @import '~codemirror/addon/hint/show-hint';
+  @import '~codemirror/addon/fold/foldgutter';
+  @import '~codemirror/addon/lint/lint';
+
+  // themes
+  @import '~codemirror/theme/elegant';
+  @import '~codemirror/theme/eclipse';
+
+  .CodeMirror {
+    pre.CodeMirror-line.grw-cm-header-line {
+      padding-top: 0.16em;
+      padding-bottom: 0.08em;
+      font-family: bs.$font-family-monospace;
+
+      // '#'
+      .cm-formatting-header {
+        font-style: italic;
+        font-weight: bold;
+        opacity: 0.5;
+      }
+
+      .cm-header-1 {
+        font-size: 1.9em;
+      }
+      .cm-header-2 {
+        font-size: 1.6em;
+      }
+      .cm-header-3 {
+        font-size: 1.4em;
+      }
+      .cm-header-4 {
+        font-size: 1.35em;
+      }
+      .cm-header-5 {
+        font-size: 1.25em;
+      }
+      .cm-header-6 {
+        font-size: 1.2em;
+      }
+    }
+
+    .cm-matchhighlight {
+      color: bs.$gray-900 !important;
+      background-color: cyan;
+    }
+
+    .CodeMirror-selection-highlight-scrollbar {
+      background-color: darkcyan;
+    }
+
+    // overwrite .CodeMirror-placeholder
+    pre.CodeMirror-line-like.CodeMirror-placeholder {
+      color: bs.$text-muted;
+    }
+  }
+
+  // patch to fix https://github.com/codemirror/CodeMirror/issues/4089
+  // see also https://github.com/codemirror/CodeMirror/commit/51a1e7da60a99e019f026a118dc7c98c2b1f9d62
+  .CodeMirror-wrap > div > textarea {
+    font-size: #{bs.$line-height-base}rem;
+  }
+
+  // overwrite .CodeMirror-hints
+  .CodeMirror-hints {
+    max-height: 30em !important;
+
+    .CodeMirror-hint.crowi-emoji-autocomplete {
+      font-family: var.$font-family-monospace-not-strictly;
+      line-height: 1.6em;
+
+      .img-container {
+        display: inline-block;
+        width: 30px;
+      }
+    }
+
+    // active line
+    .CodeMirror-hint-active.crowi-emoji-autocomplete {
+      .img-container {
+        padding-top: 0.3em;
+        padding-bottom: 0.3em;
+        font-size: 15px; // adjust to .wiki
+      }
+    }
+  }
+}

+ 0 - 406
packages/app/src/components/PageEditor/Editor.jsx

@@ -1,406 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import Dropzone from 'react-dropzone';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useDefaultIndentSize } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
-
-import AbstractEditor from './AbstractEditor';
-import Cheatsheet from './Cheatsheet';
-import CodeMirrorEditor from './CodeMirrorEditor';
-import pasteHelper from './PasteHelper';
-import TextAreaEditor from './TextAreaEditor';
-
-
-class Editor extends AbstractEditor {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isComponentDidMount: false,
-      dropzoneActive: false,
-      isUploading: false,
-      isCheatsheetModalShown: false,
-    };
-
-    this.getEditorSubstance = this.getEditorSubstance.bind(this);
-
-    this.pasteFilesHandler = this.pasteFilesHandler.bind(this);
-
-    this.dragEnterHandler = this.dragEnterHandler.bind(this);
-    this.dragLeaveHandler = this.dragLeaveHandler.bind(this);
-    this.dropHandler = this.dropHandler.bind(this);
-
-    this.showMarkdownHelp = this.showMarkdownHelp.bind(this);
-    this.addAttachmentHandler = this.addAttachmentHandler.bind(this);
-
-    this.getAcceptableType = this.getAcceptableType.bind(this);
-    this.getDropzoneClassName = this.getDropzoneClassName.bind(this);
-    this.renderDropzoneOverlay = this.renderDropzoneOverlay.bind(this);
-  }
-
-  componentDidMount() {
-    this.setState({ isComponentDidMount: true });
-  }
-
-  getEditorSubstance() {
-    return this.props.isMobile
-      ? this.taEditor
-      : this.cmEditor;
-  }
-
-  /**
-   * @inheritDoc
-   */
-  forceToFocus() {
-    this.getEditorSubstance().forceToFocus();
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setValue(newValue) {
-    this.getEditorSubstance().setValue(newValue);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setGfmMode(bool) {
-    this.getEditorSubstance().setGfmMode(bool);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setCaretLine(line) {
-    this.getEditorSubstance().setCaretLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  setScrollTopByLine(line) {
-    this.getEditorSubstance().setScrollTopByLine(line);
-  }
-
-  /**
-   * @inheritDoc
-   */
-  insertText(text) {
-    this.getEditorSubstance().insertText(text);
-  }
-
-  /**
-   * remove overlay and set isUploading to false
-   */
-  terminateUploadingState() {
-    this.setState({
-      dropzoneActive: false,
-      isUploading: false,
-    });
-  }
-
-  /**
-   * dispatch onUpload event
-   */
-  dispatchUpload(files) {
-    if (this.props.onUpload != null) {
-      this.props.onUpload(files);
-    }
-  }
-
-  /**
-   * get acceptable(uploadable) file type
-   */
-  getAcceptableType() {
-    let accept = 'null'; // reject all
-    if (this.props.isUploadable) {
-      if (!this.props.isUploadableFile) {
-        accept = 'image/*'; // image only
-      }
-      else {
-        accept = ''; // allow all
-      }
-    }
-
-    return accept;
-  }
-
-  pasteFilesHandler(event) {
-    const items = event.clipboardData.items || event.clipboardData.files || [];
-
-    // abort if length is not 1
-    if (items.length < 1) {
-      return;
-    }
-
-    for (let i = 0; i < items.length; i++) {
-      try {
-        const file = items[i].getAsFile();
-        // check file type (the same process as Dropzone)
-        if (file != null && pasteHelper.isAcceptableType(file, this.getAcceptableType())) {
-          this.dispatchUpload(file);
-          this.setState({ isUploading: true });
-        }
-      }
-      catch (e) {
-        this.logger.error(e);
-      }
-    }
-  }
-
-  dragEnterHandler(event) {
-    const dataTransfer = event.dataTransfer;
-
-    // do nothing if contents is not files
-    if (!dataTransfer.types.includes('Files')) {
-      return;
-    }
-
-    this.setState({ dropzoneActive: true });
-  }
-
-  dragLeaveHandler() {
-    this.setState({ dropzoneActive: false });
-  }
-
-  dropHandler(accepted, rejected) {
-    // rejected
-    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
-      this.setState({ dropzoneActive: false });
-      return;
-    }
-
-    const file = accepted[0];
-    this.dispatchUpload(file);
-    this.setState({ isUploading: true });
-  }
-
-  showMarkdownHelp() {
-    this.setState({ isCheatsheetModalShown: true });
-  }
-
-  addAttachmentHandler() {
-    this.dropzone.open();
-  }
-
-  getDropzoneClassName(isDragAccept, isDragReject) {
-    let className = 'dropzone';
-    if (!this.props.isUploadable) {
-      className += ' dropzone-unuploadable';
-    }
-    else {
-      className += ' dropzone-uploadable';
-
-      if (this.props.isUploadableFile) {
-        className += ' dropzone-uploadablefile';
-      }
-    }
-
-    // uploading
-    if (this.state.isUploading) {
-      className += ' dropzone-uploading';
-    }
-
-    if (isDragAccept) {
-      className += ' dropzone-accepted';
-    }
-
-    if (isDragReject) {
-      className += ' dropzone-rejected';
-    }
-
-    return className;
-  }
-
-  renderDropzoneOverlay() {
-    return (
-      <div className="overlay overlay-dropzone-active">
-        {this.state.isUploading
-          && (
-            <span className="overlay-content">
-              <div className="speeding-wheel d-inline-block"></div>
-              <span className="sr-only">Uploading...</span>
-            </span>
-          )
-        }
-        {!this.state.isUploading && <span className="overlay-content"></span>}
-      </div>
-    );
-  }
-
-  renderNavbar() {
-    return (
-      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
-        <ul className="pl-2 nav nav-navbar">
-          { this.getNavbarItems() != null && this.getNavbarItems().map((item, idx) => {
-            // eslint-disable-next-line react/no-array-index-key
-            return <li key={`navbarItem-${idx}`}>{item}</li>;
-          }) }
-        </ul>
-      </div>
-    );
-  }
-
-  getNavbarItems() {
-    // set navbar items(react elements) here that are common in CodeMirrorEditor or TextAreaEditor
-    const navbarItems = [];
-
-    // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-    return navbarItems.concat(this.getEditorSubstance().getNavbarItems());
-  }
-
-  renderCheatsheetModal() {
-    const hideCheatsheetModal = () => {
-      this.setState({ isCheatsheetModalShown: false });
-    };
-
-    return (
-      <Modal isOpen={this.state.isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
-        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
-          <i className="icon-fw icon-question" />Markdown help
-        </ModalHeader>
-        <ModalBody>
-          <Cheatsheet />
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-
-  render() {
-    const flexContainer = {
-      height: '100%',
-      display: 'flex',
-      flexDirection: 'column',
-    };
-
-    const {
-      isMobile,
-      indentSize,
-    } = this.props;
-
-    return (
-      <>
-        <div style={flexContainer} className="editor-container">
-          <Dropzone
-            ref={(c) => { this.dropzone = c }}
-            accept={this.getAcceptableType()}
-            noClick
-            noKeyboard
-            multiple={false}
-            onDragLeave={this.dragLeaveHandler}
-            onDrop={this.dropHandler}
-          >
-            {({
-              getRootProps,
-              getInputProps,
-              isDragAccept,
-              isDragReject,
-            }) => {
-              return (
-                <div className={this.getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
-                  { this.state.dropzoneActive && this.renderDropzoneOverlay() }
-
-                  { this.state.isComponentDidMount && this.renderNavbar() }
-
-                  {/* for PC */}
-                  { !isMobile && (
-                    // eslint-disable-next-line arrow-body-style
-                    <CodeMirrorEditor
-                      ref={(c) => { this.cmEditor = c }}
-                      indentSize={indentSize}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      onMarkdownHelpButtonClicked={this.showMarkdownHelp}
-                      onAddAttachmentButtonClicked={this.addAttachmentHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  {/* for mobile */}
-                  { isMobile && (
-                    <TextAreaEditor
-                      ref={(c) => { this.taEditor = c }}
-                      onPasteFiles={this.pasteFilesHandler}
-                      onDragEnter={this.dragEnterHandler}
-                      {...this.props}
-                    />
-                  )}
-
-                  <input {...getInputProps()} />
-                </div>
-              );
-            }}
-          </Dropzone>
-
-          { this.props.isUploadable
-            && (
-              <button
-                type="button"
-                className="btn btn-outline-secondary btn-block btn-open-dropzone"
-                onClick={this.addAttachmentHandler}
-              >
-                <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
-                Attach files
-                <span className="d-none d-sm-inline">
-                &nbsp;by dragging &amp; dropping,&nbsp;
-                  <span className="btn-link">selecting them</span>,&nbsp;
-                  or pasting from the clipboard.
-                </span>
-
-              </button>
-            )
-          }
-
-          { this.renderCheatsheetModal() }
-
-        </div>
-      </>
-    );
-  }
-
-}
-
-Editor.propTypes = Object.assign({
-  noCdn: PropTypes.bool,
-  // this value is markdown
-  value: PropTypes.string,
-  isMobile: PropTypes.bool,
-  isUploadable: PropTypes.bool,
-  isUploadableFile: PropTypes.bool,
-  onChange: PropTypes.func,
-  onUpload: PropTypes.func,
-  editorSettings: PropTypes.object.isRequired,
-  indentSize: PropTypes.number,
-}, AbstractEditor.propTypes);
-
-
-const EditorWrapper = React.forwardRef((props, ref) => {
-  const { data: editorSettings } = useEditorSettings();
-  const { data: defaultIndentSize } = useDefaultIndentSize();
-
-  if (editorSettings == null) {
-    return <></>;
-  }
-
-  return (
-    <Editor
-      ref={ref}
-      {...props}
-      editorSettings={editorSettings}
-      // eslint-disable-next-line react/prop-types
-      indentSize={props.indentSize ?? defaultIndentSize}
-    />
-  );
-});
-
-EditorWrapper.displayName = 'EditorWrapper';
-
-export default EditorWrapper;

+ 363 - 0
packages/app/src/components/PageEditor/Editor.tsx

@@ -0,0 +1,363 @@
+import React, {
+  useState, useRef, useImperativeHandle, useCallback, useMemo,
+} from 'react';
+
+import Dropzone from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { toastError } from '~/client/util/apiNotification';
+import { useDefaultIndentSize } from '~/stores/context';
+import { useEditorSettings } from '~/stores/editor';
+import { useIsMobile } from '~/stores/ui';
+
+import { IEditorMethods } from '../../interfaces/editor-methods';
+
+import Cheatsheet from './Cheatsheet';
+import CodeMirrorEditor from './CodeMirrorEditor';
+import pasteHelper from './PasteHelper';
+import TextAreaEditor from './TextAreaEditor';
+
+
+type EditorPropsType = {
+  value?: string,
+  isGfmMode?: boolean,
+  noCdn?: boolean,
+  isUploadable?: boolean,
+  isUploadableFile?: boolean,
+  onChange?: () => void,
+  onUpload?: (file) => void,
+  indentSize?: number,
+  onScrollCursorIntoView?: (line: number) => void,
+  onSave?: () => Promise<void>,
+  onPasteFiles?: (event: Event) => void,
+  onCtrlEnter?: (event: Event) => void,
+}
+
+type DropzoneRef = {
+  open: () => void
+}
+
+const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+  const {
+    onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
+  } = props;
+
+  const [dropzoneActive, setDropzoneActive] = useState(false);
+  const [isUploading, setIsUploading] = useState(false);
+  const [isCheatsheetModalShown, setIsCheatsheetModalShown] = useState(false);
+
+  const { t } = useTranslation();
+  const { data: editorSettings } = useEditorSettings();
+  const { data: defaultIndentSize } = useDefaultIndentSize();
+  const { data: isMobile } = useIsMobile();
+
+  const dropzoneRef = useRef<DropzoneRef>(null);
+  const cmEditorRef = useRef<CodeMirrorEditor>(null);
+  const taEditorRef = useRef<TextAreaEditor>(null);
+
+  const editorSubstance = isMobile ? taEditorRef.current : cmEditorRef.current;
+
+  const methods: Partial<IEditorMethods> = useMemo(() => {
+    return {
+      forceToFocus: () => {
+        if (editorSubstance == null) { return }
+        editorSubstance.forceToFocus();
+      },
+      setValue: (newValue: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setValue(newValue);
+      },
+      setGfmMode: (bool: boolean) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setGfmMode(bool);
+      },
+      setCaretLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setCaretLine(line);
+      },
+      setScrollTopByLine: (line: number) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.setScrollTopByLine(line);
+      },
+      insertText: (text: string) => {
+        if (editorSubstance == null) { return }
+        editorSubstance.insertText(text);
+      },
+      getNavbarItems: (): JSX.Element[] => {
+        if (editorSubstance == null) { return [] }
+        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
+        const navbarItems = editorSubstance.getNavbarItems() ?? [];
+        return navbarItems;
+      },
+    };
+  }, [editorSubstance]);
+
+  // methods for ref
+  useImperativeHandle(ref, () => ({
+    forceToFocus: methods.forceToFocus,
+    setValue: methods.setValue,
+    setGfmMode: methods.setGfmMode,
+    setCaretLine: methods.setCaretLine,
+    setScrollTopByLine: methods.setScrollTopByLine,
+    insertText: methods.insertText,
+    /**
+   * remove overlay and set isUploading to false
+   */
+    terminateUploadingState: () => {
+      setDropzoneActive(false);
+      setIsUploading(false);
+    },
+  }));
+
+  /**
+   * dispatch onUpload event
+   */
+  const dispatchUpload = useCallback((files) => {
+    if (onUpload != null) {
+      onUpload(files);
+    }
+  }, [onUpload]);
+
+  /**
+   * get acceptable(uploadable) file type
+   */
+  const getAcceptableType = useCallback(() => {
+    let accept = 'null'; // reject all
+    if (isUploadable) {
+      if (!isUploadableFile) {
+        accept = 'image/*'; // image only
+      }
+      else {
+        accept = ''; // allow all
+      }
+    }
+
+    return accept;
+  }, [isUploadable, isUploadableFile]);
+
+  const pasteFilesHandler = useCallback((event) => {
+    const items = event.clipboardData.items || event.clipboardData.files || [];
+
+    toastError(t('toaster.file_upload_failed'));
+
+    // abort if length is not 1
+    if (items.length < 1) {
+      return;
+    }
+
+    for (let i = 0; i < items.length; i++) {
+      try {
+        const file = items[i].getAsFile();
+        // check file type (the same process as Dropzone)
+        if (file != null && pasteHelper.isAcceptableType(file, getAcceptableType())) {
+          dispatchUpload(file);
+          setIsUploading(true);
+        }
+      }
+      catch (e) {
+        toastError(t('toaster.file_upload_failed'));
+      }
+    }
+  }, [dispatchUpload, getAcceptableType, t]);
+
+  const dragEnterHandler = useCallback((event) => {
+    const dataTransfer = event.dataTransfer;
+
+    // do nothing if contents is not files
+    if (!dataTransfer.types.includes('Files')) {
+      return;
+    }
+
+    setDropzoneActive(true);
+  }, []);
+
+  const dropHandler = useCallback((accepted) => {
+    // rejected
+    if (accepted.length !== 1) { // length should be 0 or 1 because `multiple={false}` is set
+      setDropzoneActive(false);
+      return;
+    }
+
+    const file = accepted[0];
+    dispatchUpload(file);
+    setIsUploading(true);
+  }, [dispatchUpload]);
+
+  const addAttachmentHandler = useCallback(() => {
+    if (dropzoneRef.current == null) { return }
+    dropzoneRef.current.open();
+  }, []);
+
+  const getDropzoneClassName = useCallback((isDragAccept: boolean, isDragReject: boolean) => {
+    let className = 'dropzone';
+    if (!isUploadable) {
+      className += ' dropzone-unuploadable';
+    }
+    else {
+      className += ' dropzone-uploadable';
+
+      if (isUploadableFile) {
+        className += ' dropzone-uploadablefile';
+      }
+    }
+
+    // uploading
+    if (isUploading) {
+      className += ' dropzone-uploading';
+    }
+
+    if (isDragAccept) {
+      className += ' dropzone-accepted';
+    }
+
+    if (isDragReject) {
+      className += ' dropzone-rejected';
+    }
+
+    return className;
+  }, [isUploadable, isUploading, isUploadableFile]);
+
+  const renderDropzoneOverlay = useCallback(() => {
+    return (
+      <div className="overlay overlay-dropzone-active">
+        {isUploading
+          && (
+            <span className="overlay-content">
+              <div className="speeding-wheel d-inline-block"></div>
+              <span className="sr-only">Uploading...</span>
+            </span>
+          )
+        }
+        {!isUploading && <span className="overlay-content"></span>}
+      </div>
+    );
+  }, [isUploading]);
+
+  const renderNavbar = useCallback(() => {
+    return (
+      <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
+        <ul className="pl-2 nav nav-navbar">
+          { methods.getNavbarItems?.().map((item, idx) => {
+            // eslint-disable-next-line react/no-array-index-key
+            return <li key={`navbarItem-${idx}`}>{item}</li>;
+          }) }
+        </ul>
+      </div>
+    );
+  }, [methods]);
+
+  const renderCheatsheetModal = useCallback(() => {
+    const hideCheatsheetModal = () => {
+      setIsCheatsheetModalShown(false);
+    };
+
+    return (
+      <Modal isOpen={isCheatsheetModalShown} toggle={hideCheatsheetModal} className="modal-gfm-cheatsheet">
+        <ModalHeader tag="h4" toggle={hideCheatsheetModal} className="bg-primary text-light">
+          <i className="icon-fw icon-question" />Markdown help
+        </ModalHeader>
+        <ModalBody>
+          <Cheatsheet />
+        </ModalBody>
+      </Modal>
+    );
+  }, [isCheatsheetModalShown]);
+
+  if (editorSettings == null) {
+    return <></>;
+  }
+
+  const flexContainer: React.CSSProperties = {
+    height: '100%',
+    display: 'flex',
+    flexDirection: 'column',
+  };
+
+  return (
+    <>
+      <div style={flexContainer} className="editor-container">
+        <Dropzone
+          ref={dropzoneRef}
+          accept={getAcceptableType()}
+          noClick
+          noKeyboard
+          multiple={false}
+          onDragLeave={() => { setDropzoneActive(false) }}
+          onDrop={dropHandler}
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            isDragAccept,
+            isDragReject,
+          }) => {
+            return (
+              <div className={getDropzoneClassName(isDragAccept, isDragReject)} {...getRootProps()}>
+                { dropzoneActive && renderDropzoneOverlay() }
+
+                { renderNavbar() }
+
+                {/* for PC */}
+                { !isMobile && (
+                  // eslint-disable-next-line arrow-body-style
+                  <CodeMirrorEditor
+                    ref={cmEditorRef}
+                    indentSize={indentSize ?? defaultIndentSize}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    onMarkdownHelpButtonClicked={() => { setIsCheatsheetModalShown(true) }}
+                    onAddAttachmentButtonClicked={addAttachmentHandler}
+                    editorSettings={editorSettings}
+                    isGfmMode={isGfmMode}
+                    {...props}
+                  />
+                )}
+
+                {/* for mobile */}
+                { isMobile && (
+                  <TextAreaEditor
+                    ref={taEditorRef}
+                    onPasteFiles={pasteFilesHandler}
+                    onDragEnter={dragEnterHandler}
+                    {...props}
+                  />
+                )}
+
+                <input {...getInputProps()} />
+              </div>
+            );
+          }}
+        </Dropzone>
+
+        { isUploadable
+          && (
+            <button
+              type="button"
+              className="btn btn-outline-secondary btn-block btn-open-dropzone"
+              onClick={addAttachmentHandler}
+            >
+              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              Attach files
+              <span className="d-none d-sm-inline">
+              &nbsp;by dragging &amp; dropping,&nbsp;
+                <span className="btn-link">selecting them</span>,&nbsp;
+                or pasting from the clipboard.
+              </span>
+
+            </button>
+          )
+        }
+
+        { renderCheatsheetModal() }
+
+      </div>
+    </>
+  );
+});
+
+Editor.displayName = 'Editor';
+
+export default Editor;

+ 1 - 8
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,10 +1,8 @@
 import React, { useCallback, useState, useEffect } from 'react';
 
-import PropTypes from 'prop-types';
 import { Collapse, Button } from 'reactstrap';
 
 
-import EditorContainer from '~/client/services/EditorContainer';
 import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
@@ -14,7 +12,6 @@ import {
 import SavePageControls from '../SavePageControls';
 import SlackLogo from '../SlackLogo';
 import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import OptionsSelector from './OptionsSelector';
@@ -152,8 +149,4 @@ const EditorNavbarBottom = (props) => {
   );
 };
 
-EditorNavbarBottom.propTypes = {
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-};
-
-export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer]);
+export default EditorNavbarBottom;

+ 6 - 69
packages/app/src/components/PageEditor/Preview.tsx

@@ -1,17 +1,16 @@
 import React, {
-  useCallback, useEffect, useMemo, useState, SyntheticEvent, RefObject,
+  SyntheticEvent, RefObject,
 } from 'react';
 
-import InterceptorManager from '~/services/interceptor-manager';
+import ReactMarkdown from 'react-markdown';
+
+
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useEditorSettings } from '~/stores/editor';
 
 import RevisionBody from '../Page/RevisionBody';
 
 
-declare const interceptorManager: InterceptorManager;
-
-
 type Props = {
   rendererOptions: RendererOptions,
   markdown?: string,
@@ -27,59 +26,8 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
     markdown, pagePath,
   } = props;
 
-  const [html, setHtml] = useState('');
-
   const { data: editorSettings } = useEditorSettings();
 
-  const context = useMemo(() => {
-    return {
-      markdown,
-      pagePath,
-      renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
-      currentPathname: decodeURIComponent(window.location.pathname),
-      parsedHTML: null,
-    };
-  }, [markdown, pagePath, editorSettings?.renderDrawioInRealtime]);
-
-  const renderPreview = useCallback(async() => {
-
-    // TODO: use ReactMarkdown
-
-    // if (interceptorManager != null) {
-    //   await interceptorManager.process('preRenderPreview', context);
-    //   await interceptorManager.process('prePreProcess', context);
-    //   context.markdown = rendererOptions.preProcess(context.markdown, context);
-    //   await interceptorManager.process('postPreProcess', context);
-    //   context.parsedHTML = rendererOptions.process(context.markdown, context);
-    //   await interceptorManager.process('prePostProcess', context);
-    //   context.parsedHTML = rendererOptions.postProcess(context.parsedHTML, context);
-    //   await interceptorManager.process('postPostProcess', context);
-    //   await interceptorManager.process('preRenderPreviewHtml', context);
-    // }
-
-    // setHtml(context.parsedHTML ?? '');
-  }, [context, rendererOptions]);
-
-  useEffect(() => {
-    if (markdown == null) {
-      setHtml('');
-    }
-
-    renderPreview();
-  }, [markdown, renderPreview]);
-
-  useEffect(() => {
-    if (html == null) {
-      return;
-    }
-
-    if (interceptorManager != null) {
-      interceptorManager.process('postRenderPreviewHtml', {
-        ...context,
-        parsedHTML: html,
-      });
-    }
-  }, [context, html]);
 
   return (
     <div
@@ -91,11 +39,7 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
         }
       }}
     >
-      <RevisionBody
-        {...props}
-        html={html}
-        renderMathJaxInRealtime={editorSettings?.renderMathJaxInRealtime}
-      />
+      <ReactMarkdown {...rendererOptions} >{markdown || ''}</ReactMarkdown>
     </div>
   );
 
@@ -103,11 +47,4 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
 
 Preview.displayName = 'Preview';
 
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PreviewWrapper = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
-  return <Preview ref={ref} {...props} />;
-});
-
-PreviewWrapper.displayName = 'PreviewWrapper';
-
-export default PreviewWrapper;
+export default Preview;

+ 5 - 7
packages/app/src/components/PageEditorByHackmd.jsx

@@ -13,7 +13,7 @@ import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -456,9 +456,7 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
-  const { data: grant } = useSelectedGrant();
-  const { data: grantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: grantData } = useSelectedGrant();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   if (editorMode == null) {
@@ -473,9 +471,9 @@ const PageEditorByHackmdWrapper = (props) => {
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannelsData.toString()}
       pageTags={pageTags}
-      grant={grant}
-      grantGroupId={grantGroupId}
-      grantGroupName={grantGroupName}
+      grant={grantData.grant}
+      grantGroupId={grantData.grantGroup?.id}
+      grantGroupName={grantData.grantedGroup?.name}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
   );

+ 1 - 1
packages/app/src/components/PageList/PageListItemL.tsx

@@ -99,7 +99,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const lastUpdateDate = format(new Date(pageData.updatedAt), 'yyyy/MM/dd HH:mm:ss');
 
   useEffect(() => {
-    if (isIPageInfoForEntity(pageInfo) && pageInfo != null) {
+    if (isIPageInfoForEntity(pageInfo)) {
       // likerCount
       setLikerCount(pageInfo.likerIds?.length ?? 0);
       // bookmarkCount

+ 5 - 1
packages/app/src/components/PagePathNav.tsx

@@ -3,6 +3,8 @@ import React, { FC } from 'react';
 import { DevidedPagePath } from '@growi/core';
 import dynamic from 'next/dynamic';
 
+import { useIsNotFound } from '~/stores/context';
+
 import LinkedPagePath from '../models/linked-page-path';
 
 import PagePathHierarchicalLink from './PagePathHierarchicalLink';
@@ -21,6 +23,8 @@ const PagePathNav: FC<Props> = (props: Props) => {
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
+  const { data: isNotFound } = useIsNotFound();
+
   const CopyDropdown = dynamic(() => import('./Page/CopyDropdown'), { ssr: false });
 
   let formerLink;
@@ -47,7 +51,7 @@ const PagePathNav: FC<Props> = (props: Props) => {
       {formerLink}
       <span className="d-flex align-items-center">
         <h1 className="m-0">{latterLink}</h1>
-        { pageId != null && (
+        { pageId != null && !isNotFound && (
           <div className="mx-2">
             <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName={copyDropdownToggleClassName}>
               <i className="ti ti-clipboard"></i>

+ 35 - 32
packages/app/src/components/SavePageControls.jsx

@@ -7,12 +7,12 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import PageContainer from '~/client/services/PageContainer';
+// import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
 import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -43,7 +43,7 @@ class SavePageControls extends React.Component {
 
   async save() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, /* pageContainer, */ pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     // disable unsaved warning
     mutateIsEnabledUnsavedWarning(false);
@@ -51,25 +51,25 @@ class SavePageControls extends React.Component {
     try {
       // save
       const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-      await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
+      // await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     catch (error) {
       logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
+      // pageContainer.showErrorToastr(error);
       if (error.code === 'conflict') {
-        pageContainer.setState({
-          remoteRevisionId: error.data.revisionId,
-          remoteRevisionBody: error.data.revisionBody,
-          remoteRevisionUpdateAt: error.data.createdAt,
-          lastUpdateUser: error.data.user,
-        });
+        // pageContainer.setState({
+        //   remoteRevisionId: error.data.revisionId,
+        //   remoteRevisionBody: error.data.revisionBody,
+        //   remoteRevisionUpdateAt: error.data.createdAt,
+        //   lastUpdateUser: error.data.user,
+        // });
       }
     }
   }
 
   saveAndOverwriteScopesOfDescendants() {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, /* pageContainer, */ pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     // disable unsaved warning
     mutateIsEnabledUnsavedWarning(false);
@@ -78,18 +78,18 @@ class SavePageControls extends React.Component {
     const optionsToSave = Object.assign(currentOptionsToSave, {
       overwriteScopesOfDescendants: true,
     });
-    pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
+    // pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
   }
 
   render() {
 
     const {
-      t, pageContainer, isAclEnabled, grant, grantGroupId, grantGroupName,
+      t, /* pageContainer, */ isAclEnabled, grant, grantGroupId, grantGroupName,
     } = this.props;
 
-    const isRootPage = pageContainer.state.path === '/';
-    const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
-    const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
+    // const isRootPage = pageContainer.state.path === '/';
+    // const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
+    // const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
     return (
       <div className="d-flex align-items-center form-inline flex-nowrap">
@@ -98,7 +98,7 @@ class SavePageControls extends React.Component {
           && (
             <div className="mr-2">
               <GrantSelector
-                disabled={isRootPage}
+                // disabled={isRootPage}
                 grant={grant}
                 grantGroupId={grantGroupId}
                 grantGroupName={grantGroupName}
@@ -109,11 +109,15 @@ class SavePageControls extends React.Component {
         }
 
         <UncontrolledButtonDropdown direction="up">
-          <Button id="caret" color="primary" className="btn-submit" onClick={this.save}>{labelSubmitButton}</Button>
+          <Button id="caret" color="primary" className="btn-submit" onClick={this.save}>
+          labelSubmitButton
+            {/* {labelSubmitButton} */}
+          </Button>
           <DropdownToggle caret color="primary" />
           <DropdownMenu right>
             <DropdownItem onClick={this.saveAndOverwriteScopesOfDescendants}>
-              {labelOverwriteScopes}
+            labelOverwriteScopes
+              {/* {labelOverwriteScopes} */}
             </DropdownItem>
           </DropdownMenu>
         </UncontrolledButtonDropdown>
@@ -127,22 +131,20 @@ class SavePageControls extends React.Component {
 /**
  * Wrapper component for using unstated
  */
-const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
+// const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
 
 const SavePageControlsWrapper = (props) => {
   const { t } = useTranslation();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
-  const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
-  if (isEditable == null || editorMode == null || isAclEnabled == null) {
+  if (isEditable == null || editorMode == null || isAclEnabled == null || grantData == null) {
     return null;
   }
 
@@ -151,17 +153,18 @@ const SavePageControlsWrapper = (props) => {
   }
 
   return (
-    <SavePageControlsHOCWrapper
+    // <SavePageControlsHOCWrapper
+    <SavePageControls
       t={t}
       {...props}
       editorMode={editorMode}
       isAclEnabled={isAclEnabled}
-      grant={grant}
-      grantGroupId={grantGroupId}
-      grantGroupName={grantGroupName}
+      grant={grantData.grant}
+      grantGroupId={grantData.grantGroup?.id}
+      grantGroupName={grantData.grantedGroup?.name}
       mutateGrant={mutateGrant}
-      mutateGrantGroupId={mutateGrantGroupId}
-      mutateGrantGroupName={mutateGrantGroupName}
+      // mutateGrantGroupId={mutateGrantGroupId}
+      // mutateGrantGroupName={mutateGrantGroupName}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       pageTags={pageTags}
     />
@@ -171,7 +174,7 @@ const SavePageControlsWrapper = (props) => {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,

+ 2 - 1
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -20,7 +20,7 @@ import { useFullTextSearchTermManager } from '~/stores/search';
 
 
 import AppContainer from '../../client/services/AppContainer';
-import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType } from '../Common/Dropdown/PageItemControl';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
@@ -175,6 +175,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       ? page.revision
       : page.revision._id;
 
+
     return (
       <div className="d-flex flex-column align-items-end justify-content-center py-md-2">
         <SubNavButtons

+ 0 - 1
packages/app/src/components/SearchPage/SearchResultList.tsx

@@ -125,7 +125,6 @@ const SearchResultListSubstance: ForwardRefRenderFunction<ISelectableAll, Props>
     advanceFts();
   };
 
-
   return (
     <ul data-testid="search-result-list" className="page-list-ul list-group list-group-flush">
       { (injectedPages ?? pages).map((page, i) => {

+ 2 - 2
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -21,7 +21,7 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
-
+import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
 import CountBadge from '../../Common/CountBadge';
@@ -411,7 +411,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
 
   // Rename process
   // Icon that draw attention from users for some actions
-  const shouldShowAttentionIcon = !!page.processData?.Rename?.isProcessable;
+  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
   return (
     <div

+ 2 - 0
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -17,8 +17,10 @@ import FormattedDistanceDate from '../FormattedDistanceDate';
 
 import InfiniteScroll from './InfiniteScroll';
 
+import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import styles from './RecentChanges.module.scss';
 
+
 const logger = loggerFactory('growi:History');
 
 type PageItemProps = {

+ 2 - 9
packages/app/src/components/Skelton.tsx

@@ -1,24 +1,17 @@
 import React from 'react';
 
 type SkeltonProps = {
-  width?: number,
-  height?: number,
   additionalClass?: string,
   roundedPill?: boolean,
 }
 
 export const Skelton = (props: SkeltonProps): JSX.Element => {
   const {
-    width, height, additionalClass, roundedPill,
+    additionalClass, roundedPill,
   } = props;
 
-  const skeltonStyle = {
-    width,
-    height,
-  };
-
   return (
-    <div style={skeltonStyle} className={`${additionalClass}`}>
+    <div className={`${additionalClass}`}>
       <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
     </div>
   );

+ 11 - 0
packages/app/src/components/TableOfContents.module.scss

@@ -1,3 +1,4 @@
+@use '~/styles/bootstrap/init' as bs;
 
 .revision-toc :global {
   // to get on the Attachment row
@@ -26,3 +27,13 @@
     }
   }
 }
+
+.revision-toc {
+  @media print {
+    float: none;
+    max-width: 100%;
+    margin-bottom: 20px;
+    font-size: 0.9em;
+    border: solid 1px bs.$gray-400;
+  }
+}

+ 19 - 13
packages/app/src/components/TableOfContents.tsx

@@ -11,6 +11,10 @@ import loggerFactory from '~/utils/logger';
 
 import { StickyStretchableScroller } from './StickyStretchableScroller';
 
+
+import styles from './TableOfContents.module.scss';
+
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 
@@ -53,20 +57,22 @@ const TableOfContents = (): JSX.Element => {
   }, [tocHtml]);
 
   return (
-    <StickyStretchableScroller
-      stickyElemSelector=".grw-side-contents-sticky-container"
-      calcViewHeight={calcViewHeight}
-    >
-      <div
-        id="revision-toc-content"
-        className="revision-toc-content mb-3"
+    <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>
+      <StickyStretchableScroller
+        stickyElemSelector=".grw-side-contents-sticky-container"
+        calcViewHeight={calcViewHeight}
       >
-        {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}
-        <ReactMarkdown {...rendererOptions}>
-          {''}
-        </ReactMarkdown>
-      </div>
-    </StickyStretchableScroller>
+        <div
+          id="revision-toc-content"
+          className="revision-toc-content mb-3"
+        >
+          {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}
+          <ReactMarkdown {...rendererOptions}>
+            {''}
+          </ReactMarkdown>
+        </div>
+      </StickyStretchableScroller>
+    </div>
   );
 
 };

+ 0 - 59
packages/app/src/components/TagPage.tsx

@@ -1,59 +0,0 @@
-import React, { FC, useState, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import { IDataTagCount } from '~/interfaces/tag';
-import { useSWRxTagsList } from '~/stores/tag';
-
-import TagCloudBox from './TagCloudBox';
-import TagList from './TagList';
-
-const PAGING_LIMIT = 10;
-
-const TagPage: FC = () => {
-  const [activePage, setActivePage] = useState<number>(1);
-  const [offset, setOffset] = useState<number>(0);
-
-  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: IDataTagCount[] = tagDataList?.data || [];
-  const totalCount: number = tagDataList?.totalCount || 0;
-  const isLoading = tagDataList === undefined && error == null;
-
-  const { t } = useTranslation('');
-
-  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
-    setActivePage(selectedPageNumber);
-    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
-  }, []);
-
-  // todo: adjust margin and redesign tags page
-  return (
-    <div className="grw-container-convertible mb-5 pb-5">
-      <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
-      <div className="px-3 mb-5 text-center">
-        <TagCloudBox tags={tagData} minSize={20} />
-      </div>
-      { isLoading
-        ? (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-          </div>
-        )
-        : (
-          <div data-testid="grw-tags-list">
-            <TagList
-              tagData={tagData}
-              totalTags={totalCount}
-              activePage={activePage}
-              onChangePage={setOffsetByPageNumber}
-              pagingLimit={PAGING_LIMIT}
-            />
-          </div>
-        )
-      }
-    </div>
-  );
-
-};
-
-export default TagPage;

+ 1 - 1
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -19,7 +19,7 @@ interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
   forwardedRef: Ref<UncontrolledCodeMirrorCore>;
 }
 
-class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+export class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
 
   override render(): ReactNode {
 

+ 1 - 0
packages/app/src/components/UnstatedUtils.tsx

@@ -4,6 +4,7 @@ import React from 'react';
 
 import { Provider, Subscribe } from 'unstated';
 
+
 /**
  * generate K/V object by specified instances
  *

+ 21 - 0
packages/app/src/interfaces/customize.ts

@@ -0,0 +1,21 @@
+const IHighlightJsCssSelectorThemes = {
+  GITHUB: 'github',
+  GITHUB_GIST: 'github-gist',
+  ATOM_ONE_LIGHT: 'atom-one-light',
+  XCIDE: 'xcode',
+  VS: 'vs',
+  ATOM_ONE_DARK: 'atom-one-dark',
+  HYBRID: 'hybrid',
+  MONOKAI: 'monokai',
+  TOMMORROW_NIGHT: 'tomorrow-night',
+  VS_2015: 'vs2015',
+} as const;
+
+type IHighlightJsCssSelectorThemes = typeof IHighlightJsCssSelectorThemes[keyof typeof IHighlightJsCssSelectorThemes];
+
+export type IHighlightJsCssSelectorOptions = {
+  [theme in IHighlightJsCssSelectorThemes]: {
+    name: string,
+    border: boolean
+  }
+}

+ 17 - 0
packages/app/src/interfaces/editor-methods.ts

@@ -0,0 +1,17 @@
+export interface IEditorMethods {
+  forceToFocus: () => void,
+  setValue: (newValue: string) => void,
+  setGfmMode: (bool: boolean) => void,
+  setCaretLine: (line: number) => void,
+  setScrollTopByLine: (line: number) => void,
+  getStrFromBol(): void,
+  getStrToEol: () => void,
+  getStrFromBolToSelectedUpperPos: () => void,
+  replaceBolToCurrentPos: (text: string) => void,
+  replaceLine: (text: string) => void,
+  insertText: (text: string) => void,
+  insertLinebreak: () => void,
+  dispatchSave: () => void,
+  dispatchPasteFiles: (event: Event) => void,
+  getNavbarItems: () => JSX.Element[],
+}

+ 15 - 4
packages/app/src/interfaces/page-operation.ts

@@ -6,10 +6,21 @@ export const PageActionType = {
   Revert: 'Revert',
   NormalizeParent: 'NormalizeParent',
 } as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType]
-export type IPageOperationProcessData = Partial<{
-  [key in PageActionType]: {isProcessable: boolean}
-}>
+export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
+
+export const PageActionStage = {
+  Main: 'Main',
+  Sub: 'Sub',
+} as const;
+export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
+
+export type IPageOperationProcessData = {
+  [key in PageActionType]?: {
+    [PageActionStage.Main]?: { isProcessable: boolean },
+    [PageActionStage.Sub]?: { isProcessable: boolean },
+  }
+}
+
 export type IPageOperationProcessInfo = {
   [pageId: string]: IPageOperationProcessData,
 }

+ 4 - 0
packages/app/src/interfaces/services/renderer.ts

@@ -1,3 +1,5 @@
+import { HastNode } from 'hast-util-select';
+
 import { XssOptionConfig } from '~/services/xss/xssOption';
 
 // export type GrowiHydratedEnv = {
@@ -18,3 +20,5 @@ export type RendererConfig = {
   plantumlUri: string | null,
   blockdiagUri: string | null,
 } & XssOptionConfig;
+
+export type RehypePlugin = (option: any) => (node: HastNode) => void

+ 0 - 28
packages/app/src/interfaces/unstated-container.ts

@@ -1,28 +0,0 @@
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-
-type AdminUnstatedContainers =
-  AdminAppContainer | AdminBasicSecurityContainer | AdminCustomizeContainer | AdminExternalAccountsContainer |
-  AdminGeneralSecurityContainer | AdminGitHubSecurityContainer | AdminGoogleSecurityContainer | AdminHomeContainer |
-  AdminImportContainer | AdminLdapSecurityContainer | AdminLocalSecurityContainer | AdminMarkDownContainer |
-  AdminNotificationContainer | AdminOidcSecurityContainer | AdminSamlSecurityContainer | AdminSlackIntegrationLegacyContainer |
-  AdminTwitterSecurityContainer | AdminUserGroupDetailContainer | AdminUsersContainer;
-
-export type AdminInjectableContainers = AdminUnstatedContainers[];

+ 19 - 14
packages/app/src/pages/[[...path]].page.tsx

@@ -34,9 +34,9 @@ import { PageModel, PageDocument } from '~/server/models/page';
 import { PageRedirectModel } from '~/server/models/page-redirect';
 import UserUISettings from '~/server/models/user-ui-settings';
 import Xss from '~/services/xss';
-import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
 import {
-  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
@@ -147,7 +147,6 @@ type Props = CommonProps & {
   // noCdn: string,
   // highlightJsStyle: string,
   // isAllReplyShown: boolean,
-  // isContainerFluid: boolean,
   // editorConfig: any,
   isEnabledStaleNotification: boolean,
   // isEnabledLinebreaks: boolean,
@@ -234,20 +233,29 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
     shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
   }
 
-  useCurrentPageId(pageWithMeta?.data._id);
+  const pageId = pageWithMeta?.data._id;
+
+  useCurrentPageId(pageId);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  // useSWRxPage(pageWithMeta?.data._id);
-  useSWRxPageInfo(pageWithMeta?.data._id, undefined, pageWithMeta?.meta); // store initial data
+  useSWRxPageInfo(pageId, undefined, pageWithMeta?.meta); // store initial data
   useIsTrashPage(_isTrashPage(pageWithMeta?.data.path ?? ''));
   useIsUserPage(isUserPage(pageWithMeta?.data.path ?? ''));
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pageWithMeta?.data.path ?? '')); // TODO: need to include props.isIdentical
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPathname(props.currentPathname);
-  useEditingMarkdown(pageWithMeta?.data.revision.body);
+  useEditingMarkdown(pageWithMeta?.data.revision?.body);
+  const { data: grantData } = useSWRxIsGrantNormalized(pageId);
+  const { mutate: mutateSelectedGrant } = useSelectedGrant();
+
+  // sync grant data
+  useEffect(() => {
+    mutateSelectedGrant(grantData?.grantData.currentPageGrant);
+  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
-    if (isClient() && window.location.pathname !== props.currentPathname) {
+    const decodedURI = decodeURI(window.location.pathname);
+    if (isClient() && decodedURI !== props.currentPathname) {
       router.replace(props.currentPathname, undefined, { shallow: true });
     }
   }, [props.currentPathname, router]);
@@ -261,9 +269,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   //     classNames.push('on-edit', 'hackmd');
   //     break;
   // }
-  // if (props.isContainerFluid) {
-  //   classNames.push('growi-layout-fluid');
-  // }
   // if (page == null) {
   //   classNames.push('not-found-page');
   // }
@@ -279,7 +284,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         */}
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
         <header className="py-0">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
@@ -305,7 +310,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
                     {/* <DisplaySwitcher /> */}
                     <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                     {/* <PageStatusAlert /> */}
-                    PageStatusAlert
                   </>
                 ) }
 
@@ -428,6 +432,8 @@ async function injectRoutingInformation(context: GetServerSidePropsContext, prop
     props.isForbidden = count > 0;
   }
   else {
+    props.isNotFound = page.isEmpty;
+
     // /62a88db47fed8b2d94f30000 ==> /path/to/page
     if (isPermalink && page.isEmpty) {
       props.currentPathname = page.path;
@@ -478,7 +484,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
-  // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');

+ 91 - 19
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { isClient } from '@growi/core';
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -7,13 +8,32 @@ import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
+import { Container, Provider } from 'unstated';
 
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
+import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import PluginUtils from '~/server/plugins/plugin-utils';
 import ConfigLoader from '~/server/service/config-loader';
 import {
-  useCurrentUser, /* useSearchServiceConfigured, */ useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
+  useCurrentUser, /* useSearchServiceConfigured, */ useIsAclEnabled, useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
 } from '~/stores/context';
 
 import {
@@ -39,6 +59,7 @@ const ElasticsearchManagement = dynamic(() => import('../../components/Admin/Ela
 // named export
 const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(module => module.AuditLogManagement));
 
+
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
 
 const pluginUtils = new PluginUtils();
@@ -51,7 +72,7 @@ type Props = CommonProps & {
   yarnVersion: string,
   installedPlugins: any,
   envVars: any,
-
+  isAclEnabled: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isMailerSetup: boolean,
@@ -144,30 +165,80 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
 
+  useIsAclEnabled(props.isAclEnabled);
   useSiteUrl(props.siteUrl);
 
   // useEnvVars(props.envVars);
 
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    // Create unstated container instances (except Security)
+    const adminAppContainer = new AdminAppContainer();
+    const adminImportContainer = new AdminImportContainer();
+    const adminHomeContainer = new AdminHomeContainer();
+    const adminCustomizeContainer = new AdminCustomizeContainer();
+    const adminUsersContainer = new AdminUsersContainer();
+    const adminExternalAccountsContainer = new AdminExternalAccountsContainer();
+    const adminNotificationContainer = new AdminNotificationContainer();
+    const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
+    const adminMarkDownContainer = new AdminMarkDownContainer();
+    const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer();
+
+    injectableContainers.push(
+      adminAppContainer,
+      adminImportContainer,
+      adminHomeContainer,
+      adminCustomizeContainer,
+      adminUsersContainer,
+      adminExternalAccountsContainer,
+      adminNotificationContainer,
+      adminSlackIntegrationLegacyContainer,
+      adminMarkDownContainer,
+      adminUserGroupDetailContainer,
+    );
+  }
+
+
+  const adminSecurityContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminSecuritySettingElem = document.getElementById('admin-security-setting');
+
+    if (adminSecuritySettingElem != null) {
+      // Create unstated container instances (Security)
+      const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer();
+      const adminLocalSecurityContainer = new AdminLocalSecurityContainer();
+      const adminLdapSecurityContainer = new AdminLdapSecurityContainer();
+      const adminSamlSecurityContainer = new AdminSamlSecurityContainer();
+      const adminOidcSecurityContainer = new AdminOidcSecurityContainer();
+      const adminBasicSecurityContainer = new AdminBasicSecurityContainer();
+      const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer();
+      const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer();
+      const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer();
+
+      adminSecurityContainers.push(
+        adminGeneralSecurityContainer,
+        adminLocalSecurityContainer,
+        adminLdapSecurityContainer,
+        adminSamlSecurityContainer,
+        adminOidcSecurityContainer,
+        adminBasicSecurityContainer,
+        adminGoogleSecurityContainer,
+        adminGitHubSecurityContainer,
+        adminTwitterSecurityContainer,
+      );
+    }
 
-  const adminCustomizeContainer = new AdminCustomizeContainer();
+  }
 
-  const injectableContainers = [
-    // adminAppContainer,
-    // adminImportContainer,
-    // adminHomeContainer,
-    adminCustomizeContainer,
-    // adminUsersContainer,
-    // adminExternalAccountsContainer,
-    // adminNotificationContainer,
-    // adminSlackIntegrationLegacyContainer,
-    // adminMarkDownContainer,
-    // adminUserGroupDetailContainer,
-  ];
 
   return (
-    <AdminLayout title={title} selectedNavOpt={name} injectableContainers={injectableContainers}>
-      {content.component}
-    </AdminLayout>
+    <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
+      <AdminLayout title={title} selectedNavOpt={name}>
+        {content.component}
+      </AdminLayout>
+    </Provider>
   );
 };
 
@@ -195,7 +266,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const {
-    appService, searchService,
+    appService, searchService, aclService,
   } = crowi;
 
   const { user } = req;
@@ -221,6 +292,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
   props.installedPlugins = pluginUtils.listPlugins();
   props.envVars = await ConfigLoader.getEnvVarsForDisplay(true);
+  props.isAclEnabled = aclService.isAclEnabled();
 
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;

+ 137 - 0
packages/app/src/pages/tags.page.tsx

@@ -0,0 +1,137 @@
+import React, { useState, useCallback } from 'react';
+
+import {
+  IUser, IUserHasId,
+} from '@growi/core';
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import Head from 'next/head';
+
+import TagCloudBox from '~/components/TagCloudBox';
+import TagList from '~/components/TagList';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { IDataTagCount } from '~/interfaces/tag';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import UserUISettings from '~/server/models/user-ui-settings';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import { BasicLayout } from '../components/Layout/BasicLayout';
+import {
+  useCurrentUser,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable,
+  useIsSearchScopeChildrenAsDefault,
+} from '../stores/context';
+
+import {
+  CommonProps, getServerSideCommonProps, useCustomTitle,
+} from './utils/commons';
+
+const PAGING_LIMIT = 10;
+
+type Props = CommonProps & {
+  currentUser: IUser,
+  isSearchServiceConfigured: boolean,
+  isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+  userUISettings?: IUserUISettings
+};
+
+const TagPage: NextPage<CommonProps> = (props: Props) => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  useCurrentUser(props.currentUser ?? null);
+  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const { t } = useTranslation('');
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+  const classNames: string[] = [];
+
+  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  return (
+    <>
+      <Head>
+      </Head>
+      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+        <div className="grw-container-convertible mb-5 pb-5">
+          <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
+          <div className="px-3 mb-5 text-center">
+            <TagCloudBox tags={tagData} minSize={20} />
+          </div>
+          { isLoading
+            ? (
+              <div className="text-muted text-center">
+                <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+              </div>
+            )
+            : (
+              <div data-testid="grw-tags-list">
+                <TagList
+                  tagData={tagData}
+                  totalTags={totalCount}
+                  activePage={activePage}
+                  onChangePage={setOffsetByPageNumber}
+                  pagingLimit={PAGING_LIMIT}
+                />
+              </div>
+            )
+          }
+        </div>
+      </BasicLayout>
+    </>
+  );
+};
+
+async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
+
+  if (userUISettings != null) {
+    props.userUISettings = userUISettings.toObject();
+  }
+}
+
+function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    searchService, configManager,
+  } = crowi;
+
+  props.isSearchServiceConfigured = searchService.isConfigured;
+  props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+  const result = await getServerSideCommonProps(context);
+
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+  await injectUserUISettings(context, props);
+  injectServerConfigurations(context, props);
+
+  return {
+    props,
+  };
+};
+
+export default TagPage;

+ 2 - 0
packages/app/src/pages/utils/commons.ts

@@ -17,6 +17,7 @@ export type CommonProps = {
   theme: GrowiThemes,
   customTitleTemplate: string,
   csrfToken: string,
+  isContainerFluid: boolean,
   growiVersion: string,
 } & Partial<SSRConfig>;
 
@@ -41,6 +42,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     theme: configManager.getConfig('crowi', 'customize:theme'),
     customTitleTemplate: customizeService.customTitleTemplate,
     csrfToken: req.csrfToken(),
+    isContainerFluid: configManager.getConfig('crowi', 'customize:isContainerFluid') ?? false,
     growiVersion: crowi.version,
   };
 

+ 4 - 1
packages/app/src/server/models/obsolete-page.js

@@ -683,6 +683,7 @@ export const getPageSchema = (crowi) => {
     const Revision = crowi.model('Revision');
     const format = options.format || 'markdown';
     const grantUserGroupId = options.grantUserGroupId || null;
+    const expandContentWidth = crowi.configManager.getConfig('crowi', 'customize:isContainerFluid');
 
     // sanitize path
     path = crowi.xss.process(path); // eslint-disable-line no-param-reassign
@@ -704,7 +705,9 @@ export const getPageSchema = (crowi) => {
     page.creator = user;
     page.lastUpdateUser = user;
     page.status = STATUS_PUBLISHED;
-
+    if (expandContentWidth != null) {
+      page.expandContentWidth = expandContentWidth;
+    }
     await validateAppliedScope(user, grant, grantUserGroupId);
     page.applyScope(user, grant, grantUserGroupId);
 

+ 1 - 16
packages/app/src/server/models/page-operation.ts

@@ -3,6 +3,7 @@ import mongoose, {
   Schema, Model, Document, QueryOptions, FilterQuery,
 } from 'mongoose';
 
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
 } from '~/server/models/interfaces/page-operation';
@@ -19,22 +20,6 @@ const logger = loggerFactory('growi:models:page-operation');
 type IObjectId = mongoose.Types.ObjectId;
 const ObjectId = mongoose.Schema.Types.ObjectId;
 
-export const PageActionType = {
-  Rename: 'Rename',
-  Duplicate: 'Duplicate',
-  Delete: 'Delete',
-  DeleteCompletely: 'DeleteCompletely',
-  Revert: 'Revert',
-  NormalizeParent: 'NormalizeParent',
-} as const;
-export type PageActionType = typeof PageActionType[keyof typeof PageActionType];
-
-export const PageActionStage = {
-  Main: 'Main',
-  Sub: 'Sub',
-} as const;
-export type PageActionStage = typeof PageActionStage[keyof typeof PageActionStage];
-
 /*
  * Main Schema
  */

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů