Преглед на файлове

Merge branch 'support/apply-nextjs-2' into feat/integrate-implement-page-alert-component

yohei0125 преди 3 години
родител
ревизия
0353c2648c
променени са 100 файла, в които са добавени 1280 реда и са изтрити 1115 реда
  1. 12 0
      .eslintrc.js
  2. 46 22
      .vscode/launch.json
  3. 21 1
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 2 2
      packages/app/docker/README.md
  7. 20 3
      packages/app/next.config.js
  8. 16 28
      packages/app/package.json
  9. 2 1
      packages/app/public/static/locales/en_US/translation.json
  10. 2 1
      packages/app/public/static/locales/ja_JP/translation.json
  11. 2 1
      packages/app/public/static/locales/zh_CN/translation.json
  12. 1 6
      packages/app/src/client/app.jsx
  13. 0 0
      packages/app/src/client/legacy/thirdparty-js/waves.js
  14. 1 3
      packages/app/src/client/services/ContextExtractor.tsx
  15. 0 24
      packages/app/src/client/services/EditorContainer.js
  16. 0 172
      packages/app/src/client/services/PageHistoryContainer.js
  17. 0 113
      packages/app/src/client/services/RevisionComparerContainer.js
  18. 3 1
      packages/app/src/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.jsx
  19. 1 0
      packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  20. 3 4
      packages/app/src/components/BasicLayout.tsx
  21. 2 0
      packages/app/src/components/Common/ClosableTextInput.tsx
  22. 3 0
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  23. 2 1
      packages/app/src/components/ContentLinkButtons.tsx
  24. 9 3
      packages/app/src/components/Fab.jsx
  25. 2 0
      packages/app/src/components/ForbiddenPage.tsx
  26. 4 1
      packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx
  27. 2 0
      packages/app/src/components/Icons/GrowiLogo.jsx
  28. 7 4
      packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx
  29. 11 2
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  30. 56 0
      packages/app/src/components/Navbar/GlobalSearch.module.scss
  31. 33 27
      packages/app/src/components/Navbar/GlobalSearch.tsx
  32. 55 12
      packages/app/src/components/Navbar/GrowiNavbar.module.scss
  33. 18 8
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  34. 16 0
      packages/app/src/components/Navbar/GrowiNavbarBottom.module.scss
  35. 8 10
      packages/app/src/components/Navbar/GrowiNavbarBottom.tsx
  36. 3 1
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  37. 8 3
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  38. 6 28
      packages/app/src/components/NotFoundPage.tsx
  39. 9 6
      packages/app/src/components/Page.jsx
  40. 25 19
      packages/app/src/components/Page/DisplaySwitcher.tsx
  41. 1 0
      packages/app/src/components/Page/RenderTagLabels.tsx
  42. 2 3
      packages/app/src/components/Page/TagsInput.tsx
  43. 1 0
      packages/app/src/components/PageComment.tsx
  44. 1 0
      packages/app/src/components/PageContentFooter.tsx
  45. 18 4
      packages/app/src/components/PageEditor.tsx
  46. 2 0
      packages/app/src/components/PageEditor/Editor.jsx
  47. 4 5
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  48. 8 19
      packages/app/src/components/PageEditor/OptionsSelector.tsx
  49. 4 0
      packages/app/src/components/PageEditor/Preview.tsx
  50. 14 10
      packages/app/src/components/PageEditorByHackmd.jsx
  51. 35 59
      packages/app/src/components/PageHistory.jsx
  52. 32 33
      packages/app/src/components/PageHistory/PageRevisionTable.jsx
  53. 2 2
      packages/app/src/components/PageHistory/Revision.jsx
  54. 2 0
      packages/app/src/components/PaginationWrapper.tsx
  55. 4 0
      packages/app/src/components/PrivateLegacyPages.tsx
  56. 24 26
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  57. 16 20
      packages/app/src/components/SavePageControls.jsx
  58. 3 0
      packages/app/src/components/SearchForm.tsx
  59. 6 3
      packages/app/src/components/SearchPage.tsx
  60. 4 1
      packages/app/src/components/SearchPage/SearchControl.tsx
  61. 44 0
      packages/app/src/components/SearchTypeahead.module.scss
  62. 7 5
      packages/app/src/components/SearchTypeahead.tsx
  63. 6 4
      packages/app/src/components/ShortcutsModal.module.scss
  64. 10 7
      packages/app/src/components/ShortcutsModal.tsx
  65. 31 100
      packages/app/src/components/Sidebar.module.scss
  66. 80 74
      packages/app/src/components/Sidebar.tsx
  67. 4 1
      packages/app/src/components/Sidebar/PageTree.tsx
  68. 2 0
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  69. 69 0
      packages/app/src/components/Sidebar/SidebarNav.module.scss
  70. 6 5
      packages/app/src/components/Sidebar/SidebarNav.tsx
  71. 22 22
      packages/app/src/components/StickyStretchableScroller.tsx
  72. 6 0
      packages/app/src/components/SystemVersion.module.scss
  73. 3 1
      packages/app/src/components/SystemVersion.tsx
  74. 2 0
      packages/app/src/components/TagCloudBox.tsx
  75. 2 0
      packages/app/src/components/UncontrolledCodeMirror.tsx
  76. 0 5
      packages/app/src/interfaces/page-listing-results.ts
  77. 5 0
      packages/app/src/interfaces/revision.ts
  78. 59 0
      packages/app/src/pages/UnsavedAlertDialog.tsx
  79. 128 56
      packages/app/src/pages/[[...path]].page.tsx
  80. 0 4
      packages/app/src/server/crowi/express-init.js
  81. 5 0
      packages/app/src/server/interfaces/search.ts
  82. 12 12
      packages/app/src/server/middlewares/login-required.js
  83. 1 1
      packages/app/src/server/models/interfaces/page-operation.ts
  84. 1 1
      packages/app/src/server/models/page-operation.ts
  85. 3 3
      packages/app/src/server/routes/apiv3/forgot-password.js
  86. 2 1
      packages/app/src/server/routes/apiv3/pages.js
  87. 12 10
      packages/app/src/server/routes/index.js
  88. 1 2
      packages/app/src/server/routes/login.js
  89. 1 4
      packages/app/src/server/routes/page.js
  90. 4 0
      packages/app/src/server/service/interfaces/search.ts
  91. 25 11
      packages/app/src/server/service/page-operation.ts
  92. 56 24
      packages/app/src/server/service/page.ts
  93. 9 9
      packages/app/src/server/service/search-delegator/elasticsearch.ts
  94. 0 1
      packages/app/src/server/views/layout-growi/not_found.html
  95. 21 25
      packages/app/src/stores/context.tsx
  96. 4 0
      packages/app/src/stores/editor.tsx
  97. 21 0
      packages/app/src/stores/page.tsx
  98. 14 12
      packages/app/src/stores/ui.tsx
  99. 6 9
      packages/app/src/styles/_layout.scss
  100. 0 12
      packages/app/src/styles/_navbar.scss

+ 12 - 0
.eslintrc.js

@@ -35,6 +35,18 @@ module.exports = {
             group: 'parent',
             group: 'parent',
             position: 'before',
             position: 'before',
           },
           },
+          {
+            pattern: '*.css',
+            group: 'type',
+            patternOptions: { matchBase: true },
+            position: 'after',
+          },
+          {
+            pattern: '*.scss',
+            group: 'type',
+            patternOptions: { matchBase: true },
+            position: 'after',
+          },
         ],
         ],
         alphabetize: {
         alphabetize: {
           order: 'asc',
           order: 'asc',

+ 46 - 22
.vscode/launch.json

@@ -1,37 +1,57 @@
 {
 {
-    // IntelliSense を使用して利用可能な属性を学べます。
-    // 既存の属性の説明をホバーして表示します。
-    // 詳細情報は次を確認してください: https://go.microsoft.com/fwlink/?linkid=830387
     "version": "0.2.0",
     "version": "0.2.0",
     "configurations": [
     "configurations": [
       {
       {
-        "type": "node",
+        "type": "pwa-node",
         "request": "attach",
         "request": "attach",
         "name": "Debug: Attach Debugger to Server",
         "name": "Debug: Attach Debugger to Server",
-        "port": 9229
+        "port": 9229,
+        "cwd": "${workspaceFolder}/packages/app",
+        "sourceMapPathOverrides": {
+          "webpack://@growi/app/*": "${workspaceFolder}/packages/app/*"
+        }
       },
       },
       {
       {
-        "type": "node",
+        "type": "pwa-node",
+        "request": "launch",
+        "name": "Debug: Current File",
+        "skipFiles": [
+          "<node_internals>/**"
+        ],
+        "console": "integratedTerminal",
+        "cwd": "${fileDirname}",
+        "runtimeExecutable": "yarn",
+        "runtimeArgs": [
+          "ts-node",
+          "${file}"
+        ]
+      },
+      {
+        "type": "pwa-node",
         "request": "launch",
         "request": "launch",
         "name": "Debug: Server",
         "name": "Debug: Server",
         "cwd": "${workspaceFolder}/packages/app",
         "cwd": "${workspaceFolder}/packages/app",
-        "runtimeExecutable": "npm",
+        "runtimeExecutable": "yarn",
         "runtimeArgs": [
         "runtimeArgs": [
-          "run",
-          "dev:server"
+          "dev"
+        ],
+        "skipFiles": [
+          "<node_internals>/**"
         ],
         ],
-        "port": 9229,
         "restart": true,
         "restart": true,
         "console": "integratedTerminal",
         "console": "integratedTerminal",
-        "internalConsoleOptions": "neverOpen"
+        "internalConsoleOptions": "neverOpen",
+        "sourceMapPathOverrides": {
+          "webpack://@growi/app/*": "${workspaceFolder}/packages/app/*"
+        }
       },
       },
       {
       {
-        "type": "chrome",
+        "type": "pwa-chrome",
         "request": "launch",
         "request": "launch",
         "name": "Debug: Chrome",
         "name": "Debug: Chrome",
         "sourceMaps": true,
         "sourceMaps": true,
         "sourceMapPathOverrides": {
         "sourceMapPathOverrides": {
-          "webpack:///*": "${workspaceFolder}/packages/app/*"
+          "webpack://_N_E/*": "${workspaceFolder}/packages/app/*"
         },
         },
         "webRoot": "${workspaceFolder}/packages/app/public",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "url": "http://localhost:3000"
         "url": "http://localhost:3000"
@@ -41,32 +61,36 @@
         "request": "launch",
         "request": "launch",
         "name": "Debug: Firefox",
         "name": "Debug: Firefox",
         "reAttach": true,
         "reAttach": true,
-        "url": "http://localhost:3000",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "webRoot": "${workspaceFolder}/packages/app/public",
+        "url": "http://localhost:3000",
         "pathMappings": [
         "pathMappings": [
           {
           {
-            "url": "webpack:///core",
+            "url": "webpack://_n_e/src",
+            "path": "${workspaceFolder}/packages/app/src"
+          },
+          {
+            "url": "webpack://_n_e/core",
             "path": "${workspaceFolder}/packages/core"
             "path": "${workspaceFolder}/packages/core"
           },
           },
           {
           {
-            "url": "webpack:///plugin-attachment-refs",
+            "url": "webpack://_n_e/plugin-attachment-refs",
             "path": "${workspaceFolder}/packages/plugin-attachment-refs"
             "path": "${workspaceFolder}/packages/plugin-attachment-refs"
           },
           },
           {
           {
-            "url": "webpack:///plugin-pukiwiki-like-linker",
+            "url": "webpack://_n_e/plugin-pukiwiki-like-linker",
             "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
             "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
           },
           },
           {
           {
-            "url": "webpack:///plugin-lsx",
+            "url": "webpack://_n_e/plugin-lsx",
             "path": "${workspaceFolder}/packages/plugin-lsx"
             "path": "${workspaceFolder}/packages/plugin-lsx"
           },
           },
           {
           {
-            "url": "webpack:///ui",
-            "path": "${workspaceFolder}/packages/ui"
+            "url": "webpack://_n_e/slack",
+            "path": "${workspaceFolder}/packages/app/slack"
           },
           },
           {
           {
-            "url": "webpack:///src",
-            "path": "${workspaceFolder}/packages/app/src"
+            "url": "webpack://_n_e/ui",
+            "path": "${workspaceFolder}/packages/ui"
           },
           },
           {
           {
             "url": "http://localhost:3000",
             "url": "http://localhost:3000",

+ 21 - 1
CHANGELOG.md

@@ -1,9 +1,29 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.10...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.11...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v5.0.11](https://github.com/weseek/growi/compare/v5.0.10...v5.0.11) - 2022-07-05
+
+### 💎 Features
+
+- feat: Integrate recount descendant count after paths fix (#6170) @Yohei-Shiina
+
+### 🚀 Improvement
+
+- imprv: Redirect when the anchor is #password (#6144) @Kami-jo
+
+### 🐛 Bug Fixes
+
+- fix: User registration page is not redirected after tmp login (#6197) @kaoritokashiki
+- fix: Empty trash doesn't work (#6168) @yukendev
+
+### 🧰 Maintenance
+
+- support: Ease rate limit temporary (#6191) @yuki-takei
+- support: Omit page history container and page revision comparer container (#6185) @yukendev
+
 ## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
 ## [v5.0.10](https://github.com/weseek/growi/compare/v5.0.9...v5.0.10) - 2022-06-27
 
 
 ### 💎 Features
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "growi",
   "name": "growi",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "description": "Team collaboration software using markdown",
   "description": "Team collaboration software using markdown",
   "tags": [
   "tags": [
     "wiki",
     "wiki",

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.0.10`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
-* [`5.0.10-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.10/docker/Dockerfile)
+* [`5.0.11`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
+* [`5.0.11-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.5.22-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.22/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 20 - 3
packages/app/next.config.js

@@ -1,12 +1,29 @@
+import eazyLogger from 'eazy-logger';
 import { I18NextHMRPlugin } from 'i18next-hmr/plugin';
 import { I18NextHMRPlugin } from 'i18next-hmr/plugin';
 import { WebpackManifestPlugin } from 'webpack-manifest-plugin';
 import { WebpackManifestPlugin } from 'webpack-manifest-plugin';
 
 
 import { i18n, localePath } from './src/next-i18next.config';
 import { i18n, localePath } from './src/next-i18next.config';
 import { listScopedPackages } from './src/utils/next.config.utils';
 import { listScopedPackages } from './src/utils/next.config.utils';
 
 
-// define transpiled packages for '@growi/*'
-const scopedPackages = listScopedPackages(['@growi']);
-const withTM = require('next-transpile-modules')(scopedPackages);
+
+// setup logger
+const logger = eazyLogger.Logger({
+  prefix: '[{green:next.config.js}] ',
+  useLevelPrefixes: false,
+});
+
+
+const setupWithTM = () => {
+  // define transpiled packages for '@growi/*'
+  const scopedPackages = listScopedPackages(['@growi'], { ignorePackageNames: '@growi/app' });
+
+  logger.info('{bold:Listing scoped packages for transpiling:}');
+  logger.unprefixed('info', `{grey:${JSON.stringify(scopedPackages, null, 2)}}`);
+
+  return require('next-transpile-modules')(scopedPackages);
+};
+const withTM = setupWithTM();
+
 
 
 // define additional entries
 // define additional entries
 const additionalWebpackEntries = {
 const additionalWebpackEntries = {

+ 16 - 28
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.11-RC.0",
+  "version": "5.0.12-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -19,7 +19,7 @@
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "preserver": "yarn cross-env NODE_ENV=production yarn migrate",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
     "migrate": "node -r dotenv-flow/config node_modules/.bin/migrate-mongo up",
     "//// for development": "",
     "//// for development": "",
-    "dev": "yarn cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --transpile-only src/server/app.ts",
+    "dev": "yarn cross-env NODE_ENV=development ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config --inspect --transpile-only src/server/app.ts",
     "predev": "yarn cross-env NODE_ENV=development run-p resources:* dev:migrate:up",
     "predev": "yarn cross-env NODE_ENV=development run-p resources:* dev:migrate:up",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate-mongo": "yarn cross-env NODE_ENV=development yarn ts-node node_modules/.bin/migrate-mongo",
     "dev:migrate": "yarn dev:migrate:up",
     "dev:migrate": "yarn dev:migrate:up",
@@ -29,7 +29,7 @@
     "dev:migrate:down": "yarn dev:migrate-mongo down",
     "dev:migrate:down": "yarn dev:migrate-mongo down",
     "cy:run": "cypress run --browser chrome",
     "cy:run": "cypress run --browser chrome",
     "//// for CI": "",
     "//// for CI": "",
-    "dev:ci": "yarn dev:client:nowatch && yarn dev:server --ci",
+    "dev:ci": "yarn dev --ci",
     "predev:ci": "run-p resources:*",
     "predev:ci": "run-p resources:*",
     "lint:typecheck": "npx -y tsc",
     "lint:typecheck": "npx -y tsc",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
     "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
@@ -63,11 +63,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.11-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.11-RC.0",
-    "@growi/plugin-lsx": "^5.0.11-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.11-RC.0",
-    "@growi/slack": "^5.0.11-RC.0",
+    "@growi/codemirror-textlint": "^5.0.12-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.12-RC.0",
+    "@growi/plugin-lsx": "^5.0.12-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.12-RC.0",
+    "@growi/slack": "^5.0.12-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -167,18 +167,17 @@
   },
   },
   "// comments for defDependencies": {
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no loger MIT lisence.",
-    "ts-loader": "v9 is not compatible with webpack@5"
+    "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   },
   "devDependencies": {
   "devDependencies": {
-    "@growi/ui": "^5.0.11-RC.0",
+    "@growi/ui": "^5.0.12-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
     "@types/jquery": "^3.5.8",
     "@types/jquery": "^3.5.8",
     "@types/multer": "^1.4.5",
     "@types/multer": "^1.4.5",
     "autoprefixer": "^9.0.0",
     "autoprefixer": "^9.0.0",
-    "bootstrap": "^4.5.0",
+    "bootstrap": "^4.6.1",
     "browser-sync": "^2.27.7",
     "browser-sync": "^2.27.7",
     "bunyan-debug": "^2.0.0",
     "bunyan-debug": "^2.0.0",
     "cli": "~1.0.1",
     "cli": "~1.0.1",
@@ -186,24 +185,19 @@
     "colors": "=1.4.0",
     "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
     "core-js": "=2.6.9",
-    "css-loader": "^3.0.0",
     "csv-to-markdown-table": "^1.0.1",
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",
-    "file-loader": "^5.0.2",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
-    "hard-source-webpack-plugin": "^0.13.1",
     "i18next-hmr": "^1.7.7",
     "i18next-hmr": "^1.7.7",
-    "imports-loader": "^0.8.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
     "jshint": "^2.13.0",
     "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
-    "lodash-webpack-plugin": "^0.11.5",
     "markdown-it": "^10.0.0",
     "markdown-it": "^10.0.0",
     "markdown-it-blockdiag": "^1.1.1",
     "markdown-it-blockdiag": "^1.1.1",
     "markdown-it-drawio-viewer": "^1.3.1",
     "markdown-it-drawio-viewer": "^1.3.1",
@@ -216,15 +210,11 @@
     "markdown-it-task-checkbox": "^1.0.6",
     "markdown-it-task-checkbox": "^1.0.6",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "markdown-table": "^1.1.1",
-    "mini-css-extract-plugin": "^2.6.1",
+    "material-icons": "^1.11.3",
     "morgan": "^1.10.0",
     "morgan": "^1.10.0",
-    "node-dev": "^4.0.0",
     "normalize-path": "^3.0.0",
     "normalize-path": "^3.0.0",
-    "null-loader": "^3.0.0",
-    "on-headers": "^1.0.1",
     "penpal": "^4.0.0",
     "penpal": "^4.0.0",
     "plantuml-encoder": "^1.2.5",
     "plantuml-encoder": "^1.2.5",
-    "postcss-loader": "^3.0.0",
     "prettier": "^1.19.1",
     "prettier": "^1.19.1",
     "react-bootstrap-typeahead": "^5.2.2",
     "react-bootstrap-typeahead": "^5.2.2",
     "react-codemirror2": "^6.0.0",
     "react-codemirror2": "^6.0.0",
@@ -232,24 +222,22 @@
     "react-dropzone": "^11.2.4",
     "react-dropzone": "^11.2.4",
     "react-frame-component": "^4.0.0",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-hotkeys": "^2.0.0",
+    "react-use-ripple": "^1.5.2",
     "react-waypoint": "^10.1.0",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
     "replacestream": "^4.0.3",
     "reveal.js": "^4.3.1",
     "reveal.js": "^4.3.1",
-    "sass": "^1.43.4",
-    "sass-loader": "^10.1.1",
+    "sass": "^1.53.0",
+    "simple-line-icons": "^2.5.5",
     "simple-load-script": "^1.0.2",
     "simple-load-script": "^1.0.2",
     "simplebar-react": "^2.3.6",
     "simplebar-react": "^2.3.6",
     "socket.io-client": "^4.2.0",
     "socket.io-client": "^4.2.0",
     "sticky-events": "^3.4.11",
     "sticky-events": "^3.4.11",
-    "style-loader": "^1.0.0",
-    "styled-components": "^5.0.1",
     "swagger2openapi": "^5.3.1",
     "swagger2openapi": "^5.3.1",
     "swr": "^1.1.2",
     "swr": "^1.1.2",
     "throttle-debounce": "^3.0.1",
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
     "toastr": "^2.1.2",
-    "ts-loader": "^8.3.0",
-    "ts-node-dev": "^1.1.6",
+    "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9",
     "tsc-alias": "^1.2.9",
     "unstated": "^2.1.1",
     "unstated": "^2.1.1",
     "webpack-manifest-plugin": "^5.0.0"
     "webpack-manifest-plugin": "^5.0.0"

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

@@ -387,7 +387,8 @@
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
     "notice": {
     "notice": {
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
-    }
+    },
+    "changes_not_saved": "Changes you made may not be saved."
   },
   },
   "page_comment": {
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",

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

@@ -387,7 +387,8 @@
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
     "notice": {
     "notice": {
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
-    }
+    },
+    "changes_not_saved": "変更が保存されていない可能性があります。"
   },
   },
   "page_comment": {
   "page_comment": {
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",

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

@@ -366,7 +366,8 @@
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
 		"notice": {
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
-		}
+		},
+    "changes_not_saved": "您所做的更改可能不会保存。"
   },
   },
   "page_comment": {
   "page_comment": {
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",

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

@@ -10,9 +10,7 @@ import { Provider } from 'unstated';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
 import PersonalContainer from '~/client/services/PersonalContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -58,13 +56,10 @@ const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 
 
 // create unstated container instance
 // create unstated container instance
 const pageContainer = new PageContainer(appContainer);
 const pageContainer = new PageContainer(appContainer);
-const pageHistoryContainer = new PageHistoryContainer(appContainer, pageContainer);
-const revisionComparerContainer = new RevisionComparerContainer(appContainer, pageContainer);
 const editorContainer = new EditorContainer(appContainer);
 const editorContainer = new EditorContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const personalContainer = new PersonalContainer(appContainer);
 const injectableContainers = [
 const injectableContainers = [
-  appContainer, socketIoContainer, pageContainer, pageHistoryContainer, revisionComparerContainer,
-  editorContainer, personalContainer,
+  appContainer, socketIoContainer, pageContainer, editorContainer, personalContainer,
 ];
 ];
 
 
 logger.info('unstated containers have been initialized');
 logger.info('unstated containers have been initialized');

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
packages/app/src/client/legacy/thirdparty-js/waves.js


+ 1 - 3
packages/app/src/client/services/ContextExtractor.tsx

@@ -18,7 +18,7 @@ import {
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUpdatedAt, useCreator, useRevisionAuthor, useCurrentUser, useTargetAndAncestors,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
-  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
+  useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
   useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
   useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useIsEmptyPage, useEmptyPageId, useGrowiVersion,
 } from '../../stores/context';
 } from '../../stores/context';
 
 
@@ -92,7 +92,6 @@ const ContextExtractorOnce: FC = () => {
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const revisionAuthor = JSON.parse(mainContent?.getAttribute('data-page-revision-author') || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const targetAndAncestors = JSON.parse(document.getElementById('growi-pagetree-target-and-ancestors')?.textContent || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
   const notFoundTargetPathOrId = JSON.parse(notFoundContentForPt?.getAttribute('data-not-found-target-path-or-id') || jsonNull);
-  const isNotFoundPermalink = JSON.parse(notFoundContext?.getAttribute('data-is-not-found-permalink') || jsonNull);
   const isSearchPage = document.getElementById('search-page') != null;
   const isSearchPage = document.getElementById('search-page') != null;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
   const isEmptyPage = JSON.parse(mainContent?.getAttribute('data-page-is-empty') || jsonNull) ?? false;
 
 
@@ -153,7 +152,6 @@ const ContextExtractorOnce: FC = () => {
   useRevisionAuthor(revisionAuthor);
   useRevisionAuthor(revisionAuthor);
   useTargetAndAncestors(targetAndAncestors);
   useTargetAndAncestors(targetAndAncestors);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
   useNotFoundTargetPathOrId(notFoundTargetPathOrId);
-  useIsNotFoundPermalink(isNotFoundPermalink);
   useIsSearchPage(isSearchPage);
   useIsSearchPage(isSearchPage);
   useIsEmptyPage(isEmptyPage);
   useIsEmptyPage(isEmptyPage);
   useHasParent(hasParent);
   useHasParent(hasParent);

+ 0 - 24
packages/app/src/client/services/EditorContainer.js

@@ -21,8 +21,6 @@ export default class EditorContainer extends Container {
       tags: null,
       tags: null,
     };
     };
 
 
-    this.isSetBeforeunloadEventHandler = false;
-
     this.initDrafts();
     this.initDrafts();
 
 
   }
   }
@@ -59,28 +57,6 @@ export default class EditorContainer extends Container {
     }
     }
   }
   }
 
 
-
-  // See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
-  showUnsavedWarning(e) {
-    // Cancel the event
-    e.preventDefault();
-    // display browser default message
-    e.returnValue = '';
-    return '';
-  }
-
-  disableUnsavedWarning() {
-    window.removeEventListener('beforeunload', this.showUnsavedWarning);
-    this.isSetBeforeunloadEventHandler = false;
-  }
-
-  enableUnsavedWarning() {
-    if (!this.isSetBeforeunloadEventHandler) {
-      window.addEventListener('beforeunload', this.showUnsavedWarning);
-      this.isSetBeforeunloadEventHandler = true;
-    }
-  }
-
   clearDraft(path) {
   clearDraft(path) {
     delete this.drafts[path];
     delete this.drafts[path];
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));
     window.localStorage.setItem('drafts', JSON.stringify(this.drafts));

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

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

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

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

+ 3 - 1
packages/app/src/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
 import {
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
@@ -91,4 +91,6 @@ DeleteSlackBotSettingsModal.propTypes = {
   onClickDeleteButton: PropTypes.func,
   onClickDeleteButton: PropTypes.func,
 };
 };
 
 
+DeleteSlackBotSettingsModal.displayName = 'DeleteSlackBotSettingsModal';
+
 export default DeleteSlackBotSettingsModal;
 export default DeleteSlackBotSettingsModal;

+ 1 - 0
packages/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -20,6 +20,7 @@ const SuspendAlert = React.memo((): JSX.Element => {
   );
   );
 });
 });
 
 
+SuspendAlert.displayName = 'SuspendAlert';
 
 
 type Props = {
 type Props = {
   adminUsersContainer: AdminUsersContainer,
   adminUsersContainer: AdminUsersContainer,

+ 3 - 4
packages/app/src/components/BasicLayout.tsx

@@ -3,8 +3,8 @@ import React, { ReactNode } from 'react';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
 import { GrowiNavbar } from './Navbar/GrowiNavbar';
 import { GrowiNavbar } from './Navbar/GrowiNavbar';
-// import GrowiNavbarBottom from './Navbar/GrowiNavbarBottom';
 import { RawLayout } from './RawLayout';
 import { RawLayout } from './RawLayout';
+import Sidebar from './Sidebar';
 
 
 
 
 type Props = {
 type Props = {
@@ -15,9 +15,9 @@ type Props = {
 
 
 export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
 export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
 
 
-  const Sidebar = dynamic(() => import('./Sidebar'), { ssr: false });
   // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
   // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
   // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
   // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
+  const GrowiNavbarBottom = dynamic(() => import('./Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
   const ShortcutsModal = dynamic(() => import('./ShortcutsModal'), { ssr: false });
   const ShortcutsModal = dynamic(() => import('./ShortcutsModal'), { ssr: false });
   const SystemVersion = dynamic(() => import('./SystemVersion'), { ssr: false });
   const SystemVersion = dynamic(() => import('./SystemVersion'), { ssr: false });
 
 
@@ -36,8 +36,7 @@ export const BasicLayout = ({ children, title, className }: Props): JSX.Element
           </div>
           </div>
         </div>
         </div>
 
 
-        {/* <GrowiNavbarBottom /> */}
-        GrowiNavbarBottom
+        <GrowiNavbarBottom />
       </RawLayout>
       </RawLayout>
 
 
       {/* <PageCreateModal /> */}
       {/* <PageCreateModal /> */}

+ 2 - 0
packages/app/src/components/Common/ClosableTextInput.tsx

@@ -126,4 +126,6 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   );
   );
 });
 });
 
 
+ClosableTextInput.displayName = 'ClosableTextInput';
+
 export default ClosableTextInput;
 export default ClosableTextInput;

+ 3 - 0
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -153,6 +153,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
           <DropdownItem
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
             className="grw-page-control-dropdown-item"
+            data-testid="add-remove-bookmark-btn"
           >
           >
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
@@ -239,6 +240,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   );
   );
 });
 });
 
 
+PageItemControlDropdownMenu.displayName = 'PageItemControl';
+
 
 
 type PageItemControlSubstanceProps = CommonProps & {
 type PageItemControlSubstanceProps = CommonProps & {
   pageId: string,
   pageId: string,

+ 2 - 1
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,9 +1,10 @@
 import React, { useCallback, useMemo } from 'react';
 import React, { useCallback, useMemo } from 'react';
 
 
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { usePageUser } from '~/stores/context';
 import { usePageUser } from '~/stores/context';
 
 
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+
 const WIKI_HEADER_LINK = 120;
 const WIKI_HEADER_LINK = 120;
 
 
 
 

+ 9 - 3
packages/app/src/components/Fab.jsx

@@ -1,8 +1,10 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, {
+  useState, useCallback, useEffect, useRef,
+} from 'react';
 
 
+import { useRipple } from 'react-use-ripple';
 import StickyEvents from 'sticky-events';
 import StickyEvents from 'sticky-events';
 
 
-
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
@@ -22,6 +24,9 @@ const Fab = () => {
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
   const [buttonClasses, setButtonClasses] = useState('');
 
 
+  // ripple
+  const createBtnRef = useRef(null);
+  useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
 
   const stickyChangeHandler = useCallback((event) => {
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
     logger.debug('StickyEvents.CHANGE detected');
@@ -54,7 +59,8 @@ const Fab = () => {
         <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
         <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
           <button
           <button
             type="button"
             type="button"
-            className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 waves-effect waves-light ${buttonClasses}`}
+            className={`btn btn-lg btn-create-page btn-primary rounded-circle p-0 ${buttonClasses}`}
+            ref={createBtnRef}
             onClick={() => openCreateModal(currentPath)}
             onClick={() => openCreateModal(currentPath)}
           >
           >
             <CreatePageIcon />
             <CreatePageIcon />

+ 2 - 0
packages/app/src/components/ForbiddenPage.tsx

@@ -55,4 +55,6 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
   );
   );
 });
 });
 
 
+ForbiddenPage.displayName = 'ForbiddenPage';
+
 export default ForbiddenPage;
 export default ForbiddenPage;

+ 4 - 1
packages/app/src/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,8 +1,9 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/context';
 import { useCurrentPagePath } from '~/stores/context';
+import { usePageCreateModal } from '~/stores/modal';
 
 
 const CreatePage = React.memo((props) => {
 const CreatePage = React.memo((props) => {
 
 
@@ -28,4 +29,6 @@ CreatePage.getHotkeyStrokes = () => {
   return [['c']];
   return [['c']];
 };
 };
 
 
+CreatePage.displayName = 'CreatePage';
+
 export default CreatePage;
 export default CreatePage;

+ 2 - 0
packages/app/src/components/Icons/GrowiLogo.jsx

@@ -31,4 +31,6 @@ const GrowiLogo = memo(() => (
   </svg>
   </svg>
 ));
 ));
 
 
+GrowiLogo.displayName = 'GrowiLogo';
+
 export default GrowiLogo;
 export default GrowiLogo;

+ 7 - 4
packages/app/src/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -1,12 +1,12 @@
-import React, {
-  useState, useEffect, FC, useCallback,
-} from 'react';
+import React, { useState, useEffect, useRef } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRipple } from 'react-use-ripple';
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
@@ -29,6 +29,9 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(limit);
   const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(limit);
   const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
   const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
 
 
+  // ripple
+  const buttonRef = useRef(null);
+  useRipple(buttonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
 
   const updateNotificationStatus = async() => {
   const updateNotificationStatus = async() => {
     try {
     try {
@@ -77,7 +80,7 @@ export const InAppNotificationDropdown = (): JSX.Element => {
 
 
   return (
   return (
     <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler}>
     <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler}>
-      <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparent waves-effect waves-light">
+      <DropdownToggle tag="a" className="px-3 nav-link border-0 bg-transparentt" innerRef={buttonRef}>
         <i className="icon-bell" /> {badge}
         <i className="icon-bell" /> {badge}
       </DropdownToggle>
       </DropdownToggle>
       <DropdownMenu right>
       <DropdownMenu right>

+ 11 - 2
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -1,6 +1,9 @@
-import React, { FC, useState, useCallback } from 'react';
+import React, {
+  FC, useState, useCallback, useRef,
+} from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
@@ -35,6 +38,10 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
   const { scheduleToPut } = useUserUISettings();
   const { scheduleToPut } = useUserUISettings();
 
 
+  // ripple
+  const buttonRef = useRef(null);
+  useRipple(buttonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
+
   const preferDrawerModeSwitchModifiedHandler = useCallback((preferDrawerMode: boolean, isEditMode: boolean) => {
   const preferDrawerModeSwitchModifiedHandler = useCallback((preferDrawerMode: boolean, isEditMode: boolean) => {
     if (isEditMode) {
     if (isEditMode) {
       mutatePreferDrawerModeOnEdit(preferDrawerMode);
       mutatePreferDrawerModeOnEdit(preferDrawerMode);
@@ -110,7 +117,9 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
   return (
   return (
     <>
     <>
       {/* setting button */}
       {/* setting button */}
-      <button className="bg-transparent border-0 nav-link" type="button" data-toggle="dropdown" aria-haspopup="true">
+      {/* remove .dropdown-toggle for hide caret */}
+      {/* See https://stackoverflow.com/a/44577512/13183572 */}
+      <button className="bg-transparent border-0 nav-link" type="button" data-toggle="dropdown" ref={buttonRef} aria-haspopup="true">
         <i className="icon-settings"></i>
         <i className="icon-settings"></i>
       </button>
       </button>
 
 

+ 56 - 0
packages/app/src/components/Navbar/GlobalSearch.module.scss

@@ -0,0 +1,56 @@
+@use '~/styles/bootstrap/init' as bs;
+
+// input styles
+.grw-global-search :global {
+  .dropdown-toggle {
+    min-width: 95px;
+    padding-left: 1.5rem;
+  }
+
+  .search-typeahead {
+    .rbt-menu {
+      right: 0;
+      left: auto;
+
+      @include bs.media-breakpoint-up(md) {
+        right: auto;
+        left: 0;
+      }
+
+      @include bs.media-breakpoint-down(sm) {
+        left: auto !important;
+        width: 90vw;
+      }
+    }
+  }
+
+  // using react-bootstrap-typeahead
+  // see: https://github.com/ericgio/react-bootstrap-typeahead
+  .rbt-input.form-control {
+    height: 30px;
+    .rbt-input-wrapper {
+      margin-left: 8px;
+    }
+  }
+
+  .form-group:not(.has-error) {
+    .rbt-input.form-control {
+      border: none;
+    }
+  }
+
+  .grw-shortcut-key-indicator {
+    position: absolute;
+    top: 0;
+    right: 4px;
+
+    display: flex;
+    align-items: center;
+    height: 30px;
+
+    code {
+      padding-right: 0.4rem;
+      padding-left: 0.4rem;
+    }
+  }
+}

+ 33 - 27
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -1,40 +1,44 @@
-import React, {
-  FC, useState, useCallback, useRef,
-} from 'react';
-import { useTranslation } from 'next-i18next';
+import React, { useState, useCallback, useRef } from 'react';
+
 import assert from 'assert';
 import assert from 'assert';
 
 
-import AppContainer from '~/client/services/AppContainer';
+import { useTranslation } from 'next-i18next';
+
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IFocusable } from '~/client/interfaces/focusable';
-import { useGlobalSearchFormRef } from '~/stores/ui';
-import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { IPageSearchMeta } from '~/interfaces/search';
+import {
+  useCurrentPagePath, useIsSearchScopeChildrenAsDefault, useIsSearchServiceReachable,
+} from '~/stores/context';
+import { useGlobalSearchFormRef } from '~/stores/ui';
 
 
 import SearchForm from '../SearchForm';
 import SearchForm from '../SearchForm';
-import { useCurrentPagePath } from '~/stores/context';
 
 
 
 
-type Props = {
-  appContainer: AppContainer,
+import styles from './GlobalSearch.module.scss';
 
 
+
+type Props = {
   dropup?: boolean,
   dropup?: boolean,
 }
 }
 
 
-const GlobalSearch: FC<Props> = (props: Props) => {
-  const { appContainer, dropup } = props;
+export const GlobalSearch = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const { dropup } = props;
+
   const globalSearchFormRef = useRef<IFocusable>(null);
   const globalSearchFormRef = useRef<IFocusable>(null);
 
 
   useGlobalSearchFormRef(globalSearchFormRef);
   useGlobalSearchFormRef(globalSearchFormRef);
 
 
+  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
+  const { data: isSearchScopeChildrenAsDefault } = useIsSearchScopeChildrenAsDefault();
+  const { data: currentPagePath } = useCurrentPagePath();
+
   const [text, setText] = useState('');
   const [text, setText] = useState('');
-  const [isScopeChildren, setScopeChildren] = useState<boolean>(appContainer.getConfig().isSearchScopeChildrenAsDefault);
+  const [isScopeChildren, setScopeChildren] = useState<boolean|undefined>(isSearchScopeChildrenAsDefault);
   const [isFocused, setFocused] = useState<boolean>(false);
   const [isFocused, setFocused] = useState<boolean>(false);
 
 
-  const { data: currentPagePath } = useCurrentPagePath();
 
 
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
   const gotoPage = useCallback((data: IPageWithMeta<IPageSearchMeta>[]) => {
     assert(data.length > 0);
     assert(data.length > 0);
@@ -65,15 +69,23 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     ? t('header_search_box.label.This tree')
     ? t('header_search_box.label.This tree')
     : t('header_search_box.label.All pages');
     : t('header_search_box.label.All pages');
 
 
-  const isSearchServiceReachable = appContainer.getConfig().isSearchServiceReachable;
-
   const isIndicatorShown = !isFocused && (text.length === 0);
   const isIndicatorShown = !isFocused && (text.length === 0);
 
 
+  if (isScopeChildren == null || isSearchServiceReachable == null) {
+    return <></>;
+  }
+
   return (
   return (
-    <div className={`form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
+    <div className={`grw-global-search ${styles['grw-global-search']} form-group mb-0 d-print-none ${isSearchServiceReachable ? '' : 'has-error'}`}>
       <div className="input-group flex-nowrap">
       <div className="input-group flex-nowrap">
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
         <div className={`input-group-prepend ${dropup ? 'dropup' : ''}`}>
-          <button className="btn btn-secondary dropdown-toggle py-0" type="button" data-toggle="dropdown" aria-haspopup="true">
+          <button
+            className="btn btn-secondary dropdown-toggle py-0"
+            type="button"
+            data-toggle="dropdown"
+            aria-haspopup="true"
+            data-testid="select-search-scope"
+          >
             {scopeLabel}
             {scopeLabel}
           </button>
           </button>
           <div className="dropdown-menu">
           <div className="dropdown-menu">
@@ -88,6 +100,7 @@ const GlobalSearch: FC<Props> = (props: Props) => {
               { t('header_search_box.item_label.All pages') }
               { t('header_search_box.item_label.All pages') }
             </button>
             </button>
             <button
             <button
+              data-tesid="search-current-tree"
               className="dropdown-item"
               className="dropdown-item"
               type="button"
               type="button"
               onClick={() => {
               onClick={() => {
@@ -118,10 +131,3 @@ const GlobalSearch: FC<Props> = (props: Props) => {
     </div>
     </div>
   );
   );
 };
 };
-
-/**
- * Wrapper component for using unstated
- */
-const GlobalSearchWrapper = withUnstatedContainers(GlobalSearch, [AppContainer]);
-
-export default GlobalSearchWrapper;

+ 55 - 12
packages/app/src/components/Navbar/GrowiNavbar.scss → packages/app/src/components/Navbar/GrowiNavbar.module.scss

@@ -2,19 +2,23 @@
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/mixins';
 @use '~/styles/mixins';
 
 
-.grw-logo {
-  svg {
-    width: var.$grw-logo-width;
-    height: var.$grw-navbar-height;
-    padding: (var.$grw-logo-width - var.$grw-logomark-width) / 2;
+.grw-navbar :global {
+
+  .grw-logo {
+    svg {
+      width: var.$grw-logo-width;
+      height: var.$grw-navbar-height;
+      padding: (var.$grw-logo-width - var.$grw-logomark-width) / 2;
+    }
+  }
+
+  .confidential {
+    font-weight: bold;
   }
   }
-}
 
 
-.confidential {
-  font-weight: bold;
 }
 }
 
 
-.grw-navbar {
+.grw-navbar :global {
   top: #{-1 * var.$grw-navbar-height} !important;
   top: #{-1 * var.$grw-navbar-height} !important;
 
 
   z-index: var.$grw-navbar-z-index !important;
   z-index: var.$grw-navbar-z-index !important;
@@ -66,19 +70,20 @@
     background: rgba(0, 0, 0, 0.2);
     background: rgba(0, 0, 0, 0.2);
   }
   }
 
 
+  .grw-apperance-mode-dropdown,
   .grw-personal-dropdown {
   .grw-personal-dropdown {
     .dropdown-menu {
     .dropdown-menu {
       min-width: 15rem;
       min-width: 15rem;
 
 
-      .grw-email-sm {
-        font-size: 0.75em;
-      }
       .grw-icon-container svg {
       .grw-icon-container svg {
         width: 18px;
         width: 18px;
         height: 18px;
         height: 18px;
       }
       }
     }
     }
   }
   }
+  .grw-email-sm {
+    font-size: 0.75em;
+  }
 
 
   .grw-notification-dropdown {
   .grw-notification-dropdown {
     .dropdown-menu {
     .dropdown-menu {
@@ -87,6 +92,44 @@
   }
   }
 }
 }
 
 
+// layout for GlobalSearch
+.grw-navbar :global {
+  .grw-global-search-container {
+    // centering on navbar
+    top: var.$grw-navbar-height / 2;
+    left: 50vw;
+    z-index: bs.$zindex-fixed + 1;
+    transform: translate(-50%, -50%);
+
+    .rbt-input.form-control {
+      width: 200px;
+      transition: 0.3s ease-out;
+
+      // focus
+      &.focus {
+        width: 300px;
+      }
+
+      @include bs.media-breakpoint-up(md) {
+        width: 300px;
+      }
+      @include bs.media-breakpoint-up(lg) {
+        // focus
+        &.focus {
+          width: 400px;
+        }
+      }
+      @include bs.media-breakpoint-up(xl) {
+        width: 350px;
+        // focus
+        &.focus {
+          width: 450px;
+        }
+      }
+    }
+  }
+}
+
 .grw-notification-badge {
 .grw-notification-badge {
   position: absolute;
   position: absolute;
   top: 6px;
   top: 6px;

+ 18 - 8
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -1,8 +1,11 @@
-import React, { FC, memo, useMemo } from 'react';
+import React, {
+  FC, memo, useMemo, useRef,
+} from 'react';
 
 
 import { isServer } from '@growi/core';
 import { isServer } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
+import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import { HasChildren } from '~/interfaces/common';
 import { HasChildren } from '~/interfaces/common';
@@ -16,7 +19,7 @@ import GrowiLogo from '../Icons/GrowiLogo';
 
 
 import PersonalDropdown from './PersonalDropdown';
 import PersonalDropdown from './PersonalDropdown';
 
 
-import './GrowiNavbar.scss';
+import styles from './GrowiNavbar.module.scss';
 
 
 
 
 const ShowSkeltonInSSR = memo(({ children }: HasChildren): JSX.Element => {
 const ShowSkeltonInSSR = memo(({ children }: HasChildren): JSX.Element => {
@@ -36,6 +39,10 @@ const NavbarRight = memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
+  // ripple
+  const newButtonRef = useRef(null);
+  useRipple(newButtonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
+
   const { open: openCreateModal } = usePageCreateModal();
   const { open: openCreateModal } = usePageCreateModal();
 
 
   const isAuthenticated = isGuestUser === false;
   const isAuthenticated = isGuestUser === false;
@@ -51,6 +58,7 @@ const NavbarRight = memo((): JSX.Element => {
           <button
           <button
             className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
             className="px-md-3 nav-link btn-create-page border-0 bg-transparent"
             type="button"
             type="button"
+            ref={newButtonRef}
             data-testid="newPageBtn"
             data-testid="newPageBtn"
             onClick={() => openCreateModal(currentPagePath || '')}
             onClick={() => openCreateModal(currentPagePath || '')}
           >
           >
@@ -59,7 +67,7 @@ const NavbarRight = memo((): JSX.Element => {
           </button>
           </button>
         </li>
         </li>
 
 
-        <li className="grw-personal-dropdown nav-item dropdown">
+        <li className="grw-apperance-mode-dropdown nav-item dropdown">
           <ShowSkeltonInSSR><AppearanceModeDropdown isAuthenticated={isAuthenticated} /></ShowSkeltonInSSR>
           <ShowSkeltonInSSR><AppearanceModeDropdown isAuthenticated={isAuthenticated} /></ShowSkeltonInSSR>
         </li>
         </li>
 
 
@@ -73,14 +81,14 @@ const NavbarRight = memo((): JSX.Element => {
   const notAuthenticatedNavItem = useMemo(() => {
   const notAuthenticatedNavItem = useMemo(() => {
     return (
     return (
       <>
       <>
-        <li className="grw-personal-dropdown nav-item dropdown">
+        <li className="grw-apperance-mode-dropdown nav-item dropdown">
           <ShowSkeltonInSSR><AppearanceModeDropdown isAuthenticated={isAuthenticated} /></ShowSkeltonInSSR>
           <ShowSkeltonInSSR><AppearanceModeDropdown isAuthenticated={isAuthenticated} /></ShowSkeltonInSSR>
         </li>
         </li>
 
 
         <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
         <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
       </>
       </>
     );
     );
-  }, []);
+  }, [AppearanceModeDropdown, isAuthenticated]);
 
 
   return (
   return (
     <>
     <>
@@ -121,6 +129,8 @@ Confidential.displayName = 'Confidential';
 
 
 export const GrowiNavbar = (): JSX.Element => {
 export const GrowiNavbar = (): JSX.Element => {
 
 
+  const GlobalSearch = dynamic(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
+
   const { data: appTitle } = useAppTitle();
   const { data: appTitle } = useAppTitle();
   const { data: confidential } = useConfidential();
   const { data: confidential } = useConfidential();
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
@@ -128,7 +138,7 @@ export const GrowiNavbar = (): JSX.Element => {
   const { data: isSearchPage } = useIsSearchPage();
   const { data: isSearchPage } = useIsSearchPage();
 
 
   return (
   return (
-    <nav id="grw-navbar" className="navbar grw-navbar navbar-expand navbar-dark sticky-top mb-0 px-0">
+    <nav id="grw-navbar" className={`navbar grw-navbar ${styles['grw-navbar']} navbar-expand navbar-dark sticky-top mb-0 px-0`}>
       {/* Brand Logo  */}
       {/* Brand Logo  */}
       <div className="navbar-brand mr-0">
       <div className="navbar-brand mr-0">
         <a className="grw-logo d-block" href="/">
         <a className="grw-logo d-block" href="/">
@@ -148,8 +158,8 @@ export const GrowiNavbar = (): JSX.Element => {
       </ul>
       </ul>
 
 
       { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
       { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
-        <div className="grw-global-search grw-global-search-top position-absolute">
-          {/* <GlobalSearch /> */}
+        <div className="grw-global-search-container position-absolute">
+          <GlobalSearch />
         </div>
         </div>
       ) }
       ) }
     </nav>
     </nav>

+ 16 - 0
packages/app/src/components/Navbar/GrowiNavbarBottom.module.scss

@@ -0,0 +1,16 @@
+@use '~/styles/variables' as var;
+@use '~/styles/mixins';
+
+.grw-navbar-bottom :global {
+  height: var.$grw-navbar-bottom-height;
+
+  // apply transition
+  transition-property: bottom;
+  @include mixins.apply-navigation-transition();
+}
+
+.grw-navbar-bottom {
+  &:global(.grw-navbar-bottom-drawer-opened) {
+    bottom: #{-1 * var.$grw-navbar-bottom-height};
+  }
+}

+ 8 - 10
packages/app/src/components/Navbar/GrowiNavbarBottom.jsx → packages/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,14 +1,15 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
-
-import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
-import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
 import { useCurrentPagePath, useIsSearchPage } from '~/stores/context';
+import { usePageCreateModal } from '~/stores/modal';
+import { useIsDeviceSmallerThanMd, useDrawerOpened } from '~/stores/ui';
 
 
-import GlobalSearch from './GlobalSearch';
+import { GlobalSearch } from './GlobalSearch';
 
 
-const GrowiNavbarBottom = (props) => {
+import styles from './GrowiNavbarBottom.module.scss';
+
+
+export const GrowiNavbarBottom = (): JSX.Element => {
 
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
@@ -16,7 +17,7 @@ const GrowiNavbarBottom = (props) => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSearchPage } = useIsSearchPage();
   const { data: isSearchPage } = useIsSearchPage();
 
 
-  const additionalClasses = ['grw-navbar-bottom'];
+  const additionalClasses = ['grw-navbar-bottom', styles['grw-navbar-bottom']];
   if (isDrawerOpened) {
   if (isDrawerOpened) {
     additionalClasses.push('grw-navbar-bottom-drawer-opened');
     additionalClasses.push('grw-navbar-bottom-drawer-opened');
   }
   }
@@ -73,6 +74,3 @@ const GrowiNavbarBottom = (props) => {
     </div>
     </div>
   );
   );
 };
 };
-
-
-export default GrowiNavbarBottom;

+ 3 - 1
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -1,7 +1,7 @@
 import React, { useCallback } from 'react';
 import React, { useCallback } from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
@@ -38,6 +38,8 @@ const PageEditorModeButtonWrapper = React.memo(({
 });
 });
 /* eslint-enable react/prop-types */
 /* eslint-enable react/prop-types */
 
 
+PageEditorModeButtonWrapper.displayName = 'PageEditorModeButtonWrapper';
+
 function PageEditorModeManager(props) {
 function PageEditorModeManager(props) {
   const {
   const {
     appContainer,
     appContainer,

+ 8 - 3
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useRef } from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import { useRipple } from 'react-use-ripple';
 
 
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -11,6 +12,10 @@ const PersonalDropdown = () => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
+  // ripple
+  const buttonRef = useRef(null);
+  useRipple(buttonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
+
   const user = currentUser || {};
   const user = currentUser || {};
 
 
   const logoutHandler = async() => {
   const logoutHandler = async() => {
@@ -28,9 +33,9 @@ const PersonalDropdown = () => {
       {/* Button */}
       {/* Button */}
       {/* remove .dropdown-toggle for hide caret */}
       {/* remove .dropdown-toggle for hide caret */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
       {/* See https://stackoverflow.com/a/44577512/13183572 */}
-      <a className="px-md-3 nav-link waves-effect waves-light" data-toggle="dropdown">
+      <button className="bg-transparent border-0 nav-link" type="button" ref={buttonRef} data-toggle="dropdown">
         <UserPicture user={user} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{user.name}</span>
         <UserPicture user={user} noLink noTooltip /><span className="ml-1 d-none d-lg-inline-block">&nbsp;{user.name}</span>
-      </a>
+      </button>
 
 
       {/* Menu */}
       {/* Menu */}
       <div className="dropdown-menu dropdown-menu-right">
       <div className="dropdown-menu dropdown-menu-right">

+ 6 - 28
packages/app/src/components/NotFoundPage.tsx

@@ -1,40 +1,17 @@
-import React, { useMemo, useEffect } from 'react';
+import React, { useMemo } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import urljoin from 'url-join';
+import dynamic from 'next/dynamic';
 
 
-import { useCurrentPagePath, useIsEmptyPage, useNotFoundTargetPathOrId } from '~/stores/context';
-
-import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import PageListIcon from './Icons/PageListIcon';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
-import PageTimeline from './PageTimeline';
-
-/**
- * Replace url in address bar with new path and query parameters
- */
-const replaceURLHistory = (path: string) => {
-  const queryParameters = window.location.search;
-  window.history.replaceState(null, '', urljoin(path, queryParameters));
-};
+// import PageTimeline from './PageTimeline';
 
 
 const NotFoundPage = (): JSX.Element => {
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { data: isEmptyPage } = useIsEmptyPage();
-  const { data: path } = useCurrentPagePath();
-  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
 
 
-  // replace url in address bar with path when accessing empty page by permalink
-  useEffect(() => {
-    if (path == null) {
-      return;
-    }
-    const isPermalink = !notFoundTargetPathOrId?.includes('/');
-    if (isEmptyPage && isPermalink) {
-      replaceURLHistory(path);
-    }
-  }, [path, isEmptyPage, notFoundTargetPathOrId]);
+  const CustomNavAndContents = dynamic(() => import('./CustomNavigation/CustomNavAndContents'), { ssr: false });
 
 
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
@@ -46,7 +23,8 @@ const NotFoundPage = (): JSX.Element => {
       },
       },
       timeLine: {
       timeLine: {
         Icon: TimeLineIcon,
         Icon: TimeLineIcon,
-        Content: PageTimeline,
+        // Content: PageTimeline,
+        Content: () => <></>,
         i18n: t('Timeline View'),
         i18n: t('Timeline View'),
         index: 1,
         index: 1,
       },
       },

+ 9 - 6
packages/app/src/components/Page.jsx

@@ -2,7 +2,6 @@ import React, { useEffect, useRef } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-
 import MarkdownTable from '~/client/models/MarkdownTable';
 import MarkdownTable from '~/client/models/MarkdownTable';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -11,7 +10,9 @@ import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
   useCurrentPagePath, useIsGuestUser,
   useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import {
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -76,7 +77,7 @@ class Page extends React.Component {
 
 
   async saveHandlerForHandsontableModal(markdownTable) {
   async saveHandlerForHandsontableModal(markdownTable) {
     const {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
+      isSlackEnabled, slackChannels, pageContainer, mutateIsEnabledUnsavedWarning, grant, grantGroupId, grantGroupName, pageTags,
     } = this.props;
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
 
@@ -89,7 +90,7 @@ class Page extends React.Component {
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
@@ -108,7 +109,7 @@ class Page extends React.Component {
 
 
   async saveHandlerForDrawioModal(drawioData) {
   async saveHandlerForDrawioModal(drawioData) {
     const {
     const {
-      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, editorContainer,
+      isSlackEnabled, slackChannels, pageContainer, pageTags, grant, grantGroupId, grantGroupName, mutateIsEnabledUnsavedWarning,
     } = this.props;
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
 
@@ -121,7 +122,7 @@ class Page extends React.Component {
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
       const { page, tags } = await pageContainer.save(newMarkdown, this.props.editorMode, optionsToSave);
@@ -194,6 +195,7 @@ const PageWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
   const pageRef = useRef(null);
   const pageRef = useRef(null);
 
 
@@ -244,6 +246,7 @@ const PageWrapper = (props) => {
       grant={grant}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       grantGroupName={grantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
     />
   );
   );
 };
 };

+ 25 - 19
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -2,25 +2,24 @@ import React, { useMemo } from 'react';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { TabContent, TabPane } from 'reactstrap';
 import { TabContent, TabPane } from 'reactstrap';
 
 
-
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { isPopulated } from '~/interfaces/common';
 import {
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser, useShareLinkId, useIsEmptyPage,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 import CountBadge from '../Common/CountBadge';
 import CountBadge from '../Common/CountBadge';
-import ContentLinkButtons from '../ContentLinkButtons';
-import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
 import PageListIcon from '../Icons/PageListIcon';
-import Page from '../Page';
-import PageEditor from '../PageEditor';
-import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import PageEditorByHackmd from '../PageEditorByHackmd';
+import NotFoundPage from '../NotFoundPage';
+// import Page from '../Page';
+// import PageEditor from '../PageEditor';
+// import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import TableOfContents from '../TableOfContents';
 import UserInfo from '../User/UserInfo';
 import UserInfo from '../User/UserInfo';
 
 
@@ -33,34 +32,38 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
+  const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
+  const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
+
   // get element for smoothScroll
   // get element for smoothScroll
-  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
 
 
-  const { data: isEmptyPage } = useIsEmptyPage();
-  const { data: currentPageId } = useCurrentPageId();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
   const { data: shareLinkId } = useShareLinkId();
   const { data: isUserPage } = useIsUserPage();
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
   const { data: pageUser } = usePageUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
 
 
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
 
 
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
 
-  const isPageExist = currentPageId != null;
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isTopPagePath = isTopPage(currentPagePath ?? '');
 
 
+  const revision = currentPage?.revision;
+
   return (
   return (
     <>
     <>
       <TabContent activeTab={editorMode}>
       <TabContent activeTab={editorMode}>
         <TabPane tabId={EditorMode.View}>
         <TabPane tabId={EditorMode.View}>
           <div className="d-flex flex-column flex-lg-row-reverse">
           <div className="d-flex flex-column flex-lg-row-reverse">
 
 
-            { isPageExist && !isEmptyPage && (
+            { !isNotFound && !currentPage?.isEmpty && (
               <div className="grw-side-contents-container">
               <div className="grw-side-contents-container">
                 <div className="grw-side-contents-sticky-container">
                 <div className="grw-side-contents-sticky-container">
 
 
@@ -82,12 +85,13 @@ const DisplaySwitcher = (): JSX.Element => {
                   </div>
                   </div>
 
 
                   {/* Comments */}
                   {/* Comments */}
-                  { getCommentListDom != null && !isTopPagePath && (
+                  {/* { getCommentListDom != null && !isTopPagePath && ( */}
+                  { !isTopPagePath && (
                     <div className="grw-page-accessories-control mt-2">
                     <div className="grw-page-accessories-control mt-2">
                       <button
                       <button
                         type="button"
                         type="button"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
                         className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                        // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
                       >
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
                         <span>Comments</span>
@@ -98,7 +102,7 @@ const DisplaySwitcher = (): JSX.Element => {
 
 
                   <div className="d-none d-lg-block">
                   <div className="d-none d-lg-block">
                     <div id="revision-toc" className="revision-toc">
                     <div id="revision-toc" className="revision-toc">
-                      <TableOfContents />
+                      {/* <TableOfContents /> */}
                     </div>
                     </div>
                     <ContentLinkButtons />
                     <ContentLinkButtons />
                   </div>
                   </div>
@@ -109,7 +113,9 @@ const DisplaySwitcher = (): JSX.Element => {
 
 
             <div className="flex-grow-1 flex-basis-0 mw-0">
             <div className="flex-grow-1 flex-basis-0 mw-0">
               { isUserPage && <UserInfo pageUser={pageUser} />}
               { isUserPage && <UserInfo pageUser={pageUser} />}
-              <Page />
+              {/* { !isNotFound && <Page /> } */}
+              { !isNotFound && revision != null && isPopulated(revision) && revision.body }
+              { isNotFound && <NotFoundPage /> }
             </div>
             </div>
 
 
           </div>
           </div>
@@ -117,14 +123,14 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
             <div data-testid="page-editor" id="page-editor">
-              <PageEditor />
+              {/* <PageEditor /> */}
             </div>
             </div>
           </TabPane>
           </TabPane>
         ) }
         ) }
         { isEditable && (
         { isEditable && (
           <TabPane tabId={EditorMode.HackMD}>
           <TabPane tabId={EditorMode.HackMD}>
             <div id="page-editor-with-hackmd">
             <div id="page-editor-with-hackmd">
-              <PageEditorByHackmd />
+              {/* <PageEditorByHackmd /> */}
             </div>
             </div>
           </TabPane>
           </TabPane>
         ) }
         ) }

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

@@ -52,5 +52,6 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
 
 
 });
 });
 
 
+RenderTagLabels.displayName = 'RenderTagLabels';
 
 
 export default RenderTagLabels;
 export default RenderTagLabels;

+ 2 - 3
packages/app/src/components/Page/TagsInput.tsx

@@ -38,11 +38,10 @@ const TagsInput: FC<Props> = (props: Props) => {
   const searchHandler = useCallback(async(query: string) => {
   const searchHandler = useCallback(async(query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
     setSearchQuery(query);
-
-    tagsSearchData.unshift(searchQuery);
+    tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
     setResultTags(Array.from(new Set(tagsSearchData)));
 
 
-  }, [searchQuery, tagsSearch?.tags]);
+  }, [tagsSearch?.tags]);
 
 
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
   const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
     if (event.key === ' ') {
     if (event.key === ' ') {

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

@@ -217,5 +217,6 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   );
   );
 });
 });
 
 
+PageComment.displayName = 'PageComment';
 
 
 export default PageComment;
 export default PageComment;

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

@@ -29,5 +29,6 @@ const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
   );
   );
 });
 });
 
 
+PageContentFooter.displayName = 'PageContentFooter';
 
 
 export default PageContentFooter;
 export default PageContentFooter;

+ 18 - 4
packages/app/src/components/PageEditor.tsx

@@ -18,6 +18,7 @@ import {
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
+  useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import {
 import {
   EditorMode,
   EditorMode,
@@ -96,6 +97,7 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
   const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
@@ -129,7 +131,7 @@ const PageEditor = (props: Props): JSX.Element => {
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
       const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
       const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
@@ -144,7 +146,19 @@ const PageEditor = (props: Props): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       pageContainer.showErrorToastr(error);
       pageContainer.showErrorToastr(error);
     }
     }
-  }, [editorContainer, editorMode, grant, grantGroupId, grantGroupName, isSlackEnabled, slackChannelsData, markdown, pageContainer, pageTags]);
+  }, [
+    editorContainer,
+    editorMode,
+    grant,
+    grantGroupId,
+    grantGroupName,
+    isSlackEnabled,
+    slackChannelsData,
+    markdown,
+    pageContainer,
+    pageTags,
+    mutateIsEnabledUnsavedWarning,
+  ]);
 
 
 
 
   /**
   /**
@@ -355,9 +369,9 @@ const PageEditor = (props: Props): JSX.Element => {
   useEffect(() => {
   useEffect(() => {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     if (pageContainer.state.markdown! !== markdown) {
     if (pageContainer.state.markdown! !== markdown) {
-      editorContainer.enableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(true);
     }
     }
-  }, [editorContainer, markdown, pageContainer.state.markdown]);
+  }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
 
 
   // Detect indent size from contents (only when users are allowed to change it)
   // Detect indent size from contents (only when users are allowed to change it)
   useEffect(() => {
   useEffect(() => {

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

@@ -401,4 +401,6 @@ const EditorWrapper = React.forwardRef((props, ref) => {
   );
   );
 });
 });
 
 
+EditorWrapper.displayName = 'EditorWrapper';
+
 export default EditorWrapper;
 export default EditorWrapper;

+ 4 - 5
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
 
 
 
 
-import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
-import { useCurrentPagePath } from '~/stores/context';
+import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
 import {
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
   EditorMode, useDrawerOpened, useEditorMode, useIsDeviceSmallerThanMd,
@@ -27,13 +26,14 @@ const EditorNavbarBottom = (props) => {
   const [isExpanded, setExpanded] = useState(false);
   const [isExpanded, setExpanded] = useState(false);
 
 
   const [isSlackExpanded, setSlackExpanded] = useState(false);
   const [isSlackExpanded, setSlackExpanded] = useState(false);
-  const isSlackConfigured = props.appContainer.getConfig().isSlackConfigured;
 
 
+  const { data: isSlackConfigured } = useIsSlackConfigured();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const additionalClasses = ['grw-editor-navbar-bottom'];
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
 
 
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
   const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
@@ -153,8 +153,7 @@ const EditorNavbarBottom = (props) => {
 };
 };
 
 
 EditorNavbarBottom.propTypes = {
 EditorNavbarBottom.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 };
 };
 
 
-export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer, AppContainer]);
+export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer]);

+ 8 - 19
packages/app/src/components/PageEditor/OptionsSelector.tsx

@@ -7,12 +7,10 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useIsTextlintEnabled, useCurrentIndentSize } from '~/stores/editor';
 import { useEditorSettings, useIsTextlintEnabled, useCurrentIndentSize } from '~/stores/editor';
 
 
 import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
 import { DEFAULT_THEME, KeyMapMode } from '../../interfaces/editor-settings';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import { DownloadDictModal } from './DownloadDictModal';
 import { DownloadDictModal } from './DownloadDictModal';
 
 
@@ -118,6 +116,7 @@ const KeymapSelector = memo((): JSX.Element => {
 
 
 });
 });
 
 
+KeymapSelector.displayName = 'KeymapSelector';
 
 
 type IndentSizeSelectorProps = {
 type IndentSizeSelectorProps = {
   isIndentSizeForced: boolean,
   isIndentSizeForced: boolean,
@@ -160,13 +159,14 @@ const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onCha
 
 
 });
 });
 
 
+IndentSizeSelector.displayName = 'IndentSizeSelector';
+
 
 
 type ConfigurationDropdownProps = {
 type ConfigurationDropdownProps = {
-  isMathJaxEnabled: boolean,
   onConfirmEnableTextlint?: () => void,
   onConfirmEnableTextlint?: () => void,
 }
 }
 
 
-const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
+const ConfigurationDropdown = memo(({ onConfirmEnableTextlint }: ConfigurationDropdownProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const [isCddMenuOpened, setCddMenuOpened] = useState(false);
   const [isCddMenuOpened, setCddMenuOpened] = useState(false);
@@ -204,10 +204,6 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
       return <></>;
       return <></>;
     }
     }
 
 
-    if (!isMathJaxEnabled) {
-      return <></>;
-    }
-
     const isActive = editorSettings.renderMathJaxInRealtime;
     const isActive = editorSettings.renderMathJaxInRealtime;
 
 
     const iconClasses = ['text-info'];
     const iconClasses = ['text-info'];
@@ -225,7 +221,7 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
         </div>
         </div>
       </DropdownItem>
       </DropdownItem>
     );
     );
-  }, [editorSettings, isMathJaxEnabled, update]);
+  }, [editorSettings, update]);
 
 
   const renderRealtimeDrawioMenuItem = useCallback(() => {
   const renderRealtimeDrawioMenuItem = useCallback(() => {
     if (editorSettings == null) {
     if (editorSettings == null) {
@@ -341,15 +337,10 @@ const ConfigurationDropdown = memo(({ isMathJaxEnabled, onConfirmEnableTextlint
 
 
 });
 });
 
 
+ConfigurationDropdown.displayName = 'ConfigurationDropdown';
 
 
-type Props = {
-  appContainer: AppContainer
-};
-
-const OptionsSelector = (props: Props): JSX.Element => {
-  const { appContainer } = props;
-  const config = appContainer.config;
 
 
+const OptionsSelector = (): JSX.Element => {
   const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
   const [isDownloadDictModalShown, setDownloadDictModalShown] = useState(false);
 
 
   const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
   const { data: editorSettings, turnOffAskingBeforeDownloadLargeFiles } = useEditorSettings();
@@ -379,7 +370,6 @@ const OptionsSelector = (props: Props): JSX.Element => {
         </span>
         </span>
         <span className="ml-2 ml-sm-4">
         <span className="ml-2 ml-sm-4">
           <ConfigurationDropdown
           <ConfigurationDropdown
-            isMathJaxEnabled={!!config.env.MATHJAX}
             onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
             onConfirmEnableTextlint={() => setDownloadDictModalShown(true)}
           />
           />
         </span>
         </span>
@@ -406,5 +396,4 @@ const OptionsSelector = (props: Props): JSX.Element => {
 };
 };
 
 
 
 
-const OptionsSelectorWrapper = withUnstatedContainers(OptionsSelector, [AppContainer]);
-export default OptionsSelectorWrapper;
+export default OptionsSelector;

+ 4 - 0
packages/app/src/components/PageEditor/Preview.tsx

@@ -104,6 +104,8 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
 
 
 });
 });
 
 
+Preview.displayName = 'Preview';
+
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
@@ -114,4 +116,6 @@ const PreviewWrapper2 = React.forwardRef((props: Props, ref: RefObject<HTMLDivEl
   return <PreviewWrapper ref={ref} {...props} />;
   return <PreviewWrapper ref={ref} {...props} />;
 });
 });
 
 
+PreviewWrapper2.displayName = 'PreviewWrapper2';
+
 export default PreviewWrapper2;
 export default PreviewWrapper2;

+ 14 - 10
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,8 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-
+import PropTypes from 'prop-types';
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import EditorContainer from '~/client/services/EditorContainer';
@@ -10,7 +9,9 @@ import PageContainer from '~/client/services/PageContainer';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
 import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import {
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -172,13 +173,13 @@ class PageEditorByHackmd extends React.Component {
    */
    */
   async onSaveWithShortcut(markdown) {
   async onSaveWithShortcut(markdown) {
     const {
     const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags,
+      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     } = this.props;
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
-      editorContainer.disableUnsavedWarning();
+      mutateIsEnabledUnsavedWarning(false);
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
       const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
       const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
@@ -200,7 +201,7 @@ class PageEditorByHackmd extends React.Component {
    */
    */
   async hackmdEditorChangeHandler(body) {
   async hackmdEditorChangeHandler(body) {
     const hackmdUri = this.getHackmdUri();
     const hackmdUri = this.getHackmdUri();
-    const { pageContainer, editorContainer } = this.props;
+    const { pageContainer, mutateIsEnabledUnsavedWarning } = this.props;
 
 
     if (hackmdUri == null) {
     if (hackmdUri == null) {
       // do nothing
       // do nothing
@@ -213,7 +214,7 @@ class PageEditorByHackmd extends React.Component {
     }
     }
 
 
     // enable unsaved warning
     // enable unsaved warning
-    editorContainer.enableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(true);
 
 
     const params = {
     const params = {
       pageId: pageContainer.state.pageId,
       pageId: pageContainer.state.pageId,
@@ -311,7 +312,7 @@ class PageEditorByHackmd extends React.Component {
           { !isHackmdDocumentOutdated && (
           { !isHackmdDocumentOutdated && (
             <div className="text-center hackmd-resume-button-container mb-3">
             <div className="text-center hackmd-resume-button-container mb-3">
               <button
               <button
-                className="btn btn-success btn-lg waves-effect waves-light"
+                className="btn btn-success btn-lg"
                 type="button"
                 type="button"
                 disabled={this.state.isInitializing}
                 disabled={this.state.isInitializing}
                 onClick={() => { return this.resumeToEdit() }}
                 onClick={() => { return this.resumeToEdit() }}
@@ -324,7 +325,7 @@ class PageEditorByHackmd extends React.Component {
 
 
           <div className="text-center hackmd-discard-button-container mb-3">
           <div className="text-center hackmd-discard-button-container mb-3">
             <button
             <button
-              className="btn btn-outline-secondary btn-lg waves-effect waves-light"
+              className="btn btn-outline-secondary btn-lg"
               type="button"
               type="button"
               onClick={() => { return this.discardChanges() }}
               onClick={() => { return this.discardChanges() }}
             >
             >
@@ -347,7 +348,7 @@ class PageEditorByHackmd extends React.Component {
           <p className="text-muted text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <p className="text-muted text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
           <div className="text-center hackmd-start-button-container mb-3">
           <div className="text-center hackmd-start-button-container mb-3">
             <button
             <button
-              className="btn btn-info btn-lg waves-effect waves-light"
+              className="btn btn-info btn-lg"
               type="button"
               type="button"
               disabled={isRevisionOutdated || this.state.isInitializing}
               disabled={isRevisionOutdated || this.state.isInitializing}
               onClick={() => { return this.startToEdit() }}
               onClick={() => { return this.startToEdit() }}
@@ -439,6 +440,7 @@ PageEditorByHackmd.propTypes = {
   grant: PropTypes.number.isRequired,
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
   grantGroupName: PropTypes.string,
+  mutateIsEnabledUnsavedWarning: PropTypes.func,
 };
 };
 
 
 /**
 /**
@@ -457,6 +459,7 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
   if (editorMode == null) {
   if (editorMode == null) {
     return null;
     return null;
@@ -473,6 +476,7 @@ const PageEditorByHackmdWrapper = (props) => {
       grant={grant}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       grantGroupName={grantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
     />
   );
   );
 };
 };

+ 35 - 59
packages/app/src/components/PageHistory.jsx

@@ -1,66 +1,45 @@
-import React, { useCallback } from 'react';
-import PropTypes from 'prop-types';
-import loggerFactory from '~/utils/logger';
+import React, { useState, useEffect } from 'react';
 
 
-import { withUnstatedContainers } from './UnstatedUtils';
-import { toastError } from '~/client/util/apiNotification';
+import { useCurrentPageId } from '~/stores/context';
+import { useSWRxPageRevisions } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
 
 
-import { withLoadingSppiner } from './SuspenseUtils';
 import PageRevisionTable from './PageHistory/PageRevisionTable';
 import PageRevisionTable from './PageHistory/PageRevisionTable';
-
-import PageHistroyContainer from '~/client/services/PageHistoryContainer';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 import RevisionComparer from './RevisionComparer/RevisionComparer';
 import RevisionComparer from './RevisionComparer/RevisionComparer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 
 
 const logger = loggerFactory('growi:PageHistory');
 const logger = loggerFactory('growi:PageHistory');
 
 
-function PageHistory(props) {
-  const { pageHistoryContainer, revisionComparerContainer } = props;
-  const { getPreviousRevision } = pageHistoryContainer;
-  const {
-    activePage, totalPages, pagingLimit, revisions, diffOpened,
-  } = pageHistoryContainer.state;
+const PageHistory = () => {
+  const [activePage, setActivePage] = useState(1);
+  const { data: currentPageId } = useCurrentPageId();
+  const { data: revisionsData } = useSWRxPageRevisions(currentPageId, activePage, 10);
+  const [sourceRevision, setSourceRevision] = useState(null);
+  const [targetRevision, setTargetRevision] = useState(null);
 
 
-  const handlePage = useCallback(async(selectedPage) => {
-    try {
-      await props.pageHistoryContainer.retrieveRevisions(selectedPage);
-    }
-    catch (err) {
-      toastError(err);
-      props.pageHistoryContainer.setState({ errorMessage: err.message });
-      logger.error(err);
+  useEffect(() => {
+    if (revisionsData != null) {
+      setSourceRevision(revisionsData.revisions[0]);
+      setTargetRevision(revisionsData.revisions[0]);
     }
     }
-  }, [props.pageHistoryContainer]);
+  }, [revisionsData]);
 
 
-  if (pageHistoryContainer.state.errorMessage != null) {
+
+  const pagingLimit = 10;
+
+  if (revisionsData == null) {
     return (
     return (
-      <div className="my-5">
-        <div className="text-danger">{pageHistoryContainer.state.errorMessage}</div>
+      <div className="text-muted text-center">
+        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
       </div>
       </div>
     );
     );
   }
   }
-
-  if (pageHistoryContainer.state.revisions === pageHistoryContainer.dummyRevisions) {
-    throw new Promise(async() => {
-      try {
-        await props.pageHistoryContainer.retrieveRevisions(1);
-        await props.revisionComparerContainer.initRevisions();
-      }
-      catch (err) {
-        toastError(err);
-        pageHistoryContainer.setState({ errorMessage: err.message });
-        logger.error(err);
-      }
-    });
-  }
-
   function pager() {
   function pager() {
     return (
     return (
       <PaginationWrapper
       <PaginationWrapper
         activePage={activePage}
         activePage={activePage}
-        changePage={handlePage}
-        totalItemsCount={totalPages}
+        changePage={setActivePage}
+        totalItemsCount={revisionsData.totalCounts}
         pagingLimit={pagingLimit}
         pagingLimit={pagingLimit}
         align="center"
         align="center"
       />
       />
@@ -70,26 +49,23 @@ function PageHistory(props) {
   return (
   return (
     <div className="revision-history" data-testid="page-history">
     <div className="revision-history" data-testid="page-history">
       <PageRevisionTable
       <PageRevisionTable
-        pageHistoryContainer={pageHistoryContainer}
-        revisionComparerContainer={revisionComparerContainer}
-        revisions={revisions}
-        diffOpened={diffOpened}
-        getPreviousRevision={getPreviousRevision}
+        revisions={revisionsData.revisions}
+        pagingLimit={pagingLimit}
+        sourceRevision={sourceRevision}
+        targetRevision={targetRevision}
+        onChangeSourceInvoked={setSourceRevision}
+        onChangeTargetInvoked={setTargetRevision}
       />
       />
       <div className="my-3">
       <div className="my-3">
         {pager()}
         {pager()}
       </div>
       </div>
-      <RevisionComparer />
+      <RevisionComparer
+        sourceRevision={sourceRevision}
+        targetRevision={targetRevision}
+        currentPageId={currentPageId}
+      />
     </div>
     </div>
   );
   );
-
-}
-
-const RenderPageHistoryWrapper = withUnstatedContainers(withLoadingSppiner(PageHistory), [PageHistroyContainer, RevisionComparerContainer]);
-
-PageHistory.propTypes = {
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 };
 };
 
 
-export default RenderPageHistoryWrapper;
+export default PageHistory;

+ 32 - 33
packages/app/src/components/PageHistory/PageRevisionTable.jsx

@@ -3,9 +3,6 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import PageHistroyContainer from '~/client/services/PageHistoryContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-
 import Revision from './Revision';
 import Revision from './Revision';
 
 
 class PageRevisionTable extends React.Component {
 class PageRevisionTable extends React.Component {
@@ -17,21 +14,20 @@ class PageRevisionTable extends React.Component {
    * @param {boolean} hasDiff whether revision has difference to previousRevision
    * @param {boolean} hasDiff whether revision has difference to previousRevision
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    * @param {boolean} isContiguousNodiff true if the current 'hasDiff' and one of previous row is both false
    */
    */
-  renderRow(revision, previousRevision, hasDiff, isContiguousNodiff) {
-    const { revisionComparerContainer, t } = this.props;
-    const { latestRevision, oldestRevision } = this.props.pageHistoryContainer.state;
+  renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff) {
+    const {
+      t, sourceRevision, targetRevision, onChangeSourceInvoked, onChangeTargetInvoked,
+    } = this.props;
     const revisionId = revision._id;
     const revisionId = revision._id;
-    const revisionDiffOpened = this.props.diffOpened[revisionId] || false;
-    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
 
     const handleCompareLatestRevisionButton = () => {
     const handleCompareLatestRevisionButton = () => {
-      revisionComparerContainer.setState({ sourceRevision: revision });
-      revisionComparerContainer.setState({ targetRevision: latestRevision });
+      onChangeSourceInvoked(revision);
+      onChangeTargetInvoked(latestRevision);
     };
     };
 
 
     const handleComparePreviousRevisionButton = () => {
     const handleComparePreviousRevisionButton = () => {
-      revisionComparerContainer.setState({ sourceRevision: previousRevision });
-      revisionComparerContainer.setState({ targetRevision: revision });
+      onChangeSourceInvoked(previousRevision);
+      onChangeTargetInvoked(revision);
     };
     };
 
 
     return (
     return (
@@ -42,7 +38,6 @@ class PageRevisionTable extends React.Component {
               t={this.props.t}
               t={this.props.t}
               revision={revision}
               revision={revision}
               isLatestRevision={revision === latestRevision}
               isLatestRevision={revision === latestRevision}
-              revisionDiffOpened={revisionDiffOpened}
               hasDiff={hasDiff}
               hasDiff={hasDiff}
               key={`revision-history-rev-${revisionId}`}
               key={`revision-history-rev-${revisionId}`}
             />
             />
@@ -60,7 +55,7 @@ class PageRevisionTable extends React.Component {
                     type="button"
                     type="button"
                     className="btn btn-outline-secondary btn-sm"
                     className="btn btn-outline-secondary btn-sm"
                     onClick={handleComparePreviousRevisionButton}
                     onClick={handleComparePreviousRevisionButton}
-                    disabled={revision === oldestRevision}
+                    disabled={isOldestRevision}
                   >
                   >
                     {t('page_history.compare_previous')}
                     {t('page_history.compare_previous')}
                   </button>
                   </button>
@@ -70,34 +65,34 @@ class PageRevisionTable extends React.Component {
           </div>
           </div>
         </td>
         </td>
         <td className="col-1">
         <td className="col-1">
-          {(hasDiff || revision._id === sourceRevision?._id) && (
+          {(hasDiff || revisionId === sourceRevision?._id) && (
             <div className="custom-control custom-radio custom-control-inline mr-0">
             <div className="custom-control custom-radio custom-control-inline mr-0">
               <input
               <input
                 type="radio"
                 type="radio"
                 className="custom-control-input"
                 className="custom-control-input"
-                id={`compareSource-${revision._id}`}
+                id={`compareSource-${revisionId}`}
                 name="compareSource"
                 name="compareSource"
-                value={revision._id}
-                checked={revision._id === sourceRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ sourceRevision: revision })}
+                value={revisionId}
+                checked={revisionId === sourceRevision?._id}
+                onChange={() => onChangeSourceInvoked(revision)}
               />
               />
-              <label className="custom-control-label" htmlFor={`compareSource-${revision._id}`} />
+              <label className="custom-control-label" htmlFor={`compareSource-${revisionId}`} />
             </div>
             </div>
           )}
           )}
         </td>
         </td>
         <td className="col-2">
         <td className="col-2">
-          {(hasDiff || revision._id === targetRevision?._id) && (
+          {(hasDiff || revisionId === targetRevision?._id) && (
             <div className="custom-control custom-radio custom-control-inline mr-0">
             <div className="custom-control custom-radio custom-control-inline mr-0">
               <input
               <input
                 type="radio"
                 type="radio"
                 className="custom-control-input"
                 className="custom-control-input"
-                id={`compareTarget-${revision._id}`}
+                id={`compareTarget-${revisionId}`}
                 name="compareTarget"
                 name="compareTarget"
-                value={revision._id}
-                checked={revision._id === targetRevision?._id}
-                onChange={() => revisionComparerContainer.setState({ targetRevision: revision })}
+                value={revisionId}
+                checked={revisionId === targetRevision?._id}
+                onChange={() => onChangeTargetInvoked(revision)}
               />
               />
-              <label className="custom-control-label" htmlFor={`compareTarget-${revision._id}`} />
+              <label className="custom-control-label" htmlFor={`compareTarget-${revisionId}`} />
             </div>
             </div>
           )}
           )}
         </td>
         </td>
@@ -106,16 +101,18 @@ class PageRevisionTable extends React.Component {
   }
   }
 
 
   render() {
   render() {
-    const { t, pageHistoryContainer } = this.props;
+    const { t, pagingLimit } = this.props;
 
 
     const revisions = this.props.revisions;
     const revisions = this.props.revisions;
     const revisionCount = this.props.revisions.length;
     const revisionCount = this.props.revisions.length;
+    const latestRevision = revisions[0];
+    const oldestRevision = revisions[revisions.length - 1];
 
 
     let hasDiffPrev;
     let hasDiffPrev;
 
 
     const revisionList = this.props.revisions.map((revision, idx) => {
     const revisionList = this.props.revisions.map((revision, idx) => {
       // Returns null because the last revision is for the bottom diff display
       // Returns null because the last revision is for the bottom diff display
-      if (idx === pageHistoryContainer.state.pagingLimit) {
+      if (idx === pagingLimit) {
         return null;
         return null;
       }
       }
 
 
@@ -127,13 +124,13 @@ class PageRevisionTable extends React.Component {
         previousRevision = revision; // if it is the first revision, show full text as diff text
         previousRevision = revision; // if it is the first revision, show full text as diff text
       }
       }
 
 
+      const isOldestRevision = revision === oldestRevision;
 
 
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
       const hasDiff = revision.hasDiffToPrev !== false; // set 'true' if undefined for backward compatibility
-      const isContiguousNodiff = !hasDiff && !hasDiffPrev;
 
 
       hasDiffPrev = hasDiff;
       hasDiffPrev = hasDiff;
 
 
-      return this.renderRow(revision, previousRevision, hasDiff, isContiguousNodiff);
+      return this.renderRow(revision, previousRevision, latestRevision, isOldestRevision, hasDiff);
     });
     });
 
 
     return (
     return (
@@ -156,11 +153,13 @@ class PageRevisionTable extends React.Component {
 
 
 PageRevisionTable.propTypes = {
 PageRevisionTable.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  pageHistoryContainer: PropTypes.instanceOf(PageHistroyContainer).isRequired,
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
 
 
   revisions: PropTypes.array,
   revisions: PropTypes.array,
-  diffOpened: PropTypes.object,
+  pagingLimit: PropTypes.number,
+  sourceRevision: PropTypes.instanceOf(Object),
+  targetRevision: PropTypes.instanceOf(Object),
+  onChangeSourceInvoked: PropTypes.func.isRequired,
+  onChangeTargetInvoked: PropTypes.func.isRequired,
 };
 };
 
 
 const PageRevisionTableWrapperFC = (props) => {
 const PageRevisionTableWrapperFC = (props) => {

+ 2 - 2
packages/app/src/components/PageHistory/Revision.jsx

@@ -1,7 +1,8 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
+import PropTypes from 'prop-types';
+
 import UserDate from '../User/UserDate';
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
 import Username from '../User/Username';
 
 
@@ -83,6 +84,5 @@ Revision.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
   revision: PropTypes.object,
   revision: PropTypes.object,
   isLatestRevision: PropTypes.bool.isRequired,
   isLatestRevision: PropTypes.bool.isRequired,
-  revisionDiffOpened: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
   hasDiff: PropTypes.bool.isRequired,
 };
 };

+ 2 - 0
packages/app/src/components/PaginationWrapper.tsx

@@ -160,6 +160,8 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
 
 
 });
 });
 
 
+PaginationWrapper.displayName = 'PaginationWrapper';
+
 PaginationWrapper.defaultProps = {
 PaginationWrapper.defaultProps = {
   align: 'left',
   align: 'left',
   size: 'md',
   size: 'md',

+ 4 - 0
packages/app/src/components/PrivateLegacyPages.tsx

@@ -128,6 +128,8 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   );
   );
 });
 });
 
 
+SearchResultListHead.displayName = 'SearchResultListHead';
+
 /*
 /*
  * ConvertByPathModal
  * ConvertByPathModal
  */
  */
@@ -182,6 +184,8 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   );
   );
 });
 });
 
 
+ConvertByPathModal.displayName = 'ConvertByPathModal';
+
 /**
 /**
  * LegacyPage
  * LegacyPage
  */
  */

+ 24 - 26
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
@@ -8,11 +8,9 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
+import { useCurrentPagePath } from '~/stores/context';
 
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
 import RevisionDiff from '../PageHistory/RevisionDiff';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
 const { encodeSpaces } = pagePathUtils;
 const { encodeSpaces } = pagePathUtils;
@@ -29,12 +27,13 @@ const DropdownItemContents = ({ title, contents }) => (
 
 
 const RevisionComparer = (props) => {
 const RevisionComparer = (props) => {
 
 
-  const [dropdownOpen, setDropdownOpen] = useState(false);
-
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { revisionComparerContainer } = props;
-
-  const { path, pageId } = revisionComparerContainer.pageContainer.state;
+  const { data: currentPagePath } = useCurrentPagePath();
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+  const {
+    sourceRevision, targetRevision,
+    currentPageId,
+  } = props;
 
 
   function toggleDropdown() {
   function toggleDropdown() {
     setDropdownOpen(!dropdownOpen);
     setDropdownOpen(!dropdownOpen);
@@ -42,7 +41,6 @@ const RevisionComparer = (props) => {
 
 
   const generateURL = (pathName) => {
   const generateURL = (pathName) => {
     const { origin } = window.location;
     const { origin } = window.location;
-    const { sourceRevision, targetRevision } = revisionComparerContainer.state;
 
 
     const url = new URL(pathName, origin);
     const url = new URL(pathName, origin);
 
 
@@ -55,13 +53,17 @@ const RevisionComparer = (props) => {
 
 
   };
   };
 
 
-  const { sourceRevision, targetRevision } = revisionComparerContainer.state;
-
+  let isNodiff;
   if (sourceRevision == null || targetRevision == null) {
   if (sourceRevision == null || targetRevision == null) {
-    return null;
+    isNodiff = true;
+  }
+  else {
+    isNodiff = sourceRevision._id === targetRevision._id;
   }
   }
 
 
-  const isNodiff = sourceRevision._id === targetRevision._id;
+  if (currentPageId == null || currentPagePath == null) {
+    return <>{ t('not_found_page.page_not_exist')}</>;
+  }
 
 
   return (
   return (
     <div className="revision-compare">
     <div className="revision-compare">
@@ -80,15 +82,15 @@ const RevisionComparer = (props) => {
           </DropdownToggle>
           </DropdownToggle>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
           <DropdownMenu positionFixed right modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
             {/* Page path URL */}
             {/* Page path URL */}
-            <CopyToClipboard text={generateURL(path)}>
+            <CopyToClipboard text={generateURL(currentPagePath)}>
               <DropdownItem className="px-3">
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(path)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Page URL')} contents={generateURL(currentPagePath)} />
               </DropdownItem>
               </DropdownItem>
             </CopyToClipboard>
             </CopyToClipboard>
             {/* Permanent Link URL */}
             {/* Permanent Link URL */}
-            <CopyToClipboard text={generateURL(pageId)}>
+            <CopyToClipboard text={generateURL(currentPageId)}>
               <DropdownItem className="px-3">
               <DropdownItem className="px-3">
-                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(pageId)} />
+                <DropdownItemContents title={t('copy_to_clipboard.Permanent link')} contents={generateURL(currentPageId)} />
               </DropdownItem>
               </DropdownItem>
             </CopyToClipboard>
             </CopyToClipboard>
             <DropdownItem divider className="my-0"></DropdownItem>
             <DropdownItem divider className="my-0"></DropdownItem>
@@ -115,13 +117,9 @@ const RevisionComparer = (props) => {
 };
 };
 
 
 RevisionComparer.propTypes = {
 RevisionComparer.propTypes = {
-  revisionComparerContainer: PropTypes.instanceOf(RevisionComparerContainer).isRequired,
-  revisions: PropTypes.array,
+  sourceRevision: PropTypes.instanceOf(Object),
+  targetRevision: PropTypes.instanceOf(Object),
+  currentPageId: PropTypes.string,
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const RevisionComparerWrapper = withUnstatedContainers(RevisionComparer, [RevisionComparerContainer]);
-
-export default RevisionComparerWrapper;
+export default RevisionComparer;

+ 16 - 20
packages/app/src/components/SavePageControls.jsx

@@ -7,15 +7,10 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
-
-// TODO: remove this when omitting unstated is completed
-import { useIsEditable, useCurrentPageId } from '~/stores/context';
-import { usePageTagsForEditors } from '~/stores/editor';
+import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
+import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import {
 import {
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
   useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
 } from '~/stores/ui';
@@ -31,9 +26,6 @@ class SavePageControls extends React.Component {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
-    const config = this.props.appContainer.getConfig();
-    this.isAclEnabled = config.isAclEnabled;
-
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
     this.updateGrantHandler = this.updateGrantHandler.bind(this);
 
 
     this.save = this.save.bind(this);
     this.save = this.save.bind(this);
@@ -51,10 +43,10 @@ class SavePageControls extends React.Component {
 
 
   async save() {
   async save() {
     const {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     } = this.props;
     // disable unsaved warning
     // disable unsaved warning
-    editorContainer.disableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(false);
 
 
     try {
     try {
       // save
       // save
@@ -77,10 +69,10 @@ class SavePageControls extends React.Component {
 
 
   saveAndOverwriteScopesOfDescendants() {
   saveAndOverwriteScopesOfDescendants() {
     const {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, editorContainer, pageTags,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     } = this.props;
     // disable unsaved warning
     // disable unsaved warning
-    editorContainer.disableUnsavedWarning();
+    mutateIsEnabledUnsavedWarning(false);
     // save
     // save
     const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const currentOptionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
     const optionsToSave = Object.assign(currentOptionsToSave, {
     const optionsToSave = Object.assign(currentOptionsToSave, {
@@ -92,7 +84,7 @@ class SavePageControls extends React.Component {
   render() {
   render() {
 
 
     const {
     const {
-      t, pageContainer, grant, grantGroupId, grantGroupName,
+      t, pageContainer, isAclEnabled, grant, grantGroupId, grantGroupName,
     } = this.props;
     } = this.props;
 
 
     const isRootPage = pageContainer.state.path === '/';
     const isRootPage = pageContainer.state.path === '/';
@@ -102,7 +94,7 @@ class SavePageControls extends React.Component {
     return (
     return (
       <div className="d-flex align-items-center form-inline flex-nowrap">
       <div className="d-flex align-items-center form-inline flex-nowrap">
 
 
-        {this.isAclEnabled
+        {isAclEnabled
           && (
           && (
             <div className="mr-2">
             <div className="mr-2">
               <GrantSelector
               <GrantSelector
@@ -135,20 +127,22 @@ class SavePageControls extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [AppContainer, PageContainer, EditorContainer]);
+const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
 
 
 const SavePageControlsWrapper = (props) => {
 const SavePageControlsWrapper = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
+  const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grant, mutate: mutateGrant } = useSelectedGrant();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
   const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
 
 
-  if (isEditable == null || editorMode == null) {
+  if (isEditable == null || editorMode == null || isAclEnabled == null) {
     return null;
     return null;
   }
   }
 
 
@@ -161,12 +155,14 @@ const SavePageControlsWrapper = (props) => {
       t={t}
       t={t}
       {...props}
       {...props}
       editorMode={editorMode}
       editorMode={editorMode}
+      isAclEnabled={isAclEnabled}
       grant={grant}
       grant={grant}
       grantGroupId={grantGroupId}
       grantGroupId={grantGroupId}
       grantGroupName={grantGroupName}
       grantGroupName={grantGroupName}
       mutateGrant={mutateGrant}
       mutateGrant={mutateGrant}
       mutateGrantGroupId={mutateGrantGroupId}
       mutateGrantGroupId={mutateGrantGroupId}
       mutateGrantGroupName={mutateGrantGroupName}
       mutateGrantGroupName={mutateGrantGroupName}
+      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       pageTags={pageTags}
       pageTags={pageTags}
     />
     />
   );
   );
@@ -175,21 +171,21 @@ const SavePageControlsWrapper = (props) => {
 SavePageControls.propTypes = {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 
 
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
 
 
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   isSlackEnabled: PropTypes.bool.isRequired,
   slackChannels: PropTypes.string.isRequired,
   slackChannels: PropTypes.string.isRequired,
   pageTags: PropTypes.arrayOf(PropTypes.string),
   pageTags: PropTypes.arrayOf(PropTypes.string),
+  isAclEnabled: PropTypes.bool.isRequired,
   grant: PropTypes.number.isRequired,
   grant: PropTypes.number.isRequired,
   grantGroupId: PropTypes.string,
   grantGroupId: PropTypes.string,
   grantGroupName: PropTypes.string,
   grantGroupName: PropTypes.string,
   mutateGrant: PropTypes.func,
   mutateGrant: PropTypes.func,
   mutateGrantGroupId: PropTypes.func,
   mutateGrantGroupId: PropTypes.func,
   mutateGrantGroupName: PropTypes.func,
   mutateGrantGroupName: PropTypes.func,
+  mutateIsEnabledUnsavedWarning: PropTypes.func,
 };
 };
 
 
 export default SavePageControlsWrapper;
 export default SavePageControlsWrapper;

+ 3 - 0
packages/app/src/components/SearchForm.tsx

@@ -2,6 +2,7 @@ import React, {
   FC, forwardRef, ForwardRefRenderFunction, useImperativeHandle,
   FC, forwardRef, ForwardRefRenderFunction, useImperativeHandle,
   useRef, useState,
   useRef, useState,
 } from 'react';
 } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IFocusable } from '~/client/interfaces/focusable';
@@ -75,6 +76,8 @@ const SearchFormHelp: FC<SearchFormHelpProps> = React.memo((props: SearchFormHel
   );
   );
 });
 });
 
 
+SearchFormHelp.displayName = 'SearchFormHelp';
+
 
 
 type Props = TypeaheadProps & {
 type Props = TypeaheadProps & {
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,

+ 6 - 3
packages/app/src/components/SearchPage.tsx

@@ -1,20 +1,21 @@
 import React, {
 import React, {
   useCallback, useEffect, useMemo, useRef, useState,
   useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 } from 'react';
-import { useTranslation } from 'next-i18next';
 
 
 import { parse as parseQuerystring } from 'querystring';
 import { parse as parseQuerystring } from 'querystring';
 
 
+import { useTranslation } from 'next-i18next';
+
+
+import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
 import { IFormattedSearchResult } from '~/interfaces/search';
 import { IFormattedSearchResult } from '~/interfaces/search';
-import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { useIsSearchServiceReachable } from '~/stores/context';
 import { useIsSearchServiceReachable } from '~/stores/context';
 import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 import { ISearchConditions, ISearchConfigurations, useSWRxSearch } from '~/stores/search';
 
 
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
 import SearchControl from './SearchPage/SearchControl';
-
 import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 
 
 
 
@@ -84,6 +85,8 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   );
   );
 });
 });
 
 
+SearchResultListHead.displayName = 'SearchResultListHead';
+
 
 
 /**
 /**
  * SearchPage
  * SearchPage

+ 4 - 1
packages/app/src/components/SearchPage/SearchControl.tsx

@@ -1,14 +1,16 @@
 import React, {
 import React, {
   FC, useCallback, useEffect, useState,
   FC, useCallback, useEffect, useState,
 } from 'react';
 } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
 
+import SearchForm from '../SearchForm';
+
 import SearchOptionModal from './SearchOptionModal';
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
 import SortControl from './SortControl';
-import SearchForm from '../SearchForm';
 
 
 type Props = {
 type Props = {
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
@@ -165,5 +167,6 @@ const SearchControl: FC <Props> = React.memo((props: Props) => {
   );
   );
 });
 });
 
 
+SearchControl.displayName = 'SearchControl';
 
 
 export default SearchControl;
 export default SearchControl;

+ 44 - 0
packages/app/src/components/SearchTypeahead.module.scss

@@ -0,0 +1,44 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.search-typeahead :global {
+  position: relative;
+  width: 100%;
+  // corner radius
+  border-top-right-radius: bs.$border-radius;
+  border-bottom-right-radius: bs.$border-radius;
+  .rbt-input-main {
+    padding-right: 36px;
+  }
+  .search-clear {
+    position: absolute;
+    top: 4px;
+    right: 4px;
+    z-index: 3;
+    width: 24px;
+    height: 24px;
+    padding: 0;
+    line-height: 0;
+  }
+
+  .rbt-menu {
+    max-height: none !important;
+    margin-top: 3px;
+
+    li a span {
+      .page-path {
+        display: inline;
+        padding: 0 4px;
+        color: inherit;
+      }
+
+      .page-list-meta {
+        font-size: 0.9em;
+        color: bs.$gray-400;
+
+        > span {
+          margin-right: 0.3rem;
+        }
+      }
+    }
+  }
+}

+ 7 - 5
packages/app/src/components/SearchTypeahead.tsx

@@ -3,17 +3,19 @@ import React, {
   KeyboardEvent, useCallback, useRef, useState, MouseEvent, useEffect,
   KeyboardEvent, useCallback, useRef, useState, MouseEvent, useEffect,
 } from 'react';
 } from 'react';
 
 
-import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
-
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
 import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageSearchMeta } from '~/interfaces/search';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
+import { IPageSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 import { useSWRxSearch } from '~/stores/search';
 
 
 
 
+import styles from './SearchTypeahead.module.scss';
+
+
 type ResetFormButtonProps = {
 type ResetFormButtonProps = {
   input?: string,
   input?: string,
   onReset: (e: MouseEvent<HTMLButtonElement>) => void,
   onReset: (e: MouseEvent<HTMLButtonElement>) => void,
@@ -206,11 +208,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     );
     );
   }, [disableIncrementalSearch, helpElement, input, isForcused]);
   }, [disableIncrementalSearch, helpElement, input, isForcused]);
 
 
-  const isLoading = searchResult == null && searchError == null;
+  const isLoading = searchResult !== undefined && searchError == null;
   const isOpenAlways = helpElement != null;
   const isOpenAlways = helpElement != null;
 
 
   return (
   return (
-    <div className="search-typeahead">
+    <div className={`search-typeahead ${styles['search-typeahead']}`}>
       <AsyncTypeahead
       <AsyncTypeahead
         {...props}
         {...props}
         id="search-typeahead-asynctypeahead"
         id="search-typeahead-asynctypeahead"

+ 6 - 4
packages/app/src/styles/_shortcuts.scss → packages/app/src/components/ShortcutsModal.module.scss

@@ -1,4 +1,6 @@
-#shortcuts-modal {
+@use '~/styles/bootstrap/init' as bs;
+
+.shortcuts-modal :global {
   h3 {
   h3 {
     margin-bottom: 1em;
     margin-bottom: 1em;
   }
   }
@@ -12,7 +14,7 @@
     }
     }
   }
   }
 
 
-  @include media-breakpoint-up(sm) {
+  @include bs.media-breakpoint-up(sm) {
     table {
     table {
       table-layout: fixed;
       table-layout: fixed;
       th {
       th {
@@ -30,7 +32,7 @@
     margin: 0px 4px;
     margin: 0px 4px;
     /*Text Properties*/
     /*Text Properties*/
     font: 18px/36px Helvetica, serif;
     font: 18px/36px Helvetica, serif;
-    color: $secondary;
+    color: bs.$secondary;
     text-align: center;
     text-align: center;
     text-transform: uppercase;
     text-transform: uppercase;
     background: white;
     background: white;
@@ -38,7 +40,7 @@
     box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.5);
     box-shadow: 0px 1px 3px 1px rgba(0, 0, 0, 0.5);
     /* SVG Properties*/
     /* SVG Properties*/
     polygon {
     polygon {
-      fill: $secondary;
+      fill: bs.$secondary;
     }
     }
 
 
     &.key-longer {
     &.key-longer {

+ 10 - 7
packages/app/src/components/ShortcutsModal.tsx

@@ -6,6 +6,9 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import KeyboardReturnEnterIcon from '~/components/Icons/KeyboardReturnEnterIcon';
 import KeyboardReturnEnterIcon from '~/components/Icons/KeyboardReturnEnterIcon';
 import { useShortcutsModal } from '~/stores/modal';
 import { useShortcutsModal } from '~/stores/modal';
 
 
+import styles from './ShortcutsModal.module.scss';
+
+
 const ShortcutsModal = (): JSX.Element => {
 const ShortcutsModal = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -19,7 +22,7 @@ const ShortcutsModal = (): JSX.Element => {
   return (
   return (
     <>
     <>
       { status != null && (
       { status != null && (
-        <Modal id="shortcuts-modal" size="lg" isOpen={status.isOpened} toggle={close} className="grw-create-page">
+        <Modal id="shortcuts-modal" size="lg" isOpen={status.isOpened} toggle={close} className={`shortcuts-modal ${styles['shortcuts-modal']}`}>
           <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
           <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
             {t('Shortcuts')}
             {t('Shortcuts')}
           </ModalHeader>
           </ModalHeader>
@@ -63,33 +66,33 @@ const ShortcutsModal = (): JSX.Element => {
                           {/* eslint-disable-next-line react/no-danger */}
                           {/* eslint-disable-next-line react/no-danger */}
                           <span dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.global.Show Contributors') }} />:
                           <span dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.global.Show Contributors') }} />:
                         </th>
                         </th>
-                        <td>
+                        <td className='text-nowrap'>
                           <a href="{ t('modal_shortcuts.global.konami_code_url') }" target="_blank">
                           <a href="{ t('modal_shortcuts.global.konami_code_url') }" target="_blank">
                             {t('modal_shortcuts.global.Konami Code')}
                             {t('modal_shortcuts.global.Konami Code')}
                           </a>
                           </a>
                           <br />
                           <br />
                           <span className="key key-small">&uarr;</span>&nbsp;<span className="key key-small">&uarr;</span>
                           <span className="key key-small">&uarr;</span>&nbsp;<span className="key key-small">&uarr;</span>
                           <span className="key key-small">&darr;</span>&nbsp;<span className="key key-small">&darr;</span>
                           <span className="key key-small">&darr;</span>&nbsp;<span className="key key-small">&darr;</span>
-                          <span className="key key-small">&larr;</span>
                           <br />
                           <br />
-                          <span className="key key-small">&rarr;</span>
                           <span className="key key-small">&larr;</span>&nbsp;<span className="key key-small">&rarr;</span>
                           <span className="key key-small">&larr;</span>&nbsp;<span className="key key-small">&rarr;</span>
+                          <span className="key key-small">&larr;</span>&nbsp;<span className="key key-small">&rarr;</span>
+                          <br />
                           <span className="key key-small">B</span>&nbsp;<span className="key key-small">A</span>
                           <span className="key key-small">B</span>&nbsp;<span className="key key-small">A</span>
                         </td>
                         </td>
                       </tr>
                       </tr>
                       <tr>
                       <tr>
                         <th>{t('modal_shortcuts.global.MirrorMode')}:</th>
                         <th>{t('modal_shortcuts.global.MirrorMode')}:</th>
-                        <td>
+                        <td className='text-nowrap'>
                           <a href="{ t('modal_shortcuts.global.konami_code_url') }" target="_blank">
                           <a href="{ t('modal_shortcuts.global.konami_code_url') }" target="_blank">
                             {t('modal_shortcuts.global.Konami Code')}
                             {t('modal_shortcuts.global.Konami Code')}
                           </a>
                           </a>
                           <br />
                           <br />
                           <span className="key key-small">X</span>&nbsp;<span className="key key-small">X</span>
                           <span className="key key-small">X</span>&nbsp;<span className="key key-small">X</span>
                           <span className="key key-small">B</span>&nbsp;<span className="key key-small">B</span>
                           <span className="key key-small">B</span>&nbsp;<span className="key key-small">B</span>
-                          <span className="key key-small">A</span>
                           <br />
                           <br />
-                          <span className="key key-small">Y</span>
                           <span className="key key-small">A</span>&nbsp;<span className="key key-small">Y</span>
                           <span className="key key-small">A</span>&nbsp;<span className="key key-small">Y</span>
+                          <span className="key key-small">A</span>&nbsp;<span className="key key-small">Y</span>
+                          <br />
                           <span className="key key-small">&darr;</span>&nbsp;<span className="key key-small">&larr;</span>
                           <span className="key key-small">&darr;</span>&nbsp;<span className="key key-small">&larr;</span>
                         </td>
                         </td>
                       </tr>
                       </tr>

+ 31 - 100
packages/app/src/styles/_sidebar.scss → packages/app/src/components/Sidebar.module.scss

@@ -1,32 +1,17 @@
-.grw-sidebar {
-  $sidebar-nav-button-height: 55px;
-
-  %fukidashi-for-active {
-    position: relative;
-
-    // speech balloon
-    &:after {
-      position: absolute;
-      right: -0.1em;
-      display: block;
-      width: 0;
-      content: '';
-      border: 9px solid transparent;
-      border-right-color: white;
-      border-left-width: 0;
-      transform: translateY(-#{$sidebar-nav-button-height / 2});
-    }
-  }
+@use '~/styles/variables' as var;
+@use '~/styles/mixins';
+@use '~/styles/bootstrap/init' as bs;
 
 
+.grw-sidebar :global {
   // sticky
   // sticky
   position: sticky;
   position: sticky;
-  top: $grw-navbar-border-width;
+  top: var.$grw-navbar-border-width;
 
 
   // set the max value that should be taken when sticky
   // set the max value that should be taken when sticky
-  height: calc(100vh - $grw-navbar-border-width);
+  height: calc(100vh - var.$grw-navbar-border-width);
 
 
   // override @atlaskit/navigation-next styles
   // override @atlaskit/navigation-next styles
-  $navbar-total-height: $grw-navbar-height + $grw-navbar-border-width;
+  $navbar-total-height: var.$grw-navbar-height + var.$grw-navbar-border-width;
   .data-layout-container {
   .data-layout-container {
     display: flex;
     display: flex;
     flex-direction: row;
     flex-direction: row;
@@ -68,7 +53,8 @@
           animation-duration: 0.22s;
           animation-duration: 0.22s;
           animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
           animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
           animation-fill-mode: forwards;
           animation-fill-mode: forwards;
-          .grw-contextual-navigation-sub {
+
+          :global .grw-contextual-navigation-sub {
             box-sizing: border-box;
             box-sizing: border-box;
             display: flex;
             display: flex;
             flex-direction: column;
             flex-direction: column;
@@ -141,9 +127,9 @@
           }
           }
         }
         }
         .hitarea {
         .hitarea {
-          @extend .rounded-pill;
-
           position: absolute;
           position: absolute;
+          border-radius: bs.$rounded-pill;
+
           @include hitarea(30px);
           @include hitarea(30px);
         }
         }
 
 
@@ -169,55 +155,6 @@
     }
     }
   }
   }
 
 
-  .grw-sidebar-nav {
-    height: 100vh;
-
-    .btn {
-      width: $grw-sidebar-nav-width;
-      line-height: 1em;
-      border-radius: 0;
-      box-shadow: none !important;
-
-      // icon opacity
-      &:not(.active) {
-        i {
-          opacity: 0.4;
-        }
-        &:hover,
-        &:focus {
-          i {
-            opacity: 0.7;
-          }
-        }
-      }
-    }
-
-    .grw-sidebar-nav-primary-container {
-      .btn {
-        padding: 1em;
-        i {
-          font-size: 2.3em;
-        }
-
-        &.active {
-          @extend %fukidashi-for-active;
-        }
-      }
-    }
-
-    .grw-sidebar-nav-secondary-container {
-      position: fixed;
-      bottom: 1.5rem;
-
-      .btn {
-        padding: 0.9em;
-        i {
-          font-size: 1.5em;
-        }
-      }
-    }
-  }
-
   .grw-drawer-toggler {
   .grw-drawer-toggler {
     display: none; // invisible in default
     display: none; // invisible in default
   }
   }
@@ -229,14 +166,15 @@
   }
   }
 }
 }
 
 
+
 // Dock Mode
 // Dock Mode
 @mixin dock() {
 @mixin dock() {
-  z-index: $zindex-sticky;
+  z-index: bs.$zindex-sticky;
 
 
   // override @atlaskit/navigation-next styles
   // override @atlaskit/navigation-next styles
-  $navbar-total-height: $grw-navbar-height + $grw-navbar-border-width;
+  $navbar-total-height: var.$grw-navbar-height + var.$grw-navbar-border-width;
   .data-layout-container {
   .data-layout-container {
-    max-height: calc(100vh - #{$grw-navbar-border-width});
+    max-height: calc(100vh - #{var.$grw-navbar-border-width});
   }
   }
   .navigation {
   .navigation {
     position: unset;
     position: unset;
@@ -247,7 +185,7 @@
 
 
 // Drawer Mode
 // Drawer Mode
 @mixin drawer() {
 @mixin drawer() {
-  z-index: $zindex-fixed + 2;
+  z-index: bs.$zindex-fixed + 2;
 
 
   .data-layout-container {
   .data-layout-container {
     position: fixed;
     position: fixed;
@@ -259,7 +197,7 @@
 
 
     // apply transition
     // apply transition
     transition-property: transform;
     transition-property: transform;
-    @include apply-navigation-transition();
+    @include mixins.apply-navigation-transition();
   }
   }
 
 
   &:not(.open) {
   &:not(.open) {
@@ -285,13 +223,13 @@
     position: fixed;
     position: fixed;
     right: -15px;
     right: -15px;
 
 
-    @include media-breakpoint-down(sm) {
+    @include bs.media-breakpoint-down(sm) {
       bottom: 15px;
       bottom: 15px;
       width: 42px;
       width: 42px;
       height: 42px;
       height: 42px;
       font-size: 18px;
       font-size: 18px;
     }
     }
-    @include media-breakpoint-up(md) {
+    @include bs.media-breakpoint-up(md) {
       top: 72px;
       top: 72px;
       width: 50px;
       width: 50px;
       height: 50px;
       height: 50px;
@@ -302,33 +240,26 @@
   }
   }
 }
 }
 
 
-.grw-sidebar {
-  @include media-breakpoint-down(sm) {
+// '&' could not be set after :global
+// workaround from https://github.com/css-modules/css-modules/issues/295#issuecomment-404873976
+.grw-sidebar :global {
+  .grw-sidebar-drawer {
     @include drawer();
     @include drawer();
   }
   }
-  @include media-breakpoint-up(md) {
-    &.grw-sidebar-drawer {
+  .grw-sidebar-dock {
+    @include bs.media-breakpoint-down(sm) {
       @include drawer();
       @include drawer();
     }
     }
-    &:not(.grw-sidebar-drawer) {
+    @include bs.media-breakpoint-up(md) {
       @include dock();
       @include dock();
     }
     }
   }
   }
 }
 }
 
 
-.grw-sidebar-backdrop.modal-backdrop {
-  z-index: $zindex-fixed + 1;
-}
-
-// style to apply when displaying search page
-.growi.on-search {
-  // set sidebar height shown in search page
-  $search-page-sidebar-height: calc(100vh - ($grw-navbar-height + $grw-navbar-border-width));
-
-  .grw-sidebar {
-    height: $search-page-sidebar-height;
-    .data-layout-container {
-      height: 100%;
-    }
+// '&' could not be set after :global
+// workaround from https://github.com/css-modules/css-modules/issues/295#issuecomment-952885628
+.grw-sidebar-backdrop {
+  &:global(.modal-backdrop) {
+    z-index: bs.$zindex-fixed + 1;
   }
   }
 }
 }

+ 80 - 74
packages/app/src/components/Sidebar.tsx

@@ -15,14 +15,16 @@ import {
 import DrawerToggler from './Navbar/DrawerToggler';
 import DrawerToggler from './Navbar/DrawerToggler';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import { NavigationResizeHexagon } from './Sidebar/NavigationResizeHexagon';
 import SidebarContents from './Sidebar/SidebarContents';
 import SidebarContents from './Sidebar/SidebarContents';
-import SidebarNav from './Sidebar/SidebarNav';
+import { SidebarNav } from './Sidebar/SidebarNav';
 import { StickyStretchableScroller } from './StickyStretchableScroller';
 import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 
+import styles from './Sidebar.module.scss';
+
+
 const sidebarMinWidth = 240;
 const sidebarMinWidth = 240;
 const sidebarMinimizeWidth = 20;
 const sidebarMinimizeWidth = 20;
 const sidebarFixedWidthInDrawerMode = 320;
 const sidebarFixedWidthInDrawerMode = 320;
 
 
-
 const GlobalNavigation = () => {
 const GlobalNavigation = () => {
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: currentContents } = useCurrentSidebarContents();
   const { data: currentContents } = useCurrentSidebarContents();
@@ -49,38 +51,40 @@ const GlobalNavigation = () => {
   }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed, scheduleToPut]);
   }, [currentContents, isCollapsed, isDrawerMode, mutateSidebarCollapsed, scheduleToPut]);
 
 
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
   return <SidebarNav onItemSelected={itemSelectedHandler} />;
-};
-
-const SidebarContentsWrapper = () => {
-  const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
-
-  const calcViewHeight = useCallback(() => {
-    const elem = document.querySelector('#grw-sidebar-contents-wrapper');
-    return elem != null
-      ? window.innerHeight - elem?.getBoundingClientRect().top
-      : window.innerHeight;
-  }, []);
-
-  return (
-    <>
-      <div id="grw-sidebar-contents-wrapper" style={{ minHeight: '100%' }}>
-        <StickyStretchableScroller
-          simplebarRef={mutateSidebarScroller}
-          stickyElemSelector=".grw-sidebar"
-          calcViewHeight={calcViewHeight}
-        >
-          <SidebarContents />
-        </StickyStretchableScroller>
-      </div>
 
 
-      <DrawerToggler iconClass="icon-arrow-left" />
-    </>
-  );
 };
 };
 
 
+// const SidebarContentsWrapper = () => {
+//   const { mutate: mutateSidebarScroller } = useSidebarScrollerRef();
+
+//   const calcViewHeight = useCallback(() => {
+//     const elem = document.querySelector('#grw-sidebar-contents-wrapper');
+//     return elem != null
+//       ? window.innerHeight - elem?.getBoundingClientRect().top
+//       : window.innerHeight;
+//   }, []);
+
+//   return (
+//     <>
+//       <div id="grw-sidebar-contents-wrapper" style={{ minHeight: '100%' }}>
+//         <StickyStretchableScroller
+//           simplebarRef={mutateSidebarScroller}
+//           stickyElemSelector=".grw-sidebar"
+//           calcViewHeight={calcViewHeight}
+//         >
+//           <SidebarContents />
+//         </StickyStretchableScroller>
+//       </div>
+
+//       <DrawerToggler iconClass="icon-arrow-left" />
+//     </>
+//   );
+// };
+
 
 
 const Sidebar = (): JSX.Element => {
 const Sidebar = (): JSX.Element => {
-  const { data: isDrawerMode } = useDrawerMode();
+  // const { data: isDrawerMode } = useDrawerMode(); Todo Universalize
+  const isDrawerMode = false; // dummy
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
   const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
   const { data: currentProductNavWidth, mutate: mutateProductNavWidth } = useCurrentProductNavWidth();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
   const { data: isCollapsed, mutate: mutateSidebarCollapsed } = useSidebarCollapsed();
@@ -288,61 +292,63 @@ const Sidebar = (): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      <div className={`grw-sidebar d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : ''} ${isDrawerOpened ? 'open' : ''}`}>
-        <div className="data-layout-container">
-          <div
-            className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`}
-            onMouseEnter={hoverOnHandler}
-            onMouseLeave={hoverOutHandler}
-          >
-            <div className="grw-navigation-wrap">
-              <div className="grw-global-navigation">
-                <GlobalNavigation></GlobalNavigation>
-              </div>
-              <div
-                ref={resizableContainer}
-                className="grw-contextual-navigation"
-                onMouseEnter={hoverOnResizableContainerHandler}
-                onMouseLeave={hoverOutResizableContainerHandler}
-                style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
-              >
-                <div className="grw-contextual-navigation-child">
-                  <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
-                    <SidebarContentsWrapper></SidebarContentsWrapper>
-                  </div>
+      <div className={`grw-sidebar ${styles['grw-sidebar']}`}>
+        <div className={`d-print-none ${isDrawerMode ? 'grw-sidebar-drawer' : 'grw-sidebar-dock'} ${isDrawerOpened ? 'open' : ''}`}>
+          <div className="data-layout-container">
+            <div
+              className={`navigation ${isTransitionEnabled ? 'transition-enabled' : ''}`}
+              onMouseEnter={hoverOnHandler}
+              onMouseLeave={hoverOutHandler}
+            >
+              <div className="grw-navigation-wrap">
+                <div className="grw-global-navigation">
+                  <GlobalNavigation></GlobalNavigation>
                 </div>
                 </div>
-              </div>
-            </div>
-            <div className="grw-navigation-draggable">
-              { isResizableByDrag && (
                 <div
                 <div
-                  className="grw-navigation-draggable-hitarea"
-                  onMouseDown={dragableAreaMouseDownHandler}
+                  ref={resizableContainer}
+                  className="grw-contextual-navigation"
+                  onMouseEnter={hoverOnResizableContainerHandler}
+                  onMouseLeave={hoverOutResizableContainerHandler}
+                  style={{ width: isCollapsed ? sidebarMinimizeWidth : currentProductNavWidth }}
                 >
                 >
-                  <div className="grw-navigation-draggable-hitarea-child"></div>
+                  <div className="grw-contextual-navigation-child">
+                    <div role="group" data-testid="grw-contextual-navigation-sub" className={`grw-contextual-navigation-sub ${showContents ? '' : 'd-none'}`}>
+                      {/* <SidebarContentsWrapper></SidebarContentsWrapper> */}
+                    </div>
+                  </div>
                 </div>
                 </div>
-              ) }
-              <button
-                data-testid="grw-navigation-resize-button"
-                className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
-                type="button"
-                aria-expanded="true"
-                aria-label="Toggle navigation"
-                disabled={isDrawerMode}
-                onClick={toggleNavigationBtnClickHandler}
-              >
-                <span className="hexagon-container" role="presentation">
-                  <NavigationResizeHexagon />
-                </span>
-                <span className="hitarea" role="presentation"></span>
-              </button>
+              </div>
+              <div className="grw-navigation-draggable">
+                { isResizableByDrag && (
+                  <div
+                    className="grw-navigation-draggable-hitarea"
+                    onMouseDown={dragableAreaMouseDownHandler}
+                  >
+                    <div className="grw-navigation-draggable-hitarea-child"></div>
+                  </div>
+                ) }
+                <button
+                  data-testid="grw-navigation-resize-button"
+                  className={`grw-navigation-resize-button ${!isDrawerMode ? 'resizable' : ''} ${isCollapsed ? 'collapsed' : ''} `}
+                  type="button"
+                  aria-expanded="true"
+                  aria-label="Toggle navigation"
+                  disabled={isDrawerMode}
+                  onClick={toggleNavigationBtnClickHandler}
+                >
+                  <span className="hexagon-container" role="presentation">
+                    {/* <NavigationResizeHexagon /> */}
+                  </span>
+                  <span className="hitarea" role="presentation"></span>
+                </button>
+              </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
       { isDrawerOpened && (
       { isDrawerOpened && (
-        <div className="grw-sidebar-backdrop modal-backdrop show" onClick={backdropClickedHandler}></div>
+        <div className={`${styles['grw-sidebar-backdrop']} modal-backdrop show`} onClick={backdropClickedHandler}></div>
       ) }
       ) }
     </>
     </>
   );
   );

+ 4 - 1
packages/app/src/components/Sidebar/PageTree.tsx

@@ -1,10 +1,11 @@
 import React, { FC, memo } from 'react';
 import React, { FC, memo } from 'react';
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import {
 import {
   useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser, useNotFoundTargetPathOrId,
   useCurrentPagePath, useCurrentPageId, useTargetAndAncestors, useIsGuestUser, useNotFoundTargetPathOrId,
 } from '~/stores/context';
 } from '~/stores/context';
+import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 
 
 import ItemsTree from './PageTree/ItemsTree';
 import ItemsTree from './PageTree/ItemsTree';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
 import { PrivateLegacyPagesLink } from './PageTree/PrivateLegacyPagesLink';
@@ -83,4 +84,6 @@ const PageTree: FC = memo(() => {
   );
   );
 });
 });
 
 
+PageTree.displayName = 'PageTree';
+
 export default PageTree;
 export default PageTree;

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

@@ -11,3 +11,5 @@ export const PrivateLegacyPagesLink: FC = memo(() => {
     </a>
     </a>
   );
   );
 });
 });
+
+PrivateLegacyPagesLink.displayName = 'PrivateLegacyPagesLink';

+ 69 - 0
packages/app/src/components/Sidebar/SidebarNav.module.scss

@@ -0,0 +1,69 @@
+@use '~/styles/variables' as var;
+
+.grw-sidebar-nav :global {
+  $sidebar-nav-button-height: 55px;
+
+  %fukidashi-for-active {
+    position: relative;
+
+    // speech balloon
+    &:after {
+      position: absolute;
+      right: -0.1em;
+      display: block;
+      width: 0;
+      content: '';
+      border: 9px solid transparent;
+      border-right-color: white;
+      border-left-width: 0;
+      transform: translateY(-#{$sidebar-nav-button-height / 2});
+    }
+  }
+
+  height: 100vh;
+
+  .btn {
+    width: var.$grw-sidebar-nav-width;
+    line-height: 1em;
+    border-radius: 0;
+    box-shadow: none !important;
+
+    // icon opacity
+    &:not(.active) {
+      i {
+        opacity: 0.4;
+      }
+      &:hover,
+      &:focus {
+        i {
+          opacity: 0.7;
+        }
+      }
+    }
+  }
+
+  .grw-sidebar-nav-primary-container {
+    .btn {
+      padding: 1em;
+      i {
+        font-size: 2.3em;
+      }
+
+      &.active {
+        @extend %fukidashi-for-active;
+      }
+    }
+  }
+
+  .grw-sidebar-nav-secondary-container {
+    position: fixed;
+    bottom: 1.5rem;
+
+    .btn {
+      padding: 0.9em;
+      i {
+        font-size: 1.5em;
+      }
+    }
+  }
+}

+ 6 - 5
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -2,9 +2,11 @@ import React, { FC, memo, useCallback } from 'react';
 
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser, useIsGuestUser } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 
+import styles from './SidebarNav.module.scss';
+
 
 
 type PrimaryItemProps = {
 type PrimaryItemProps = {
   contents: SidebarContentsType,
   contents: SidebarContentsType,
@@ -63,13 +65,14 @@ const SecondaryItem: FC<SecondaryItemProps> = memo((props: SecondaryItemProps) =
     </a>
     </a>
   );
   );
 });
 });
+SecondaryItem.displayName = 'SecondaryItem';
 
 
 
 
 type Props = {
 type Props = {
   onItemSelected: (contents: SidebarContentsType) => void,
   onItemSelected: (contents: SidebarContentsType) => void,
 }
 }
 
 
-const SidebarNav: FC<Props> = (props: Props) => {
+export const SidebarNav: FC<Props> = (props: Props) => {
 
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
 
 
@@ -78,7 +81,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
   const { onItemSelected } = props;
   const { onItemSelected } = props;
 
 
   return (
   return (
-    <div className="grw-sidebar-nav">
+    <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
       <div className="grw-sidebar-nav-primary-container">
       <div className="grw-sidebar-nav-primary-container">
         {/* eslint-disable max-len */}
         {/* eslint-disable max-len */}
         <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
         <PrimaryItem contents={SidebarContentsType.TREE} label="Page Tree" iconName="format_list_bulleted" onItemSelected={onItemSelected} />
@@ -100,5 +103,3 @@ const SidebarNav: FC<Props> = (props: Props) => {
   );
   );
 
 
 };
 };
-
-export default SidebarNav;

+ 22 - 22
packages/app/src/components/StickyStretchableScroller.tsx

@@ -2,9 +2,9 @@ import React, {
   useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
   useEffect, useCallback, ReactNode, useRef, useState, useMemo, RefObject,
 } from 'react';
 } from 'react';
 
 
-import { debounce } from 'throttle-debounce';
-import StickyEvents from 'sticky-events';
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
+import StickyEvents from 'sticky-events';
+import { debounce } from 'throttle-debounce';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -70,26 +70,26 @@ export const StickyStretchableScroller = (props: StickyStretchableScrollerProps)
 
 
   const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
   const resetScrollbarDebounced = useMemo(() => debounce(100, resetScrollbar), [resetScrollbar]);
 
 
-  const stickyChangeHandler = useCallback(() => {
-    logger.debug('StickyEvents.CHANGE detected');
-    resetScrollbarDebounced();
-  }, [resetScrollbarDebounced]);
-
-  // setup effect by sticky event
-  useEffect(() => {
-    // sticky
-    // See: https://github.com/ryanwalters/sticky-events
-    const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
-    stickyEvents.enableEvents();
-    const { stickySelector } = stickyEvents;
-    const elem = document.querySelector(stickySelector);
-    elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-
-    // return clean up handler
-    return () => {
-      elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
-    };
-  }, [stickyElemSelector, stickyChangeHandler]);
+  // const stickyChangeHandler = useCallback(() => {
+  //   logger.debug('StickyEvents.CHANGE detected');
+  //   resetScrollbarDebounced();
+  // }, [resetScrollbarDebounced]);
+
+  // // setup effect by sticky event
+  // useEffect(() => {
+  //   // sticky
+  //   // See: https://github.com/ryanwalters/sticky-events
+  //   const stickyEvents = new StickyEvents({ stickySelector: stickyElemSelector });
+  //   stickyEvents.enableEvents();
+  //   const { stickySelector } = stickyEvents;
+  //   const elem = document.querySelector(stickySelector);
+  //   elem.addEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+
+  //   // return clean up handler
+  //   return () => {
+  //     elem.removeEventListener(StickyEvents.CHANGE, stickyChangeHandler);
+  //   };
+  // }, [stickyElemSelector, stickyChangeHandler]);
 
 
   // setup effect by resizing event
   // setup effect by resizing event
   useEffect(() => {
   useEffect(() => {

+ 6 - 0
packages/app/src/components/SystemVersion.module.scss

@@ -0,0 +1,6 @@
+.system-version {
+  position: fixed;
+  right: 0.5em;
+  bottom: 0;
+  opacity: 0.6;
+}

+ 3 - 1
packages/app/src/components/SystemVersion.tsx

@@ -3,6 +3,8 @@ import React from 'react';
 import { useGrowiVersion } from '~/stores/context';
 import { useGrowiVersion } from '~/stores/context';
 import { useShortcutsModal } from '~/stores/modal';
 import { useShortcutsModal } from '~/stores/modal';
 
 
+import styles from './SystemVersion.module.scss';
+
 const SystemVersion = (): JSX.Element => {
 const SystemVersion = (): JSX.Element => {
 
 
   const { open: openShortcutsModal } = useShortcutsModal();
   const { open: openShortcutsModal } = useShortcutsModal();
@@ -15,7 +17,7 @@ const SystemVersion = (): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
-      <div className="system-version d-none d-md-flex d-edit-none d-print-none align-items-center">
+      <div className={`${styles['system-version']} d-none d-md-flex d-edit-none d-print-none align-items-center`}>
         <span>
         <span>
           <a href="https://growi.org">GROWI</a> {growiVersion}
           <a href="https://growi.org">GROWI</a> {growiVersion}
         </span>
         </span>

+ 2 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -37,6 +37,8 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
 
 
 });
 });
 
 
+TagCloudBox.displayName = 'withLoadingSppiner';
+
 TagCloudBox.defaultProps = defaultProps;
 TagCloudBox.defaultProps = defaultProps;
 
 
 export default TagCloudBox;
 export default TagCloudBox;

+ 2 - 0
packages/app/src/components/UncontrolledCodeMirror.tsx

@@ -53,3 +53,5 @@ export const UncontrolledCodeMirror = forwardRef<UncontrolledCodeMirrorCore, Unc
     />
     />
   );
   );
 });
 });
+
+UncontrolledCodeMirror.displayName = 'UncontrolledCodeMirror';

+ 0 - 5
packages/app/src/interfaces/page-listing-results.ts

@@ -23,11 +23,6 @@ export interface TargetAndAncestors {
 }
 }
 
 
 
 
-export interface IsNotFoundPermalink {
-  isNotFoundPermalink: boolean
-}
-
-
 export interface V5MigrationStatus {
 export interface V5MigrationStatus {
   isV5Compatible : boolean,
   isV5Compatible : boolean,
   migratablePagesCount: number
   migratablePagesCount: number

+ 5 - 0
packages/app/src/interfaces/revision.ts

@@ -8,6 +8,11 @@ export type IRevision = {
   updatedAt: Date,
   updatedAt: Date,
 }
 }
 
 
+export type IRevisionsForPagination = {
+  revisions: IRevision[], // revisions in one pagination
+  totalCounts: number // total counts
+}
+
 export type IRevisionOnConflict = {
 export type IRevisionOnConflict = {
   revisionId: string,
   revisionId: string,
   revisionBody: string,
   revisionBody: string,

+ 59 - 0
packages/app/src/pages/UnsavedAlertDialog.tsx

@@ -0,0 +1,59 @@
+import React, { useCallback, useEffect } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
+
+import { useIsEnabledUnsavedWarning } from '~/stores/editor';
+
+const UnsavedAlertDialog = (): JSX.Element => {
+  const { t } = useTranslation();
+  const router = useRouter();
+  const { data: isEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+
+  const alertUnsavedWarningByBrowser = useCallback((e) => {
+    if (isEnabledUnsavedWarning) {
+      e.preventDefault();
+      // returnValue should be set to show alert dialog
+      // default alert message cannot be changed.
+      // See -> https://developer.mozilla.org/ja/docs/Web/API/Window/beforeunload_event
+      e.returnValue = '';
+      return;
+    }
+  }, [isEnabledUnsavedWarning]);
+
+  const alertUnsavedWarningByNextRouter = useCallback(() => {
+    if (isEnabledUnsavedWarning) {
+    // eslint-disable-next-line no-alert
+      window.alert(t('page_edit.changes_not_saved'));
+    }
+    return;
+  }, [isEnabledUnsavedWarning, t]);
+
+  /*
+  * Route changes by Browser
+  * Example: window.location.href, F5
+  */
+  useEffect(() => {
+    window.addEventListener('beforeunload', alertUnsavedWarningByBrowser);
+    return () => {
+      window.removeEventListener('beforeunload', alertUnsavedWarningByBrowser);
+    };
+  }, [alertUnsavedWarningByBrowser]);
+
+
+  /*
+  * Route changes by Next Router
+  * https://nextjs.org/docs/api-reference/next/router
+  */
+  useEffect(() => {
+    router.events.on('routeChangeStart', alertUnsavedWarningByNextRouter);
+    return () => {
+      router.events.off('routeChangeStart', alertUnsavedWarningByNextRouter);
+    };
+  }, [alertUnsavedWarningByNextRouter, router.events]);
+
+
+  return <></>;
+};
+
+export default UnsavedAlertDialog;

+ 128 - 56
packages/app/src/pages/[[...path]].page.tsx

@@ -1,34 +1,40 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
 
 
-import { pagePathUtils } from '@growi/core';
-import { isValidObjectId } from 'mongoose';
+import { isClient, pagePathUtils, pathUtils } from '@growi/core';
 import {
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
+import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 // import { PageComments } from '~/components/PageComment/PageComments';
 // import { PageComments } from '~/components/PageComment/PageComments';
 // import { useTranslation } from '~/i18n';
 // import { useTranslation } from '~/i18n';
+import { isPopulated } from '~/interfaces/common';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
 // import { useIndentSize } from '~/stores/editor';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
 import { IPageWithMeta } from '~/interfaces/page';
 import { IPageWithMeta } from '~/interfaces/page';
-import { PageModel } from '~/server/models/page';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
+import { PageModel, PageDocument } from '~/server/models/page';
 import { serializeUserSecurely } from '~/server/models/serializers/user-serializer';
 import { serializeUserSecurely } from '~/server/models/serializers/user-serializer';
+import UserUISettings, { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
-import { useSWRxCurrentPage, useSWRxPage, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxPageInfo, useSWRxPage } from '~/stores/page';
+import {
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
 // import GrowiSubNavigationSwitcher from '../client/js/components/Navbar/GrowiSubNavigationSwitcher';
-// import DisplaySwitcher from '../client/js/components/Page/DisplaySwitcher';
 import { BasicLayout } from '../components/BasicLayout';
 import { BasicLayout } from '../components/BasicLayout';
+import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 
 
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import { serializeUserSecurely } from '../server/models/serializers/user-serializer';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
 // import PageStatusAlert from '../client/js/components/PageStatusAlert';
@@ -40,8 +46,8 @@ import {
   useIsForbidden, useIsNotFound, useIsTrashPage, useShared, useShareLinkId, useIsSharedUser, useIsAbleToDeleteCompletely,
   useIsForbidden, useIsNotFound, useIsTrashPage, useShared, useShareLinkId, useIsSharedUser, useIsAbleToDeleteCompletely,
   useAppTitle, useSiteUrl, useConfidential, useIsEnabledStaleNotification,
   useAppTitle, useSiteUrl, useConfidential, useIsEnabledStaleNotification,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsMailerSetup,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsMailerSetup,
-  useAclEnabled, useHasSlackConfig, useDrawioUri, useHackmdUri, useMathJax, useNoCdn, useEditorConfig, useCsrfToken,
-  useCurrentPageId,
+  useAclEnabled, useIsAclEnabled, useHasSlackConfig, useDrawioUri, useHackmdUri, useMathJax,
+  useNoCdn, useEditorConfig, useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname, useIsSlackConfigured,
 } from '../stores/context';
 } from '../stores/context';
 import { useXss } from '../stores/xss';
 import { useXss } from '../stores/xss';
 
 
@@ -50,7 +56,8 @@ import { CommonProps, getServerSideCommonProps, useCustomTitle } from './commons
 
 
 
 
 const logger = loggerFactory('growi:pages:all');
 const logger = loggerFactory('growi:pages:all');
-const { isUsersHomePage, isTrashPage: _isTrashPage } = pagePathUtils;
+const { isPermalink: _isPermalink, isUsersHomePage, isTrashPage: _isTrashPage } = pagePathUtils;
+const { removeHeadingSlash } = pathUtils;
 
 
 type Props = CommonProps & {
 type Props = CommonProps & {
   currentUser: string,
   currentUser: string,
@@ -66,10 +73,14 @@ type Props = CommonProps & {
   isForbidden: boolean,
   isForbidden: boolean,
   isNotFound: boolean,
   isNotFound: boolean,
   // isAbleToDeleteCompletely: boolean,
   // isAbleToDeleteCompletely: boolean,
+
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+
+  isSlackConfigured: boolean,
   // isMailerSetup: boolean,
   // isMailerSetup: boolean,
-  // isAclEnabled: boolean,
+  isAclEnabled: boolean,
   // hasSlackConfig: boolean,
   // hasSlackConfig: boolean,
   // drawioUri: string,
   // drawioUri: string,
   // hackmdUri: string,
   // hackmdUri: string,
@@ -84,12 +95,19 @@ type Props = CommonProps & {
   // isEnabledLinebreaksInComments: boolean,
   // isEnabledLinebreaksInComments: boolean,
   // adminPreferredIndentSize: number,
   // adminPreferredIndentSize: number,
   // isIndentSizeForced: boolean,
   // isIndentSizeForced: boolean,
+
+  // UI
+  userUISettings: UserUISettingsDocument | null
+  // Sidebar
+  sidebarConfig: ISidebarConfig,
 };
 };
 
 
 const GrowiPage: NextPage<Props> = (props: Props) => {
 const GrowiPage: NextPage<Props> = (props: Props) => {
   // const { t } = useTranslation();
   // const { t } = useTranslation();
   const router = useRouter();
   const router = useRouter();
 
 
+  const UnsavedAlertDialog = dynamic(() => import('./UnsavedAlertDialog'), { ssr: false });
+
   const { data: currentUser } = useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
   const { data: currentUser } = useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
 
 
   // commons
   // commons
@@ -100,22 +118,33 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useConfidential(props.confidential);
   useConfidential(props.confidential);
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
 
 
+  // UserUISettings
+  usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
+  usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
+  useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
+
   // page
   // page
   useCurrentPagePath(props.currentPathname);
   useCurrentPagePath(props.currentPathname);
   useIsLatestRevision(props.isLatestRevision);
   useIsLatestRevision(props.isLatestRevision);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
-  // useIsForbidden(props.isForbidden);
-  // useNotFound(props.isNotFound);
-  // useShared(isSharedPage(props.currentPagePath));
+  useIsForbidden(props.isForbidden);
+  useIsNotFound(props.isNotFound);
+  // useIsTrashPage(_isTrashPage(props.currentPagePath));
+  // useShared();
   // useShareLinkId(props.shareLinkId);
   // useShareLinkId(props.shareLinkId);
   // useIsAbleToDeleteCompletely(props.isAbleToDeleteCompletely);
   // useIsAbleToDeleteCompletely(props.isAbleToDeleteCompletely);
-  // useIsSharedUser(props.currentUser == null && isSharedPage(props.currentPagePath));
+  useIsSharedUser(false); // this page cann't be routed for '/share'
   useIsEnabledStaleNotification(props.isEnabledStaleNotification);
   useIsEnabledStaleNotification(props.isEnabledStaleNotification);
 
 
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  useIsSlackConfigured(props.isSlackConfigured);
   // useIsMailerSetup(props.isMailerSetup);
   // useIsMailerSetup(props.isMailerSetup);
-  // useAclEnabled(props.isAclEnabled);
+  useIsAclEnabled(props.isAclEnabled);
   // useHasSlackConfig(props.hasSlackConfig);
   // useHasSlackConfig(props.hasSlackConfig);
   // useDrawioUri(props.drawioUri);
   // useDrawioUri(props.drawioUri);
   // useHackmdUri(props.hackmdUri);
   // useHackmdUri(props.hackmdUri);
@@ -141,6 +170,15 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useSWRxPage(pageWithMeta?.data._id);
   // useSWRxPage(pageWithMeta?.data._id);
   useSWRxPageInfo(pageWithMeta?.data._id, undefined, pageWithMeta?.meta); // store initial data
   useSWRxPageInfo(pageWithMeta?.data._id, undefined, pageWithMeta?.meta); // store initial data
   useIsTrashPage(_isTrashPage(pageWithMeta?.data.path ?? ''));
   useIsTrashPage(_isTrashPage(pageWithMeta?.data.path ?? ''));
+  useCurrentPagePath(pageWithMeta?.data.path);
+  useCurrentPathname(props.currentPathname);
+
+  // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
+  useEffect(() => {
+    if (isClient() && window.location.pathname !== props.currentPathname) {
+      router.replace(props.currentPathname, undefined, { shallow: true });
+    }
+  }, [props.currentPathname, router]);
 
 
   const classNames: string[] = [];
   const classNames: string[] = [];
   // switch (editorMode) {
   // switch (editorMode) {
@@ -158,15 +196,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   //   classNames.push('not-found-page');
   //   classNames.push('not-found-page');
   // }
   // }
 
 
-
-  // // Rewrite browser url by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
-  // useEffect(() => {
-  //   if (props.redirectTo != null) {
-  //     router.push('/[[...path]]', props.redirectTo, { shallow: true });
-  //   }
-  // // eslint-disable-next-line react-hooks/exhaustive-deps
-  // }, []);
-
   return (
   return (
     <>
     <>
       <Head>
       <Head>
@@ -197,8 +226,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
             <div className="col grw-page-content-container">
             <div className="col grw-page-content-container">
               <div id="content-main" className="content-main grw-container-convertible">
               <div id="content-main" className="content-main grw-container-convertible">
                 <PageAlerts />
                 <PageAlerts />
-                {/* <DisplaySwitcher /> */}
-                DisplaySwitcher<br />
+                <DisplaySwitcher />
                 <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                 <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                 {/* <PageStatusAlert /> */}
                 {/* <PageStatusAlert /> */}
                 PageStatusAlert
                 PageStatusAlert
@@ -218,57 +246,76 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           PageComments
           PageComments
         </footer>
         </footer>
 
 
+        <UnsavedAlertDialog />
+
       </BasicLayout>
       </BasicLayout>
     </>
     </>
   );
   );
 };
 };
 
 
-async function injectPageInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
+
+function getPageIdFromPathname(currentPathname: string): string | null {
+  return _isPermalink(currentPathname) ? removeHeadingSlash(currentPathname) : null;
+}
+
+async function getPageData(context: GetServerSidePropsContext, props: Props): Promise<IPageWithMeta|null> {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const { crowi } = req;
-  const Page = crowi.model('Page');
+  const { revisionId } = req.query;
   const { pageService } = crowi;
   const { pageService } = crowi;
 
 
-  const { user, originalUrl } = req;
+  const { user } = req;
 
 
   const { currentPathname } = props;
   const { currentPathname } = props;
 
 
-  // retrieve query params
-  const url = new URL(originalUrl, props.siteUrl);
-  const searchParams = new URLSearchParams(url.search);
-
-  // determine pageId
-  const pageIdStr = currentPathname.substring(1);
-  const pageId = isValidObjectId(pageIdStr) ? pageIdStr : null;
+  const pageId = getPageIdFromPathname(currentPathname);
 
 
   const result: IPageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
   const result: IPageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
-  const page = result.data;
+  const page = result?.data as unknown as PageDocument;
+
+  // populate
+  if (page != null) {
+    page.initLatestRevisionField(revisionId);
+    await page.populateDataToShowRevision();
+  }
+
+  return result;
+}
+
+async function injectRoutingInformation(context: GetServerSidePropsContext, props: Props, pageWithMeta: IPageWithMeta|null): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const Page = crowi.model('Page');
+
+  const { currentPathname } = props;
+  const pageId = getPageIdFromPathname(currentPathname);
+  const isPermalink = _isPermalink(currentPathname);
+
+  const page = pageWithMeta?.data;
 
 
 
 
   if (page == null) {
   if (page == null) {
-    const count = pageId != null ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
+    props.isNotFound = true;
+
     // check the page is forbidden or just does not exist.
     // check the page is forbidden or just does not exist.
+    const count = isPermalink ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
     props.isForbidden = count > 0;
     props.isForbidden = count > 0;
-    props.isNotFound = true;
-    logger.warn(`Page is ${props.isForbidden ? 'forbidden' : 'not found'}`, currentPathname);
   }
   }
 
 
-  // Todo: should check if revision document with the specified revisionId actually exist in DB.
-  // if true, replacing page.revision with old revision should be done when populating Revision
-  const revisionId = searchParams.get('revision');
-  const isSpecifiedRevisionExist = true; // dummy
-
-  // check if revision is latest
-  if (revisionId == null || !isSpecifiedRevisionExist) {
-    props.isLatestRevision = true;
+  if (page != null) {
+    // /62a88db47fed8b2d94f30000 ==> /path/to/page
+    if (isPermalink && page.isEmpty) {
+      props.currentPathname = page.path;
+    }
+
+    // /path/to/page ==> /62a88db47fed8b2d94f30000
+    if (!isPermalink && !page.isEmpty) {
+      const isToppage = pagePathUtils.isTopPage(props.currentPathname);
+      if (!isToppage) {
+        props.currentPathname = `/${page._id}`;
+      }
+    }
   }
   }
-  else {
-    props.isLatestRevision = page.revision.toString() === revisionId;
-  }
-
-  await (page as unknown as PageModel).populateDataToShowRevision();
-  props.pageWithMetaStr = JSON.stringify(result);
-
 }
 }
 
 
 // async function injectPageUserInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
 // async function injectPageUserInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
@@ -293,8 +340,10 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   } = crowi;
   } = crowi;
 
 
   const { user } = req;
   const { user } = req;
+  const { revisionId } = req.query;
 
 
   const result = await getServerSideCommonProps(context);
   const result = await getServerSideCommonProps(context);
+  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
 
 
   // check for presence
   // check for presence
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
@@ -303,16 +352,33 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   }
   }
 
 
   const props: Props = result.props as Props;
   const props: Props = result.props as Props;
-  await injectPageInformation(context, props);
+  const pageWithMeta = await getPageData(context, props);
+
+  // check isLatestRevision
+  const page = pageWithMeta?.data;
+  if (page == null || page.latestRevision == null || revisionId == null) {
+    props.isLatestRevision = true;
+  }
+  else {
+    props.isLatestRevision = page.latestRevision.toString() === revisionId;
+  }
+
+  props.pageWithMetaStr = JSON.stringify(pageWithMeta);
+
+  injectRoutingInformation(context, props, pageWithMeta);
 
 
   if (user != null) {
   if (user != null) {
     props.currentUser = JSON.stringify(user);
     props.currentUser = JSON.stringify(user);
   }
   }
 
 
+
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
   // props.isMailerSetup = mailService.isMailerSetup;
   // props.isMailerSetup = mailService.isMailerSetup;
-  // props.isAclEnabled = aclService.isAclEnabled();
+  props.isAclEnabled = aclService.isAclEnabled();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
   // props.hasSlackConfig = slackNotificationService.hasSlackConfig();
   // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   // props.drawioUri = configManager.getConfig('crowi', 'app:drawioUri');
   // props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
   // props.hackmdUri = configManager.getConfig('crowi', 'app:hackmdUri');
@@ -333,6 +399,12 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   // props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   // props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   // props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
   // props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 
 
+  // UI
+  props.userUISettings = JSON.parse(JSON.stringify(userUISettings));
+  props.sidebarConfig = {
+    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+  };
   return {
   return {
     props,
     props,
   };
   };

+ 0 - 4
packages/app/src/server/crowi/express-init.js

@@ -88,10 +88,6 @@ module.exports = function(crowi, app) {
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   app.use(express.static(crowi.publicDir, staticOption));
   app.use(express.static(crowi.publicDir, staticOption));
   app.engine('html', swig.renderFile);
   app.engine('html', swig.renderFile);
-  app.use(webpackAssets(
-    path.join(crowi.publicDir, 'manifest.json'),
-    { devMode: (crowi.node_env === 'development') },
-  ));
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   app.set('view engine', 'html');
   app.set('view engine', 'html');
   app.set('views', crowi.viewsDir);
   app.set('views', crowi.viewsDir);

+ 5 - 0
packages/app/src/server/interfaces/search.ts

@@ -36,6 +36,11 @@ export type SearchableData<T = Partial<QueryTerms>> = {
   terms: T
   terms: T
 }
 }
 
 
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}
+
 // Terms Key types
 // Terms Key types
 export type AllTermsKey = keyof QueryTerms;
 export type AllTermsKey = keyof QueryTerms;
 export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;
 export type UnavailableTermsKey<K extends AllTermsKey> = Exclude<AllTermsKey, K>;

+ 12 - 12
packages/app/src/server/middlewares/login-required.js

@@ -12,18 +12,6 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
 
 
   return function(req, res, next) {
   return function(req, res, next) {
 
 
-    // check the route config and ACL
-    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
-      logger.debug('Allowed to read: ', req.path);
-      return next();
-    }
-
-    // check the page is shared
-    if (isGuestAllowed && req.isSharedPage) {
-      logger.debug('Target page is shared page');
-      return next();
-    }
-
     const User = crowi.model('User');
     const User = crowi.model('User');
 
 
     // check the user logged in
     // check the user logged in
@@ -43,6 +31,18 @@ module.exports = (crowi, isGuestAllowed = false, fallback = null) => {
       }
       }
     }
     }
 
 
+    // check the route config and ACL
+    if (isGuestAllowed && crowi.aclService.isGuestAllowedToRead()) {
+      logger.debug('Allowed to read: ', req.path);
+      return next();
+    }
+
+    // check the page is shared
+    if (isGuestAllowed && req.isSharedPage) {
+      logger.debug('Target page is shared page');
+      return next();
+    }
+
     // is api path
     // is api path
     const baseUrl = req.baseUrl || '';
     const baseUrl = req.baseUrl || '';
     if (baseUrl.match(/^\/_api\/.+$/)) {
     if (baseUrl.match(/^\/_api\/.+$/)) {

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

@@ -1,4 +1,4 @@
-import { ObjectIdLike } from './mongoose-utils';
+import { ObjectIdLike } from '../../interfaces/mongoose-utils';
 
 
 export type IPageForResuming = {
 export type IPageForResuming = {
   _id: ObjectIdLike,
   _id: ObjectIdLike,

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

@@ -6,7 +6,7 @@ import mongoose, {
 
 
 import {
 import {
   IPageForResuming, IUserForResuming, IOptionsForResuming,
   IPageForResuming, IUserForResuming, IOptionsForResuming,
-} from '~/server/interfaces/page-operation';
+} from '~/server/models/interfaces/page-operation';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';

+ 3 - 3
packages/app/src/server/routes/apiv3/forgot-password.js

@@ -41,10 +41,10 @@ module.exports = (crowi) => {
   };
   };
 
 
   const apiLimiter = rateLimit({
   const apiLimiter = rateLimit({
-    windowMs: 15 * 60 * 1000, // 15 minutes
-    max: 10, // limit each IP to 10 requests per windowMs
+    windowMs: 1 * 60 * 1000, // 1 minutes
+    max: 30, // limit each IP to 30 requests per windowMs
     message:
     message:
-      'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
+    'Too many requests were sent from this IP. Please try a password reset request again on the password reset request form',
   });
   });
 
 
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);
   const checkPassportStrategyMiddleware = checkForgotPasswordEnabledMiddlewareFactory(crowi, true);

+ 2 - 1
packages/app/src/server/routes/apiv3/pages.js

@@ -570,7 +570,8 @@ module.exports = (crowi) => {
     }
     }
 
 
     try {
     try {
-      await crowi.pageService.resumeRenameSubOperation(page);
+      const pageOp = await crowi.pageOperationService.getRenameSubOperationByPageId(page._id);
+      await crowi.pageService.resumeRenameSubOperation(page, pageOp);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);

+ 12 - 10
packages/app/src/server/routes/index.js

@@ -1,5 +1,5 @@
-import express from 'express';
 import csrf from 'csurf';
 import csrf from 'csurf';
+import express from 'express';
 
 
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
@@ -24,10 +24,10 @@ const autoReap = require('multer-autoreap');
 const csrfProtection = csrf({ cookie: false });
 const csrfProtection = csrf({ cookie: false });
 
 
 const apiLimiter = rateLimit({
 const apiLimiter = rateLimit({
-  windowMs: 15 * 60 * 1000, // 15 minutes
-  max: 10, // limit each IP to 10 requests per windowMs
+  windowMs: 1 * 60 * 1000, // 1 minutes
+  max: 60, // limit each IP to 60 requests per windowMs
   message:
   message:
-    'Too many requests sent from this IP, please try again after 15 minutes',
+    'Too many requests sent from this IP, please try again after 1 minute',
 });
 });
 
 
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
 autoReap.options.reapOnError = true; // continue reaping the file even if an error occurs
@@ -54,7 +54,8 @@ module.exports = function(crowi, app) {
   const comment = require('./comment')(crowi, app);
   const comment = require('./comment')(crowi, app);
   const tag = require('./tag')(crowi, app);
   const tag = require('./tag')(crowi, app);
   const search = require('./search')(crowi, app);
   const search = require('./search')(crowi, app);
-  const hackmd = require('./hackmd')(crowi, app);
+  // == TODO: Replace the code in hackmd.js getting the script path from manifest.json
+  // const hackmd = require('./hackmd')(crowi, app);
   const ogp = require('./ogp')(crowi);
   const ogp = require('./ogp')(crowi);
 
 
   const next = nextFactory(crowi);
   const next = nextFactory(crowi);
@@ -223,11 +224,12 @@ module.exports = function(crowi, app) {
   app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));
   app.get('/trash/$'                  , loginRequired, (req, res) => res.redirect('/trash'));
   app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
   app.get('/trash/*/$'                , loginRequired, injectUserUISettings, page.deletedPageListShowWrapper);
 
 
-  app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
-  app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
-  app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.integrate);
-  app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.discard);
-  app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.saveOnHackmd);
+  // == TODO: Replace the code in hackmd.js getting the script path from manifest.json
+  // app.get('/_hackmd/load-agent'          , hackmd.loadAgent);
+  // app.get('/_hackmd/load-styles'         , hackmd.loadStyles);
+  // app.post('/_api/hackmd.integrate'      , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.integrate);
+  // app.post('/_api/hackmd.discard'        , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.discard);
+  // app.post('/_api/hackmd.saveOnHackmd'   , accessTokenParser , loginRequiredStrictly , hackmd.validateForApi, hackmd.saveOnHackmd);
 
 
   app.use('/forgot-password', express.Router()
   app.use('/forgot-password', express.Router()
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))
     .use(forgotPassword.checkForgotPasswordEnabledMiddlewareFactory(crowi))

+ 1 - 2
packages/app/src/server/routes/login.js

@@ -217,8 +217,7 @@ module.exports = function(crowi, app) {
       }
       }
     }
     }
     else {
     else {
-      return res.render('invited', {
-      });
+      return res.render('invited');
     }
     }
   };
   };
 
 

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

@@ -170,7 +170,7 @@ module.exports = function(crowi, app) {
   const actions = {};
   const actions = {};
 
 
   function getPathFromRequest(req) {
   function getPathFromRequest(req) {
-    return pathUtils.normalizePath(req.pagePath || req.params[0] || '');
+    return pathUtils.normalizePath(req.pagePath || req.params[0] || req.params.id || '');
   }
   }
 
 
   function generatePager(offset, limit, totalCount) {
   function generatePager(offset, limit, totalCount) {
@@ -274,9 +274,6 @@ module.exports = function(crowi, app) {
     }
     }
 
 
     renderVars.notFoundTargetPathOrId = pathOrId;
     renderVars.notFoundTargetPathOrId = pathOrId;
-
-    const isPath = pathOrId.includes('/');
-    renderVars.isNotFoundPermalink = !isPath && !await Page.exists({ _id: pathOrId });
   }
   }
 
 
   async function addRenderVarsWhenEmptyPage(renderVars, isEmpty, pageId) {
   async function addRenderVarsWhenEmptyPage(renderVars, isEmpty, pageId) {

+ 4 - 0
packages/app/src/server/service/interfaces/search.ts

@@ -0,0 +1,4 @@
+export type UpdateOrInsertPagesOpts = {
+  shouldEmitProgress?: boolean
+  invokeGarbageCollection?: boolean
+}

+ 25 - 11
packages/app/src/server/service/page-operation.ts

@@ -8,7 +8,9 @@ import { ObjectIdLike } from '../interfaces/mongoose-utils';
 
 
 const logger = loggerFactory('growi:services:page-operation');
 const logger = loggerFactory('growi:services:page-operation');
 
 
-const { isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage } = pagePathUtils;
+const {
+  isEitherOfPathAreaOverlap, isPathAreaOverlap, isTrashPage, collectAncestorPaths,
+} = pagePathUtils;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 const AUTO_UPDATE_INTERVAL_SEC = 5;
 
 
 const {
 const {
@@ -34,8 +36,10 @@ class PageOperationService {
    */
    */
   async afterExpressServerReady(): Promise<void> {
   async afterExpressServerReady(): Promise<void> {
     try {
     try {
+      const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
+        .sort({ createdAt: 'asc' });
       // execute rename operation
       // execute rename operation
-      await this.executeAllRenameOperationBySystem();
+      await this.executeAllRenameOperationBySystem(pageOps);
     }
     }
     catch (err) {
     catch (err) {
       logger.error(err);
       logger.error(err);
@@ -45,17 +49,12 @@ class PageOperationService {
   /**
   /**
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    * Execute renameSubOperation on every page operation for rename ordered by createdAt ASC
    */
    */
-  private async executeAllRenameOperationBySystem(): Promise<void> {
-    const Page = this.crowi.model('Page');
-
-    const pageOps = await PageOperation.find({ actionType: PageActionType.Rename, actionStage: PageActionStage.Sub })
-      .sort({ createdAt: 'asc' });
+  private async executeAllRenameOperationBySystem(pageOps: PageOperationDocument[]): Promise<void> {
     if (pageOps.length === 0) return;
     if (pageOps.length === 0) return;
 
 
+    const Page = this.crowi.model('Page');
+
     for await (const pageOp of pageOps) {
     for await (const pageOp of pageOps) {
-      const {
-        page, toPath, options, user,
-      } = pageOp;
 
 
       const renamedPage = await Page.findById(pageOp.page._id);
       const renamedPage = await Page.findById(pageOp.page._id);
       if (renamedPage == null) {
       if (renamedPage == null) {
@@ -64,7 +63,7 @@ class PageOperationService {
       }
       }
 
 
       // rename
       // rename
-      await this.crowi.pageService.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+      await this.crowi.pageService.resumeRenameSubOperation(renamedPage, pageOp);
     }
     }
   }
   }
 
 
@@ -169,6 +168,21 @@ class PageOperationService {
     clearInterval(timerObj);
     clearInterval(timerObj);
   }
   }
 
 
+  /**
+   * Get ancestor's paths using fromPath and toPath. Merge same paths if any.
+   */
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[] {
+    const fromAncestorsPaths = collectAncestorPaths(fromPath);
+    const toAncestorsPaths = collectAncestorPaths(toPath);
+    // merge duplicate paths and return paths of ancestors
+    return Array.from(new Set(toAncestorsPaths.concat(fromAncestorsPaths)));
+  }
+
+  async getRenameSubOperationByPageId(pageId: ObjectIdLike): Promise<PageOperationDocument | null> {
+    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': pageId };
+    return PageOperation.findOne(filter);
+  }
+
 }
 }
 
 
 export default PageOperationService;
 export default PageOperationService;

+ 56 - 24
packages/app/src/server/service/page.ts

@@ -29,7 +29,7 @@ import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { PathAlreadyExistsError } from '../models/errors';
 import { PathAlreadyExistsError } from '../models/errors';
-import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import PageOperation, { PageActionStage, PageActionType, PageOperationDocument } from '../models/page-operation';
 import { PageRedirectModel } from '../models/page-redirect';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
 import Subscription from '../models/subscription';
@@ -618,30 +618,31 @@ class PageService {
     await PageOperation.findByIdAndDelete(pageOpId);
     await PageOperation.findByIdAndDelete(pageOpId);
   }
   }
 
 
-  async resumeRenameSubOperation(renamedPage: PageDocument): Promise<void> {
-
-    // findOne PageOperation
-    const filter = { actionType: PageActionType.Rename, actionStage: PageActionStage.Sub, 'page._id': renamedPage._id };
-    const pageOp = await PageOperation.findOne(filter);
-    if (pageOp == null) {
-      throw Error('There is nothing to be processed right now');
-    }
+  async resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument): Promise<void> {
     const isProcessable = pageOp.isProcessable();
     const isProcessable = pageOp.isProcessable();
     if (!isProcessable) {
     if (!isProcessable) {
       throw Error('This page operation is currently being processed');
       throw Error('This page operation is currently being processed');
     }
     }
+    if (pageOp.toPath == null) {
+      throw Error(`Property toPath is missing which is needed to resume rename operation(${pageOp._id})`);
+    }
 
 
     const {
     const {
-      page, toPath, options, user,
+      page, fromPath, toPath, options, user,
     } = pageOp;
     } = pageOp;
 
 
-    // check property
-    if (toPath == null) {
-      throw Error(`Property toPath is missing which is needed to resume page operation(${pageOp._id})`);
-    }
-
-    this.renameSubOperation(page, toPath, user, options, renamedPage, pageOp._id);
+    this.fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOp._id, fromPath, toPath);
+  }
 
 
+  /**
+   * Renaming paths and fixing descendantCount of ancestors. It shoud be run synchronously.
+   * `renameSubOperation` to restart rename operation
+   * `updateDescendantCountOfPagesWithPaths` to fix descendantCount of ancestors
+   */
+  private async fixPathsAndDescendantCountOfAncestors(page, user, options, renamedPage, pageOpId, fromPath, toPath): Promise<void> {
+    await this.renameSubOperation(page, toPath, user, options, renamedPage, pageOpId);
+    const ancestorsPaths = this.crowi.pageOperationService.getAncestorsPathsByFromAndToPath(fromPath, toPath);
+    await this.updateDescendantCountOfPagesWithPaths(ancestorsPaths);
   }
   }
 
 
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
   private isRenamingToUnderTarget(fromPath: string, toPath: string): boolean {
@@ -2713,7 +2714,7 @@ class PageService {
 
 
     // then migrate
     // then migrate
     try {
     try {
-      await this.normalizeParentRecursively(['/'], null);
+      await this.normalizeParentRecursively(['/'], null, true);
     }
     }
     catch (err) {
     catch (err) {
       logger.error('V5 initial miration failed.', err);
       logger.error('V5 initial miration failed.', err);
@@ -2760,7 +2761,7 @@ class PageService {
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @param user To be used to filter pages to update. If null, only public pages will be updated.
    * @returns Promise<void>
    * @returns Promise<void>
    */
    */
-  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmit = false): Promise<number> {
+  async normalizeParentRecursively(paths: string[], user: any | null, shouldEmitProgress = false): Promise<number> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
     const ancestorPaths = paths.flatMap(p => collectAncestorPaths(p, []));
@@ -2779,7 +2780,7 @@ class PageService {
 
 
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
     const grantFiltersByUser: { $or: any[] } = Page.generateGrantCondition(user, userGroups);
 
 
-    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmit);
+    return this._normalizeParentRecursively(pathAndRegExpsToNormalize, ancestorPaths, grantFiltersByUser, user, shouldEmitProgress);
   }
   }
 
 
   private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
   private buildFilterForNormalizeParentRecursively(pathOrRegExps: (RegExp | string)[], publicPathsToNormalize: string[], grantFiltersByUser: { $or: any[] }) {
@@ -2829,7 +2830,7 @@ class PageService {
       publicPathsToNormalize: string[],
       publicPathsToNormalize: string[],
       grantFiltersByUser: { $or: any[] },
       grantFiltersByUser: { $or: any[] },
       user,
       user,
-      shouldEmit = false,
+      shouldEmitProgress = false,
       count = 0,
       count = 0,
       skiped = 0,
       skiped = 0,
       isFirst = true,
       isFirst = true,
@@ -2837,7 +2838,7 @@ class PageService {
     const BATCH_SIZE = 100;
     const BATCH_SIZE = 100;
     const PAGES_LIMIT = 1000;
     const PAGES_LIMIT = 1000;
 
 
-    const socket = shouldEmit ? this.crowi.socketIoService.getAdminSocket() : null;
+    const socket = shouldEmitProgress ? this.crowi.socketIoService.getAdminSocket() : null;
 
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
@@ -3002,7 +3003,16 @@ class PageService {
     await streamToPromise(migratePagesStream);
     await streamToPromise(migratePagesStream);
 
 
     if (await Page.exists(matchFilter) && shouldContinue) {
     if (await Page.exists(matchFilter) && shouldContinue) {
-      return this._normalizeParentRecursively(pathOrRegExps, publicPathsToNormalize, grantFiltersByUser, user, shouldEmit, nextCount, nextSkiped, false);
+      return this._normalizeParentRecursively(
+        pathOrRegExps,
+        publicPathsToNormalize,
+        grantFiltersByUser,
+        user,
+        shouldEmitProgress,
+        nextCount,
+        nextSkiped,
+        false,
+      );
     }
     }
 
 
     // End
     // End
@@ -3067,8 +3077,30 @@ class PageService {
     builder.addConditionToSortPagesByDescPath();
     builder.addConditionToSortPagesByDescPath();
 
 
     const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
     const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
 
 
+  /**
+   * update descendantCount of the pages sequentially from longer path to shorter path
+   */
+  async updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void> {
+    const BATCH_SIZE = 200;
+    const Page = this.crowi.model('Page');
+    const { PageQueryBuilder } = Page;
+
+    const builder = new PageQueryBuilder(Page.find(), true);
+    builder.addConditionToListByPathsArray(paths); // find by paths
+    builder.addConditionToSortPagesByDescPath(); // sort in DESC
 
 
+    const aggregatedPages = await builder.query.lean().cursor({ batchSize: BATCH_SIZE });
+    await this.recountAndUpdateDescendantCountOfPages(aggregatedPages, BATCH_SIZE);
+  }
+
+  /**
+   * Recount descendantCount of pages one by one
+   */
+  async recountAndUpdateDescendantCountOfPages(pageCursor: QueryCursor<any>, batchSize:number): Promise<void> {
+    const Page = this.crowi.model('Page');
     const recountWriteStream = new Writable({
     const recountWriteStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(pageDocuments, encoding, callback) {
       async write(pageDocuments, encoding, callback) {
@@ -3082,8 +3114,8 @@ class PageService {
         callback();
         callback();
       },
       },
     });
     });
-    aggregatedPages
-      .pipe(createBatchStream(BATCH_SIZE))
+    pageCursor
+      .pipe(createBatchStream(batchSize))
       .pipe(recountWriteStream);
       .pipe(recountWriteStream);
 
 
     await streamToPromise(recountWriteStream);
     await streamToPromise(recountWriteStream);

+ 9 - 9
packages/app/src/server/service/search-delegator/elasticsearch.ts

@@ -18,6 +18,7 @@ import {
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import { PageModel } from '../../models/page';
 import { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { createBatchStream } from '../../util/batch-stream';
+import { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 
 
 import ElasticsearchClient from './elasticsearch-client';
 import ElasticsearchClient from './elasticsearch-client';
@@ -437,7 +438,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   addAllPages() {
   addAllPages() {
     const Page = mongoose.model('Page');
     const Page = mongoose.model('Page');
-    return this.updateOrInsertPages(() => Page.find(), { isEmittingProgressEvent: true, invokeGarbageCollection: true });
+    return this.updateOrInsertPages(() => Page.find(), { shouldEmitProgress: true, invokeGarbageCollection: true });
   }
   }
 
 
   updateOrInsertPageById(pageId) {
   updateOrInsertPageById(pageId) {
@@ -456,8 +457,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
   /**
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    * @param {function} queryFactory factory method to generate a Mongoose Query instance
    */
    */
-  async updateOrInsertPages(queryFactory, option: any = {}) {
-    const { isEmittingProgressEvent = false, invokeGarbageCollection = false } = option;
+  async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
+    const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
 
 
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
@@ -465,7 +466,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const Comment = mongoose.model('Comment') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
     const PageTagRelation = mongoose.model('PageTagRelation') as any; // TODO: typescriptize model
 
 
-    const socket = this.socketIoService.getAdminSocket();
+    const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 
 
     // prepare functions invoked from custom streams
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
@@ -583,8 +584,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
           logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
           logger.info(`Adding pages progressing: (count=${count}, errors=${res.errors}, took=${res.took}ms)`);
 
 
-          if (isEmittingProgressEvent) {
-            socket.emit('addPageProgress', { totalCount, count, skipped });
+          if (shouldEmitProgress) {
+            socket?.emit('addPageProgress', { totalCount, count, skipped });
           }
           }
         }
         }
         catch (err) {
         catch (err) {
@@ -607,8 +608,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       final(callback) {
       final(callback) {
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
         logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
 
 
-        if (isEmittingProgressEvent) {
-          socket.emit('finishAddPage', { totalCount, count, skipped });
+        if (shouldEmitProgress) {
+          socket?.emit('finishAddPage', { totalCount, count, skipped });
         }
         }
         callback();
         callback();
       },
       },
@@ -623,7 +624,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       .pipe(writeStream);
       .pipe(writeStream);
 
 
     return streamToPromise(writeStream);
     return streamToPromise(writeStream);
-
   }
   }
 
 
   deletePages(pages) {
   deletePages(pages) {

+ 0 - 1
packages/app/src/server/views/layout-growi/not_found.html

@@ -10,7 +10,6 @@
   </div>
   </div>
   <div
   <div
     id="growi-not-found-context"
     id="growi-not-found-context"
-    data-is-not-found-permalink="{% if isNotFoundPermalink %}{{isNotFoundPermalink|json}}{% endif %}"
     data-page-id="{%if pageId %}{{pageId.toString()}}{% endif %}"
     data-page-id="{%if pageId %}{{pageId.toString()}}{% endif %}"
   >
   >
   </div>
   </div>

+ 21 - 25
packages/app/src/stores/context.tsx

@@ -3,7 +3,7 @@ import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 
 
-import { TargetAndAncestors, IsNotFoundPermalink } from '../interfaces/page-listing-results';
+import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { IUser } from '../interfaces/user';
 import { IUser } from '../interfaces/user';
 
 
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
@@ -40,6 +40,10 @@ export const useCurrentPagePath = (initialData?: Nullable<string>): SWRResponse<
   return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
   return useStaticSWR<Nullable<string>, Error>('currentPagePath', initialData);
 };
 };
 
 
+export const useCurrentPathname = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
+  return useStaticSWR<Nullable<string>, Error>('currentPathname', initialData);
+};
+
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
 export const useCurrentPageId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
   return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
   return useStaticSWR<Nullable<string>, Error>('currentPageId', initialData);
 };
 };
@@ -84,6 +88,10 @@ export const useIsForbidden = (initialData?: boolean): SWRResponse<boolean, Erro
   return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
   return useStaticSWR<boolean, Error>('isForbidden', initialData, { fallbackData: false });
 };
 };
 
 
+export const useIsNotFound = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isNotFound', initialData, { fallbackData: false });
+};
+
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const usePageUser = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('pageUser', initialData);
   return useStaticSWR<Nullable<any>, Error>('pageUser', initialData);
 };
 };
@@ -96,6 +104,10 @@ export const useTemplateTagData = (initialData?: Nullable<any>): SWRResponse<Nul
   return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData);
   return useStaticSWR<Nullable<any>, Error>('templateTagData', initialData);
 };
 };
 
 
+export const useIsSharedUser = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isSharedUser', initialData);
+};
+
 export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
 export const useShareLinksNumber = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
   return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData);
   return useStaticSWR<Nullable<any>, Error>('shareLinksNumber', initialData);
 };
 };
@@ -140,14 +152,6 @@ export const useTargetAndAncestors = (initialData?: TargetAndAncestors): SWRResp
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
   return useStaticSWR<TargetAndAncestors, Error>('targetAndAncestors', initialData);
 };
 };
 
 
-export const useNotFoundTargetPathOrId = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR<string, Error>('notFoundTargetPathOrId', initialData);
-};
-
-export const useIsNotFoundPermalink = (initialData?: Nullable<IsNotFoundPermalink>): SWRResponse<Nullable<IsNotFoundPermalink>, Error> => {
-  return useStaticSWR<Nullable<IsNotFoundPermalink>, Error>('isNotFoundPermalink', initialData);
-};
-
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
   return useStaticSWR<boolean, Error>('isAclEnabled', initialData);
 };
 };
@@ -160,13 +164,18 @@ export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse
   return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
   return useStaticSWR<boolean, Error>('isSearchServiceReachable', initialData);
 };
 };
 
 
+export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData);
+};
+
+export const useIsSlackConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isSlackConfigured', initialData);
+};
+
 export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useIsEnabledAttachTitleHeader = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
   return useStaticSWR<boolean, Error>('isEnabledAttachTitleHeader', initialData);
 };
 };
 
 
-export const useIsEmptyPage = (initialData?: boolean) : SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isEmptyPage', initialData);
-};
 export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useHasParent = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('hasParent', initialData);
   return useStaticSWR<boolean, Error>('hasParent', initialData);
 };
 };
@@ -217,16 +226,3 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
     },
     },
   );
   );
 };
 };
-
-export const useIsSharedUser = (): SWRResponse<boolean, Error> => {
-  const { data: isGuestUser } = useIsGuestUser();
-
-  const pathname = window.location.pathname;
-
-  return useSWRImmutable(
-    ['isSharedUser', isGuestUser, pathname],
-    (key: Key, isGuestUser: boolean, pathname: string) => {
-      return isGuestUser && pagePathUtils.isSharedPage(pathname);
-    },
-  );
-};

+ 4 - 0
packages/app/src/stores/editor.tsx

@@ -108,3 +108,7 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
     },
     },
   };
   };
 };
 };
+
+export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning', undefined, { fallbackData: false });
+};

+ 21 - 0
packages/app/src/stores/page.tsx

@@ -9,6 +9,7 @@ import {
 } from '~/interfaces/page';
 } from '~/interfaces/page';
 import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
 import { IPagingResult } from '~/interfaces/paging-result';
 import { IPagingResult } from '~/interfaces/paging-result';
+import { IRevisionsForPagination } from '~/interfaces/revision';
 
 
 import { apiGet } from '../client/util/apiv1-client';
 import { apiGet } from '../client/util/apiv1-client';
 import { Nullable } from '../interfaces/common';
 import { Nullable } from '../interfaces/common';
@@ -161,6 +162,26 @@ export const useSWRxPageInfoForList = (
   };
   };
 };
 };
 
 
+export const useSWRxPageRevisions = (
+    pageId: string,
+    page: number, // page number of pagination
+    limit: number, // max number of pages in one paginate
+): SWRResponse<IRevisionsForPagination, Error> => {
+
+  return useSWRImmutable<IRevisionsForPagination, Error>(
+    ['/revisions/list', pageId, page, limit],
+    (endpoint, pageId, page, limit) => {
+      return apiv3Get(endpoint, { pageId, page, limit }).then((response) => {
+        const revisions = {
+          revisions: response.data.docs,
+          totalCounts: response.data.totalDocs,
+        };
+        return revisions;
+      });
+    },
+  );
+};
+
 /*
 /*
  * Grant normalization fetching hooks
  * Grant normalization fetching hooks
  */
  */

+ 14 - 12
packages/app/src/stores/ui.tsx

@@ -1,6 +1,8 @@
 import { RefObject } from 'react';
 import { RefObject } from 'react';
 
 
-import { isClient, pagePathUtils } from '@growi/core';
+import { constants } from 'zlib';
+
+import { isClient, isServer, pagePathUtils } from '@growi/core';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import { Breakpoint, addBreakpointListener } from '@growi/ui';
 import SimpleBar from 'simplebar-react';
 import SimpleBar from 'simplebar-react';
 import {
 import {
@@ -19,11 +21,10 @@ import loggerFactory from '~/utils/logger';
 
 
 import {
 import {
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
   useCurrentPageId, useCurrentPagePath, useIsEditable, useIsTrashPage, useIsUserPage, useIsGuestUser, useEmptyPageId,
-  useIsNotCreatable, useIsSharedUser, useNotFoundTargetPathOrId, useIsForbidden, useIsIdenticalPath, useIsNotFoundPermalink, useCurrentUser,
+  useIsNotCreatable, useIsSharedUser, useIsForbidden, useIsIdenticalPath, useCurrentUser, useIsNotFound,
 } from './context';
 } from './context';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { localStorageMiddleware } from './middlewares/sync-to-storage';
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
-import { constants } from 'zlib';
 
 
 const { isSharedPage } = pagePathUtils;
 const { isSharedPage } = pagePathUtils;
 
 
@@ -112,6 +113,10 @@ const updateHashByEditorMode = (newEditorMode: EditorMode) => {
 };
 };
 
 
 export const determineEditorModeByHash = (): EditorMode => {
 export const determineEditorModeByHash = (): EditorMode => {
+  if (isServer()) {
+    return EditorMode.View;
+  }
+
   const { hash } = window.location;
   const { hash } = window.location;
 
 
   switch (hash) {
   switch (hash) {
@@ -421,20 +426,18 @@ export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> =>
 export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
 export const useIsAbleToShowTagLabel = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowTagLabel';
   const key = 'isAbleToShowTagLabel';
   const { data: isUserPage } = useIsUserPage();
   const { data: isUserPage } = useIsUserPage();
-  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isSharedUser } = useIsSharedUser();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
-  const { data: notFoundTargetPathOrId } = useNotFoundTargetPathOrId();
+  const { data: isNotFound } = useIsNotFound();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
 
 
-  const includesUndefined = [isUserPage, currentPagePath, isIdenticalPath, notFoundTargetPathOrId, editorMode].some(v => v === undefined);
+  const includesUndefined = [isUserPage, isSharedUser, isIdenticalPath, isNotFound, editorMode].some(v => v === undefined);
 
 
   const isViewMode = editorMode === EditorMode.View;
   const isViewMode = editorMode === EditorMode.View;
-  const isNotFoundPage = notFoundTargetPathOrId != null;
 
 
   return useSWRImmutable(
   return useSWRImmutable(
     includesUndefined ? null : [key, editorMode],
     includesUndefined ? null : [key, editorMode],
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    () => !isUserPage && !isSharedPage(currentPagePath!) && !isIdenticalPath && !(isViewMode && isNotFoundPage),
+    () => !isUserPage && !isSharedUser && !isIdenticalPath && !(isViewMode && isNotFound),
   );
   );
 };
 };
 
 
@@ -444,13 +447,12 @@ export const useIsAbleToShowPageEditorModeManager = (): SWRResponse<boolean, Err
   const { data: isForbidden } = useIsForbidden();
   const { data: isForbidden } = useIsForbidden();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isTrashPage } = useIsTrashPage();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();
-  const { data: isNotFoundPermalink } = useIsNotFoundPermalink();
 
 
-  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser, isNotFoundPermalink].some(v => v === undefined);
+  const includesUndefined = [isNotCreatable, isForbidden, isTrashPage, isSharedUser].some(v => v === undefined);
 
 
   return useSWRImmutable(
   return useSWRImmutable(
     includesUndefined ? null : key,
     includesUndefined ? null : key,
-    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser && !isNotFoundPermalink,
+    () => !isNotCreatable && !isForbidden && !isTrashPage && !isSharedUser,
   );
   );
 };
 };
 
 

+ 6 - 9
packages/app/src/styles/_layout.scss

@@ -1,6 +1,10 @@
-@use './variables';
+@use './variables' as var;
 @use './bootstrap/init' as bs;
 @use './bootstrap/init' as bs;
 
 
+:root {
+  font-size: var.$font-size-root;
+}
+
 body {
 body {
   overflow-y: scroll !important;
   overflow-y: scroll !important;
   overscroll-behavior-y: none;
   overscroll-behavior-y: none;
@@ -30,7 +34,7 @@ body.growi-layout-fluid .grw-container-convertible {
 
 
 // padding settings for GrowiNavbarBottom
 // padding settings for GrowiNavbarBottom
 .page-wrapper {
 .page-wrapper {
-  padding-bottom: variables.$grw-navbar-bottom-height;
+  padding-bottom: var.$grw-navbar-bottom-height;
 
 
   @include bs.media-breakpoint-up(md) {
   @include bs.media-breakpoint-up(md) {
     padding-bottom: unset;
     padding-bottom: unset;
@@ -131,10 +135,3 @@ body.growi-layout-fluid .grw-container-convertible {
     }
     }
   }
   }
 }
 }
-
-.system-version {
-  position: fixed;
-  right: 0.5em;
-  bottom: 0;
-  opacity: 0.6;
-}

+ 0 - 12
packages/app/src/styles/_navbar.scss

@@ -1,15 +1,3 @@
-.grw-navbar-bottom {
-  height: $grw-navbar-bottom-height;
-
-  // apply transition
-  transition-property: bottom;
-  @include apply-navigation-transition();
-
-  &.grw-navbar-bottom-drawer-opened {
-    bottom: -$grw-navbar-bottom-height;
-  }
-}
-
 .grw-custom-nav-tab,
 .grw-custom-nav-tab,
 .grw-custom-nav-dropdown {
 .grw-custom-nav-dropdown {
   svg {
   svg {

Някои файлове не бяха показани, защото твърде много файлове са промени