Browse Source

Merge branch 'support/apply-nextjs-2' into imprv/104022-draw-io-on-editor-navbar

kaori 3 years ago
parent
commit
102b33adcd
100 changed files with 1847 additions and 1698 deletions
  1. 20 1
      CHANGELOG.md
  2. 1 1
      lerna.json
  3. 1 1
      package.json
  4. 2 2
      packages/app/bin/github-actions/update-readme.sh
  5. 0 1
      packages/app/config/ci/.env.local.for-ci
  6. 1 1
      packages/app/config/rate-limiter.ts
  7. 9 2
      packages/app/docker/Dockerfile
  8. 2 2
      packages/app/docker/README.md
  9. 3 0
      packages/app/next.config.js
  10. 12 9
      packages/app/package.json
  11. 3 0
      packages/app/public/static/locales/en_US/admin.json
  12. 22 3
      packages/app/public/static/locales/en_US/translation.json
  13. 3 0
      packages/app/public/static/locales/ja_JP/admin.json
  14. 22 3
      packages/app/public/static/locales/ja_JP/translation.json
  15. 3 0
      packages/app/public/static/locales/zh_CN/admin.json
  16. 22 3
      packages/app/public/static/locales/zh_CN/translation.json
  17. 8 14
      packages/app/resource/locales/en_US/sandbox-diagrams.md
  18. 1 1
      packages/app/resource/locales/en_US/sandbox-math.md
  19. 34 34
      packages/app/resource/locales/en_US/sandbox.md
  20. 8 15
      packages/app/resource/locales/ja_JP/sandbox-diagrams.md
  21. 1 1
      packages/app/resource/locales/ja_JP/sandbox-math.md
  22. 34 34
      packages/app/resource/locales/ja_JP/sandbox.md
  23. 8 14
      packages/app/resource/locales/zh_CN/sandbox-diagrams.md
  24. 1 1
      packages/app/resource/locales/zh_CN/sandbox-math.md
  25. 34 34
      packages/app/resource/locales/zh_CN/sandbox.md
  26. 3 3
      packages/app/src/client/services/ContextExtractor.tsx
  27. 0 28
      packages/app/src/client/util/blink-section-header.ts
  28. 2 0
      packages/app/src/client/util/editor.ts
  29. 4 3
      packages/app/src/client/util/smooth-scroll.ts
  30. 2 2
      packages/app/src/components/Admin/App/AppSetting.jsx
  31. 17 10
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  32. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  33. 2 6
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  34. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  35. 3 4
      packages/app/src/components/BookmarkButtons.module.scss
  36. 2 2
      packages/app/src/components/BookmarkButtons.tsx
  37. 29 10
      packages/app/src/components/Comments.tsx
  38. 10 2
      packages/app/src/components/Common/ClosableTextInput.tsx
  39. 1 1
      packages/app/src/components/ContentLinkButtons.module.scss
  40. 48 45
      packages/app/src/components/ContentLinkButtons.tsx
  41. 2 1
      packages/app/src/components/CustomNavigation/CustomTabContent.tsx
  42. 3 3
      packages/app/src/components/EmptyTrashButton.tsx
  43. 14 9
      packages/app/src/components/Fab.tsx
  44. 1 5
      packages/app/src/components/Icons/CreatePageIcon.tsx
  45. 1 3
      packages/app/src/components/Icons/RecentlyCreatedIcon.tsx
  46. 1 6
      packages/app/src/components/Icons/ReturnTopIcon.tsx
  47. 2 2
      packages/app/src/components/IdenticalPathPage.tsx
  48. 0 217
      packages/app/src/components/InstallerForm.jsx
  49. 231 0
      packages/app/src/components/InstallerForm.tsx
  50. 0 0
      packages/app/src/components/Invited.module.scss
  51. 111 0
      packages/app/src/components/InvitedForm.tsx
  52. 3 3
      packages/app/src/components/Layout/BasicLayout.tsx
  53. 1 2
      packages/app/src/components/Layout/NoLoginLayout.tsx
  54. 50 0
      packages/app/src/components/Layout/ShareLinkLayout.tsx
  55. 4 4
      packages/app/src/components/LikeButtons.module.scss
  56. 2 2
      packages/app/src/components/LikeButtons.tsx
  57. 174 144
      packages/app/src/components/LoginForm.tsx
  58. 0 1
      packages/app/src/components/Me/BasicInfoSettings.tsx
  59. 1 0
      packages/app/src/components/Navbar/DrawerToggler.tsx
  60. 31 42
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  61. 10 13
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  62. 21 33
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  63. 1 3
      packages/app/src/components/NotFoundPage.tsx
  64. 7 17
      packages/app/src/components/Page.tsx
  65. 113 95
      packages/app/src/components/Page/DisplaySwitcher.tsx
  66. 0 2
      packages/app/src/components/Page/RevisionLoader.jsx
  67. 2 76
      packages/app/src/components/Page/RevisionRenderer.tsx
  68. 0 52
      packages/app/src/components/Page/ShareLinkAlert.jsx
  69. 52 0
      packages/app/src/components/Page/ShareLinkAlert.tsx
  70. 15 16
      packages/app/src/components/Page/TagLabels.tsx
  71. 5 3
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  72. 62 65
      packages/app/src/components/PageAttachment.tsx
  73. 0 97
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  74. 98 0
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  75. 0 45
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  76. 47 0
      packages/app/src/components/PageAttachment/PageAttachmentList.tsx
  77. 29 50
      packages/app/src/components/PageComment.tsx
  78. 47 43
      packages/app/src/components/PageComment/Comment.tsx
  79. 6 10
      packages/app/src/components/PageComment/CommentEditor.module.scss
  80. 27 25
      packages/app/src/components/PageComment/CommentEditor.tsx
  81. 0 47
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  82. 9 0
      packages/app/src/components/PageComment/CommentPreview.module.scss
  83. 12 6
      packages/app/src/components/PageComment/CommentPreview.tsx
  84. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.tsx
  85. 13 10
      packages/app/src/components/PageComment/ReplyComments.tsx
  86. 2 0
      packages/app/src/components/PageComment/_comment-inheritance.scss
  87. 27 12
      packages/app/src/components/PageContentFooter.tsx
  88. 9 17
      packages/app/src/components/PageEditor.tsx
  89. 9 2
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  90. 5 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss
  91. 2 1
      packages/app/src/components/PageEditor/Editor.module.scss
  92. 28 46
      packages/app/src/components/PageEditor/Editor.tsx
  93. 2 2
      packages/app/src/components/PageEditor/Preview.tsx
  94. 1 0
      packages/app/src/components/PageEditor/_page-editor-inheritance.scss
  95. 117 98
      packages/app/src/components/PageEditorByHackmd.tsx
  96. 15 28
      packages/app/src/components/PageList/BookmarkList.tsx
  97. 0 39
      packages/app/src/components/PageList/PageListItemS.jsx
  98. 32 0
      packages/app/src/components/PageList/PageListItemS.tsx
  99. 1 0
      packages/app/src/components/PagePathHierarchicalLink.tsx
  100. 8 5
      packages/app/src/components/PagePathNav.tsx

+ 20 - 1
CHANGELOG.md

@@ -1,9 +1,28 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.4...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.4](https://github.com/weseek/growi/compare/v5.1.3...v5.1.4) - 2022-09-12
+
+### 💎 Features
+
+- feat:  Truncate long path when recent changes is in S size (#6263) @mudana-grune
+- feat: In-app notifications when removing descendants of subscribed pages (#6192) @Shunm634-source
+- feat: Not increment ordered list number in CodeMirror (#6462) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv: Added page URL to mail subject (#6554) @hakumizuki
+
+### 🐛 Bug Fixes
+
+- fix: Cannot update user group without parent (#6530) @kaoritokashiki
+- fix: Make PageTree input not draggable when editting (#6525) @hakumizuki
+- fix: Pagetree input hit enter (#6526) @hakumizuki
+- fix: Disallow retrieval of revision data that does not match the page (#6537) @miya
+
 ## [v5.1.3](https://github.com/weseek/growi/compare/v5.1.2...v5.1.3) - 2022-08-28
 
 ### 💎 Features

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

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

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

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

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

+ 1 - 1
packages/app/config/rate-limiter.ts

@@ -33,7 +33,7 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
     maxRequests: MAX_REQUESTS_TIER_1,
     usersPerIpProspection: 100,
   },
-  '/login/activateInvited': {
+  '/invited/activateInvited': {
     method: 'POST',
     maxRequests: MAX_REQUESTS_TIER_2,
   },

+ 9 - 2
packages/app/docker/Dockerfile

@@ -26,6 +26,10 @@ ENV optDir /opt
 
 WORKDIR ${optDir}
 
+ENV nodeModulesGrowiPackagesDir ${optDir}/node_modules/@growi
+# expect a string seperated by commas (e.g. "A,B")
+ENV removeNodeModulesSymlinkPaths ${nodeModulesGrowiPackagesDir}/slackbot-proxy
+
 # copy files
 COPY --from=packages-json-picker ${optDir} .
 
@@ -33,6 +37,9 @@ COPY --from=packages-json-picker ${optDir} .
 RUN yarn config set network-timeout 300000
 RUN npx -y lerna bootstrap -- --frozen-lockfile
 
+# remove unnecessary symlinks
+RUN rm -f $(echo ${removeNodeModulesSymlinkPaths} | sed -e "s/,/ /g")
+
 # make artifacts
 RUN tar -cf node_modules.tar \
   node_modules \
@@ -55,7 +62,6 @@ RUN tar -cf node_modules.tar \
   packages/*/node_modules
 
 
-
 ##
 ## prebuilder-default
 ##
@@ -74,7 +80,6 @@ RUN tar -xf node_modules.tar
 RUN rm node_modules.tar
 
 
-
 ##
 ## prebuilder-nocdn
 ##
@@ -94,6 +99,7 @@ ENV optDir /opt
 
 WORKDIR ${optDir}
 
+# ignore eslint and stylelint
 COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 # copy all related packages
 COPY packages/app packages/app
@@ -103,6 +109,7 @@ COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
 COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
+COPY packages/remark-growi-plugin packages/remark-growi-plugin
 
 # build
 RUN yarn lerna run build

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`5.1.3`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
-* [`5.1.3-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.3/packages/app/docker/Dockerfile)
+* [`5.1.4`, `5.1`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
+* [`5.1.4-nocdn`, `5.1-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.1.4/packages/app/docker/Dockerfile)
 * [`5.0.11`, `5.0` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`5.0.11-nocdn`, `5.0-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.11/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)

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

@@ -63,6 +63,9 @@ module.exports = async(phase, { defaultConfig }) => {
     // see: https://github.com/vercel/next.js/discussions/27876
     // experimental: { esmExternals: true }, // Prefer loading of ES Modules over CommonJS
 
+    eslint: {
+      ignoreDuringBuilds: true,
+    },
     reactStrictMode: true,
     swcMinify: true,
     typescript: {

+ 12 - 9
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.1.4-RC.0",
+  "version": "5.1.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -56,6 +56,7 @@
     "string-width": "5.0.0 or above exports only ESM."
   },
   "dependencies": {
+    "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@aws-sdk/client-s3": "^3.58.0",
     "@aws-sdk/s3-request-presigner": "^3.58.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
@@ -63,11 +64,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.1.4-RC.0",
-    "@growi/core": "^5.1.4-RC.0",
-    "@growi/plugin-attachment-refs": "^5.1.4-RC.0",
-    "@growi/plugin-lsx": "^5.1.4-RC.0",
-    "@growi/slack": "^5.1.4-RC.0",
+    "@growi/codemirror-textlint": "^5.1.5-RC.0",
+    "@growi/core": "^5.1.5-RC.0",
+    "@growi/plugin-attachment-refs": "^5.1.5-RC.0",
+    "@growi/plugin-lsx": "^5.1.5-RC.0",
+    "@growi/slack": "^5.1.5-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -91,6 +92,7 @@
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
+    "csv-to-markdown-table": "^1.1.0",
     "date-fns": "^2.23.0",
     "detect-indent": "^7.0.0",
     "diff": "^5.0.0",
@@ -128,7 +130,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "next": "^12.2.5",
-    "next-i18next": "^11.0.0",
+    "next-i18next": "^11.3.0",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.0",
     "nocache": "^3.0.1",
@@ -191,7 +193,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.1.4-RC.0",
+    "@growi/ui": "^5.1.5-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^12.2.3",
@@ -209,7 +211,6 @@
     "colors": "=1.4.0",
     "connect-browser-sync": "^2.1.0",
     "core-js": "=2.6.9",
-    "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
@@ -236,9 +237,11 @@
     "react-dropzone": "^11.2.4",
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
+    "react-scroll": "^1.8.7",
     "react-use-ripple": "^1.5.2",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
+    "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "reveal.js": "^4.3.1",
     "sass": "^1.53.0",

+ 3 - 0
packages/app/public/static/locales/en_US/admin.json

@@ -1,4 +1,7 @@
 {
+  "meta": {
+    "display_name": "English"
+  },
   "wiki_management_home_page": "Wiki Management Home Page",
   "app_settings": "App Settings",
   "security_settings": {

+ 22 - 3
packages/app/public/static/locales/en_US/translation.json

@@ -150,7 +150,6 @@
   "add_bookmark": "Add to Bookmarks",
   "remove_bookmark": "Remove from Bookmarks",
   "wide_view": "Wide View",
-  "Recent Created": "Recent Created",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
   "original_path":"Original path",
@@ -192,7 +191,9 @@
     "setup": "Setup",
     "create_initial_account": "Create an initial account",
     "initial_account_will_be_administrator_automatically": "The initial account will be administrator automatically.",
-    "unavaliable_user_id": "This 'User ID' is unavailable."
+    "unavaliable_user_id": "This 'User ID' is unavailable.",
+    "failed_to_install": "Failed to install GROWI. Please try again.",
+    "failed_to_login_after_install": "Failed to login after installation. Redirecting to the login form ..."
   },
   "breaking_changes": {
     "v346_using_basic_auth": "Basic Authentication currently in use will <strong>no longer be available</strong> in the near future. Remove settings from %s"
@@ -675,6 +676,10 @@
     "Registration successful": "Registration successful",
     "Setup": "Setup"
   },
+  "invited": {
+    "discription_heading": "Create Account",
+    "discription": "Create an your account with the invited email address"
+  },
   "export_bulk": {
     "failed_to_export": "Failed to export",
     "failed_to_count_pages": "Failed to count pages",
@@ -706,7 +711,17 @@
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}",
     "successfully_send_email_auth":"We sent an email to {{email}}. Please click the URL in the email and complete the registration.",
-    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired."
+    "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired.",
+    "user_already_loggedin": "You cannot create a new account when you are logged in.",
+    "registration_closed": "You are not authorized to create a new account.",
+    "Username has invalid characters": "Username has invalid characters.",
+    "Username field is required": "User ID field is required",
+    "Name field is required": "Name field is required",
+    "Email format is invalid": "Email format is invalid",
+    "Email field is required": "Email field is required",
+    "Password has invalid character": "Password has invalid character",
+    "Password minimum character should be more than 8 characters": "Password minimum character should be more than 8 characters",
+    "Password field is required": "Password field is required"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
@@ -838,5 +853,9 @@
   "page_operation":{
     "paths_recovered": "Paths recovered successfully",
     "path_recovery_failed":"Path recovery failed"
+  },
+  "footer": {
+    "bookmarks": "Bookmarks",
+    "recently_created": "Recently Created"
   }
 }

+ 3 - 0
packages/app/public/static/locales/ja_JP/admin.json

@@ -1,4 +1,7 @@
 {
+  "meta": {
+    "display_name": "日本語"
+  },
   "Update": "更新",
   "Delete": "削除",
   "User": "ユーザー",

+ 22 - 3
packages/app/public/static/locales/ja_JP/translation.json

@@ -143,7 +143,6 @@
   "add_bookmark": "ブックマークに追加",
   "remove_bookmark": "ブックマークから削除",
   "wide_view": "ワイドビュー",
-  "Recent Created": "最新の作成",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
   "original_path":"元のパス",
@@ -185,7 +184,9 @@
     "setup": "セットアップ",
     "create_initial_account": "最初のアカウントの作成",
     "initial_account_will_be_administrator_automatically": "初めに作成するアカウントは、自動的に管理者権限が付与されます",
-    "unavaliable_user_id": "このユーザーIDは利用できません。"
+    "unavaliable_user_id": "このユーザーIDは利用できません。",
+    "failed_to_install": "GROWI のインストールに失敗しました。再度お試しください。",
+    "failed_to_login_after_install": "インストール後、ログインに失敗しました。ログインフォームに遷移しています ..."
   },
   "breaking_changes": {
     "v346_using_basic_auth": "現在利用中の Basic 認証機能は、近い将来<strong>廃止されます</strong>。%s から設定を削除してください。"
@@ -666,6 +667,10 @@
     "Registration successful": "登録完了",
     "Setup": "セットアップ"
   },
+  "invited": {
+    "discription_heading": "アカウント作成",
+    "discription": "招待を受け取ったメールアドレスでアカウントを作成します"
+  },
   "export_bulk": {
     "failed_to_export": "ページのエクスポートに失敗しました",
     "failed_to_count_pages": "ページ数の取得に失敗しました",
@@ -697,7 +702,17 @@
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}",
     "successfully_send_email_auth":"{{email}} にメールを送信しました。添付されたURLをクリックし、本登録を完了させてください",
-    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。"
+    "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。",
+    "user_already_loggedin": "ログイン中のため、新規アカウントを作成できませんでした。",
+    "registration_closed": "新しいアカウントを作成する権限がありません。",
+    "Username has invalid characters": "ユーザー名に不正な文字が含まれています.",
+    "Username field is required": "User ID は必須項目です",
+    "Name field is required": "ユーザーID は必須項目です",
+    "Email format is invalid": "メールアドレスのフォーマットが無効です",
+    "Email field is required": "メールアドレスは必須項目です",
+    "Password has invalid character": "パスワードに無効な文字があります",
+    "Password minimum character should be more than 8 characters": "パスワードの最小文字数は8文字以上です",
+    "Password field is required": "パスワードの欄は必ず入力してください"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
@@ -829,5 +844,9 @@
   "page_operation":{
     "paths_recovered": "パスを修復しました",
     "path_recovery_failed":"パスを修復できませんでした"
+  },
+  "footer": {
+    "bookmarks": "ブックマーク",
+    "recently_created": "最近作成したページ"
   }
 }

+ 3 - 0
packages/app/public/static/locales/zh_CN/admin.json

@@ -1,4 +1,7 @@
 {
+  "meta": {
+    "display_name": "简体中文"
+  },
   "Update": "更新",
   "Delete": "删除",
   "User": "用户",

+ 22 - 3
packages/app/public/static/locales/zh_CN/translation.json

@@ -153,7 +153,6 @@
   "add_bookmark": "添加到书签",
   "remove_bookmark": "从书签中删除",
   "wide_view": "视野开阔",
-	"Recent Created": "最新创建",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
   "original_path":"Original path",
@@ -187,7 +186,9 @@
 		"setup": "安装",
 		"create_initial_account": "创建初始用户",
 		"initial_account_will_be_administrator_automatically": "初始帐户将自动成为管理员。",
-		"unavaliable_user_id": "用户ID不可用"
+		"unavaliable_user_id": "用户ID不可用",
+    "failed_to_install": "GROWI安装失败。请再试一次。",
+    "failed_to_login_after_install": "安装后登录失败。重定向到登录表格..."
 	},
 	"breaking_changes": {
 		"v346_using_basic_auth": "当前使用的基本身份验证在不久的将来将不再可用。从%s中删除设置"
@@ -722,6 +723,10 @@
 		"Registration successful": "注册成功",
 		"Setup": "安装程序"
 	},
+  "invited": {
+    "discription_heading": "创建账户",
+    "discription": "用被邀请的电子邮件地址创建一个你的账户"
+  },
   "export_bulk": {
     "failed_to_export": "导出失败",
     "failed_to_count_pages": "页面计数失败",
@@ -753,7 +758,17 @@
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}",
     "successfully_send_email_auth":"我们向 {{email}} 发送了一封电子邮件。 请点击邮件中的网址并完成注册。",
-    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。"
+    "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。",
+    "user_already_loggedin": "当你登录的时候,你不能创建一个新的账户。",
+    "registration_closed": "你无权创建一个新的账户。",
+    "Username has invalid characters": "用户名有无效字符",
+    "Username field is required": "用户ID字段是必需的",
+    "Name field is required": "姓名字段为必填项",
+    "Email format is invalid": "电子邮件的格式是无效的",
+    "Email field is required": "电子邮件字段是必需的",
+    "Password has invalid character": "密码有无效字符",
+    "Password minimum character should be more than 8 characters": "密码最小字符应超过8个字符",
+    "Password field is required": "密码字段是必需的"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
@@ -885,5 +900,9 @@
   "page_operation":{
     "paths_recovered": "成功恢复了页面路径",
     "path_recovery_failed":"路径恢复失败"
+  },
+  "footer": {
+    "bookmarks": "书签",
+    "recently_created": "最近创建页面"
   }
 }

+ 8 - 14
packages/app/resource/locales/en_US/sandbox-diagrams.md

@@ -29,6 +29,7 @@ See [PlantUML](http://plantuml.com/).
 
 ## Sequence diagram
 
+``` plantuml
 @startuml
 skinparam sequenceArrowThickness 2
 skinparam roundcorner 20
@@ -58,13 +59,12 @@ A --> User: Done
 deactivate A
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Class diagram
 
+``` plantuml
 @startuml
 
 class BaseClass
@@ -86,13 +86,11 @@ namespace net.foo {
 BaseClass <|-- net.unused.Person
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Component diagram
-
+``` plantuml
 @startuml
 
 package "Some Group" {
@@ -125,14 +123,12 @@ database "MySql" {
 [Folder 3] --> [Frame 4]
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## State diagram
 
-
+``` plantuml
 @startuml
 scale 600 width
 
@@ -153,9 +149,7 @@ State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 # :pencil: blockdiag

+ 1 - 1
packages/app/resource/locales/en_US/sandbox-math.md

@@ -1,6 +1,6 @@
 # :pencil: Math
 
-See [MathJax](https://www.mathjax.org/).
+See [KaTeX](https://katex.org/).
 
 ## Inline Formula
 

+ 34 - 34
packages/app/resource/locales/en_US/sandbox.md

@@ -351,65 +351,65 @@ aligned    | aligned     | aligned
 | left       | right       | center       |
 | aligned    | aligned     | aligned      |
 
-## TSV (crowi-plus notation)
+## TSV
 
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv
-Content Cell  Content Cell
-Content Cell  Content Cell
-:::
-```
+~~~
 
-::: tsv
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## TSV with header (crowi-plus notation)
+## TSV with header
 
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
-```
+~~~
 
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## CSV (crowi-plus original notation)
+## CSV
 
-```
-::: csv
+~~~
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
-## CSV with header (crowi-plus original notation)
+## CSV with header
 
-```
-::: csv-h
+~~~
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv-h
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
 
 # :memo: Footnote

+ 8 - 15
packages/app/resource/locales/ja_JP/sandbox-diagrams.md

@@ -28,7 +28,7 @@ See [diagrams.net](https://diagrams.net)
 See [PlantUML](http://plantuml.com/).
 
 ## シーケンス図
-
+``` plantuml
 @startuml
 skinparam sequenceArrowThickness 2
 skinparam roundcorner 20
@@ -58,13 +58,11 @@ A --> User: Done
 deactivate A
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## クラス図
-
+``` plantuml
 @startuml
 
 class BaseClass
@@ -86,13 +84,11 @@ namespace net.foo {
 BaseClass <|-- net.unused.Person
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## コンポーネント図
-
+``` plantuml
 @startuml
 
 package "Some Group" {
@@ -125,14 +121,12 @@ database "MySql" {
 [Folder 3] --> [Frame 4]
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## ステート図
 
-
+``` plantuml
 @startuml
 scale 600 width
 
@@ -153,9 +147,8 @@ State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
 
 @enduml
+```
 
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
 
 # :pencil: blockdiag
 

+ 1 - 1
packages/app/resource/locales/ja_JP/sandbox-math.md

@@ -1,6 +1,6 @@
 # :pencil: Math
 
-See [MathJax](https://www.mathjax.org/).
+See [KaTeX](https://katex.org/).
 
 ## Inline Formula
 

+ 34 - 34
packages/app/resource/locales/ja_JP/sandbox.md

@@ -350,65 +350,65 @@ aligned    | aligned     | aligned
 | left       | right       | center       |
 | aligned    | aligned     | aligned      |
 
-## TSV (crowi-plus 独自記法)
+## TSV
 
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv
-Content Cell  Content Cell
-Content Cell  Content Cell
-:::
-```
+~~~
 
-::: tsv
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## TSV ヘッダ付き (crowi-plus 独自記法)
+## TSV (ヘッダ付き)
 
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
-```
+~~~
 
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## CSV (crowi-plus 独自記法)
+## CSV
 
-```
-::: csv
+~~~
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
-## CSV ヘッダ付き (crowi-plus 独自記法)
+## CSV (ヘッダ付き)
 
-```
-::: csv-h
+~~~
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv-h
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
 
 # :memo: Footnote

+ 8 - 14
packages/app/resource/locales/zh_CN/sandbox-diagrams.md

@@ -29,6 +29,7 @@ See [PlantUML](http://plantuml.com/).
 
 ## Sequence diagram
 
+``` plantuml
 @startuml
 skinparam sequenceArrowThickness 2
 skinparam roundcorner 20
@@ -58,13 +59,12 @@ A --> User: Done
 deactivate A
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Class diagram
 
+``` plantuml
 @startuml
 
 class BaseClass
@@ -86,13 +86,11 @@ namespace net.foo {
 BaseClass <|-- net.unused.Person
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## Component diagram
-
+``` plantuml
 @startuml
 
 package "Some Group" {
@@ -125,14 +123,12 @@ database "MySql" {
 [Folder 3] --> [Frame 4]
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 ## State diagram
 
-
+``` plantuml
 @startuml
 scale 600 width
 
@@ -153,9 +149,7 @@ State3 --> [*] : Succeeded / Save Result
 State3 --> [*] : Aborted
 
 @enduml
-
-<!-- Reset PlantUML -->
-<div class="clearfix"></div>
+```
 
 
 # :pencil: blockdiag

+ 1 - 1
packages/app/resource/locales/zh_CN/sandbox-math.md

@@ -1,6 +1,6 @@
 # :pencil: Math
 
-See [MathJax](https://www.mathjax.org/).
+See [KaTeX](https://katex.org/).
 
 ## Inline Formula
 

+ 34 - 34
packages/app/resource/locales/zh_CN/sandbox.md

@@ -351,65 +351,65 @@ aligned    | aligned     | aligned
 | left       | right       | center       |
 | aligned    | aligned     | aligned      |
 
-## TSV (crowi-plus notation)
+## TSV
 
+~~~
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv
-Content Cell  Content Cell
-Content Cell  Content Cell
-:::
-```
+~~~
 
-::: tsv
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## TSV with header (crowi-plus notation)
+## TSV with header
 
+~~~
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
 ```
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
-```
+~~~
 
-::: tsv-h
-First Header Second Header
-Content Cell Content Cell
-Content Cell Content Cell
-:::
+``` tsv-h
+First Header	Second Header
+Content Cell	Content Cell
+Content Cell	Content Cell
+```
 
-## CSV (crowi-plus original notation)
+## CSV
 
-```
-::: csv
+~~~
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv
+``` csv
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
-## CSV with header (crowi-plus original notation)
+## CSV with header
 
-```
-::: csv-h
+~~~
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
 ```
+~~~
 
-::: csv-h
+``` csv-h
 First Header,Second Header
 Content Cell,Content Cell
 Content Cell,Content Cell
-:::
+```
 
 
 # :memo: Footnote

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

@@ -16,7 +16,7 @@ import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websoc
 import {
   useSiteUrl,
   useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
-  useIsNotCreatable, useIsTrashPage, useIsUserPage, useLastUpdateUsername,
+  useIsTrashPage, useIsUserPage, useLastUpdateUsername,
   useCurrentPageId, usePageIdOnHackmd, usePageUser, useCurrentPagePath, useRevisionCreatedAt, useRevisionId, useRevisionIdHackmdSynced,
   useShareLinkId, useShareLinksNumber, useTemplateTagData, useCurrentUser, useTargetAndAncestors,
   useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
@@ -143,9 +143,9 @@ const ContextExtractorOnce: FC = () => {
   useHasChildren(hasChildren);
   useHasDraftOnHackmd(hasDraftOnHackmd);
   useIsIdenticalPath(isIdenticalPath);
-  useIsNotCreatable(isNotCreatable);
+  // useIsNotCreatable(isNotCreatable);
   useIsForbidden(isForbidden);
-  useIsTrashPage(isTrashPage);
+  // useIsTrashPage(isTrashPage);
   useIsUserPage(isUserPage);
   useLastUpdateUsername(lastUpdateUsername);
   useCurrentPageId(pageId);

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

@@ -1,28 +0,0 @@
-let lastBlinkedElem;
-
-export const blinkElem = (elem: HTMLElement): void => {
-  if (lastBlinkedElem != null) {
-    lastBlinkedElem.classList.remove('blink');
-  }
-
-  elem.classList.add('blink');
-  lastBlinkedElem = elem;
-};
-
-export const blinkSectionHeaderAtBoot = (): HTMLElement | undefined => {
-  const { hash } = window.location;
-
-  if (hash.length === 0) {
-    return;
-  }
-
-  // omit '#'
-  const id = hash.replace('#', '');
-  // don't use jQuery and document.querySelector
-  //  because hash may containe Base64 encoded strings
-  const elem = document.getElementById(id);
-  if (elem != null && elem.tagName.match(/h\d+/i)) { // match h1, h2, h3...
-    blinkElem(elem);
-    return elem;
-  }
-};

+ 2 - 0
packages/app/src/client/util/editor.ts

@@ -7,6 +7,7 @@ export const getOptionsToSave = (
     grantUserGroupId: string | null | undefined,
     grantUserGroupName: string | null | undefined,
     pageTags: string[],
+    isSyncRevisionToHackmd?: boolean,
 ): OptionsToSave => {
   return {
     pageTags,
@@ -15,5 +16,6 @@ export const getOptionsToSave = (
     grant,
     grantUserGroupId,
     grantUserGroupName,
+    isSyncRevisionToHackmd,
   };
 };

+ 4 - 3
packages/app/src/client/util/smooth-scroll.ts

@@ -1,10 +1,11 @@
 const WIKI_HEADER_LINK = 120;
 
-export const smoothScrollIntoView = (element: HTMLElement, offsetTop = 0, scrollElement: HTMLElement | Window = window): void => {
-  const targetElement = element || window.document.body;
+export const smoothScrollIntoView = (
+    element: HTMLElement = window.document.body, offsetTop = 0, scrollElement: HTMLElement | Window = window,
+): void => {
 
   // get the distance to the target element top
-  const rectTop = targetElement.getBoundingClientRect().top;
+  const rectTop = element.getBoundingClientRect().top;
 
   const top = window.pageYOffset + rectTop - offsetTop;
 

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

@@ -79,8 +79,8 @@ const AppSetting = (props) => {
         <div className="col-md-6 py-2">
           {
             i18nConfig.locales.map((locale) => {
-              const fixedT = i18n.getFixedT(locale);
-              i18n.loadLanguages(i18nConfig.locales);
+              if (i18n == null) { return }
+              const fixedT = i18n.getFixedT(locale, 'admin');
 
               return (
                 <div key={locale} className="custom-control custom-radio custom-control-inline">

+ 17 - 10
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -1,11 +1,8 @@
-/* eslint-disable no-multi-spaces */
-/* eslint-disable react/jsx-props-no-multi-spaces */
-
-
 import React from 'react';
 
 import { pathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 import PropTypes from 'prop-types';
 import urljoin from 'url-join';
 
@@ -25,6 +22,7 @@ const AdminNavigation = (props) => {
   // eslint-disable-next-line react/prop-types
   const MenuLabel = ({ menu }) => {
     switch (menu) {
+      /* eslint-disable no-multi-spaces */
       case 'app':                      return <><i className="icon-fw icon-settings"></i>        { t('app_settings') }</>;
       case 'security':                 return <><i className="icon-fw icon-shield"></i>          { t('security_settings.security_settings') }</>;
       case 'markdown':                 return <><i className="icon-fw icon-note"></i>            { t('markdown_settings.markdown_settings') }</>;
@@ -42,6 +40,7 @@ const AdminNavigation = (props) => {
       case 'audit-log':                return <><i className="icon-fw icon-feed"></i>            { t('audit_log_management.audit_log')}</>;
       case 'cloud':                    return <><i className="icon-fw icon-share-alt"></i>       { t('to_cloud_settings')} </>;
       default:                         return <><i className="icon-fw icon-home"></i>            { t('wiki_management_home_page') }</>;
+      /* eslint-enable no-multi-spaces */
     }
   };
 
@@ -53,13 +52,17 @@ const AdminNavigation = (props) => {
       ? 'list-group-item list-group-item-action border-0 round-corner'
       : 'dropdown-item px-3 py-2';
 
+    const href = isRoot ? '/admin' : urljoin('/admin', menu);
+
     return (
-      <a
-        href={isRoot ? '/admin' : urljoin('/admin', menu)}
-        className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
-      >
-        <MenuLabel menu={menu} />
-      </a>
+      <Link href={href}>
+        <a
+          href={href}
+          className={`${pageTransitionClassName} ${isActive ? 'active' : ''}`}
+        >
+          <MenuLabel menu={menu} />
+        </a>
+      </Link>
     );
   };
 
@@ -76,6 +79,7 @@ const AdminNavigation = (props) => {
   const getListGroupItemOrDropdownItemList = (isListGroupItems) => {
     return (
       <>
+        {/* eslint-disable no-multi-spaces */}
         <MenuLink menu="home"         isListGroupItems isActive={pathname === '/admin'} isRoot />
         <MenuLink menu="app"          isListGroupItems isActive={isActiveMenu('/app')} />
         <MenuLink menu="security"     isListGroupItems isActive={isActiveMenu('/security')} />
@@ -100,6 +104,7 @@ const AdminNavigation = (props) => {
             </a>
           )
         } */}
+        {/* eslint-enable no-multi-spaces */}
       </>
     );
   };
@@ -123,6 +128,7 @@ const AdminNavigation = (props) => {
           aria-expanded="false"
         >
           <span className="float-left">
+            {/* eslint-disable no-multi-spaces */}
             {pathname === '/admin' &&              <MenuLabel menu="home" />}
             {isActiveMenu('/app') &&               <MenuLabel menu="app" />}
             {isActiveMenu('/security') &&          <MenuLabel menu="security" />}
@@ -136,6 +142,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {/* eslint-enable no-multi-spaces */}
           </span>
         </button>
         <div className="dropdown-menu" aria-labelledby="dropdown-admin-navigation">

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -30,7 +30,7 @@ export const UpdateParentConfirmModal: FC = () => {
         <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       {
-        targetGroup != null && updateData != null && updateData?.parent !== undefined ? (
+        targetGroup != null && updateData != null ? (
           <>
             <ModalBody>
               <div className="mb-2">

+ 2 - 6
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -105,15 +105,11 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   }, []);
 
   const updateUserGroup = useCallback(async(userGroup: IUserGroupHasId, update: Partial<IUserGroupHasId>, forceUpdateParents: boolean) => {
-    if (update.parent == null) {
-      throw Error('"parent" attr must not be null');
-    }
-
     const parentId = typeof update.parent === 'string' ? update.parent : update.parent?._id;
     const res = await apiv3Put<{ userGroup: IUserGroupHasId }>(`/user-groups/${userGroup._id}`, {
       name: update.name,
       description: update.description,
-      parentId,
+      parentId: parentId ?? null,
       forceUpdateParents,
     });
     const { userGroup: updatedUserGroup } = res.data;
@@ -138,7 +134,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   );
 
   const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (userGroupData?.parent === undefined || typeof userGroupData?.parent === 'string') {
+    if (typeof userGroupData?.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
       return;
     }

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -6,7 +6,7 @@ import { toastError } from '~/client/util/apiNotification';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 
-import PageListItemS from '../../PageList/PageListItemS';
+import { PageListItemS } from '../../PageList/PageListItemS';
 import PaginationWrapper from '../../PaginationWrapper';
 
 const pagingLimit = 10;

+ 3 - 4
packages/app/src/components/BookmarkButtons.module.scss

@@ -1,12 +1,11 @@
 @use '~/styles/bootstrap/init' as bs;
 
-.btn-bookmark {
-  :global {
+.btn-group-bookmark :global {
+  .btn-bookmark {
     box-shadow: none !important;
-  }
 
-  &:global {
     @include bs.button-outline-variant(rgba(bs.$secondary, 50%), bs.$orange, rgba(lighten(bs.$orange, 20%), 0.5), rgba(lighten(bs.$orange, 20%), 0.5));
+
     &:not(:disabled):not(.disabled):active,
     &:not(:disabled):not(.disabled).active {
       color: bs.$orange;

+ 2 - 2
packages/app/src/components/BookmarkButtons.tsx

@@ -52,12 +52,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
   }, [isGuestUser, isBookmarked]);
 
   return (
-    <div className="btn-group" role="group" aria-label="Bookmark buttons">
+    <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
       <button
         type="button"
         id="bookmark-button"
         onClick={handleClick}
-        className={`shadow-none btn btn-bookmark ${styles['btn-bookmark']} border-0
+        className={`shadow-none btn btn-bookmark border-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>

+ 29 - 10
packages/app/src/components/Comments.tsx

@@ -1,25 +1,33 @@
 import React from 'react';
 
+import { IRevisionHasId } from '@growi/core';
+import dynamic from 'next/dynamic';
+
 import { PageComment } from '~/components/PageComment';
-import { useCommentPreviewOptions } from '~/stores/renderer';
+import { useSWRxPageComment } from '~/stores/comment';
+
+import { useIsTrashPage, useCurrentUser } from '../stores/context';
+
+import { CommentEditorProps } from './PageComment/CommentEditor';
+
 
-import { useIsTrashPage } from '../stores/context';
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 
-import { CommentEditorLazyRenderer } from './PageComment/CommentEditorLazyRenderer';
 
 type CommentsProps = {
-  pageId?: string,
+  pageId: string,
+  revision: IRevisionHasId,
 }
 
 export const Comments = (props: CommentsProps): JSX.Element => {
 
-  const { pageId } = props;
+  const { pageId, revision } = props;
 
-  const { data: rendererOptions } = useCommentPreviewOptions();
+  const { mutate } = useSWRxPageComment(pageId);
   const { data: isDeleted } = useIsTrashPage();
+  const { data: currentUser } = useCurrentUser();
 
-  // TODO: Implement or refactor Skelton if server-side rendering
-  if (rendererOptions == null || isDeleted == null) {
+  if (pageId == null) {
     return <></>;
   }
 
@@ -29,11 +37,22 @@ export const Comments = (props: CommentsProps): JSX.Element => {
       <div className="container-lg">
         <div className="page-comments">
           <div id="page-comments-list" className="page-comments-list">
-            <PageComment pageId={pageId} isReadOnly={false} titleAlign="left" />
+            <PageComment
+              pageId={pageId}
+              revision={revision}
+              currentUser={currentUser}
+              isReadOnly={false}
+              titleAlign="left"
+              hideIfEmpty={false}
+            />
           </div>
           { !isDeleted && (
             <div id="page-comment-write">
-              <CommentEditorLazyRenderer pageId={pageId} rendererOptions={rendererOptions} />
+              <CommentEditor
+                pageId={pageId}
+                isForNewComment
+                onCommentButtonClicked={mutate}
+              />
             </div>
           )}
         </div>

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

@@ -30,7 +30,8 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
   const [inputText, setInputText] = useState(props.value);
   const [currentAlertInfo, setAlertInfo] = useState<AlertInfo | null>(null);
-  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState<boolean>(false);
+  const [isAbleToShowAlert, setIsAbleToShowAlert] = useState(false);
+  const [isComposing, setComposing] = useState(false);
 
   const createValidation = async(inputText: string) => {
     if (props.inputValidator != null) {
@@ -63,6 +64,10 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
   const onKeyDownHandler = (e) => {
     switch (e.key) {
       case 'Enter':
+        // Do nothing when composing
+        if (isComposing) {
+          return;
+        }
         onPressEnter();
         break;
       default:
@@ -107,7 +112,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
 
 
   return (
-    <div className="d-block flex-fill">
+    <div>
       <input
         value={inputText || ''}
         ref={inputRef}
@@ -115,9 +120,12 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
         className="form-control"
         placeholder={props.placeholder}
         name="input"
+        data-testid="closable-text-input"
         onFocus={onFocusHandler}
         onChange={onChangeHandler}
         onKeyDown={onKeyDownHandler}
+        onCompositionStart={() => setComposing(true)}
+        onCompositionEnd={() => setComposing(false)}
         onBlur={onBlurHandler}
         autoFocus={false}
       />

+ 1 - 1
packages/app/src/styles/_toc.scss → packages/app/src/components/ContentLinkButtons.module.scss

@@ -1,4 +1,4 @@
-.grw-icon-container-recently-created {
+.grw-icon-container-recently-created :global {
   svg {
     width: 14px;
     height: 14px;

+ 48 - 45
packages/app/src/components/ContentLinkButtons.tsx

@@ -1,57 +1,62 @@
-import React, { useCallback, useMemo } from 'react';
+import React, { useCallback } from 'react';
 
 import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
+import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
 import { usePageUser } from '~/stores/context';
 
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+import styles from './ContentLinkButtons.module.scss';
 
 const WIKI_HEADER_LINK = 120;
 
+const BookMarkLinkButton = React.memo(() => {
 
-const ContentLinkButtons = (): JSX.Element => {
+  const BookMarkLinkButtonClickHandler = useCallback(() => {
+    const getBookMarkListHeaderDom = document.getElementById('bookmarks-list');
+    if (getBookMarkListHeaderDom == null) { return }
+    smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK);
+  }, []);
+
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary btn-sm px-2"
+      onClick={BookMarkLinkButtonClickHandler}
+    >
+      <i className="fa fa-fw fa-bookmark-o"></i>
+      <span>Bookmarks</span>
+    </button>
+  );
+});
+
+BookMarkLinkButton.displayName = 'BookMarkLinkButton';
+
+const RecentlyCreatedLinkButton = React.memo(() => {
+
+  const RecentlyCreatedListButtonClickHandler = useCallback(() => {
+    const getRecentlyCreatedListHeaderDom = document.getElementById('recently-created-list');
+    if (getRecentlyCreatedListHeaderDom == null) { return }
+    smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK);
+  }, []);
+
+  return (
+    <button
+      type="button"
+      className="btn btn-outline-secondary btn-sm px-3"
+      onClick={RecentlyCreatedListButtonClickHandler}
+    >
+      <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created mr-2`}><RecentlyCreatedIcon /></i>
+      <span>Recently Created</span>
+    </button>
+  );
+});
+
+RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
+
+export const ContentLinkButtons = (): JSX.Element => {
 
   const { data: pageUser } = usePageUser();
 
-  // get element for smoothScroll
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-
-  const BookMarkLinkButton = useCallback((): JSX.Element => {
-    if (getBookMarkListHeaderDom == null) {
-      return <></>;
-    }
-
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
-        onClick={() => smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="fa fa-fw fa-bookmark-o"></i>
-        <span>Bookmarks</span>
-      </button>
-    );
-  }, [getBookMarkListHeaderDom]);
-
-  const RecentlyCreatedLinkButton = useCallback(() => {
-    if (getRecentlyCreatedListHeaderDom == null) {
-      return <></>;
-    }
-
-    return (
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
-        onClick={() => smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-    );
-  }, [getRecentlyCreatedListHeaderDom]);
-
-  if (pageUser == null) {
+  if (pageUser == null || pageUser.status === 4) {
     return <></>;
   }
 
@@ -63,5 +68,3 @@ const ContentLinkButtons = (): JSX.Element => {
   );
 
 };
-
-export default ContentLinkButtons;

+ 2 - 1
packages/app/src/components/CustomNavigation/CustomTabContent.tsx

@@ -1,4 +1,5 @@
 import React, { useEffect, useState } from 'react';
+
 import PropTypes from 'prop-types';
 import {
   TabContent, TabPane,
@@ -18,7 +19,7 @@ const CustomTabContent = (props: Props): JSX.Element => {
 
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
-  const [activatedContent, setActivatedContent] = useState<Set<string>>(new Set<string>());
+  const [activatedContent, setActivatedContent] = useState(new Set([activeTab]));
 
   // add activated content to Set
   useEffect(() => {

+ 3 - 3
packages/app/src/components/EmptyTrashButton.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { FC, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -12,7 +12,7 @@ import { useEmptyTrashModal } from '~/stores/modal';
 import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
 
 
-const EmptyTrashButton = () => {
+const EmptyTrashButton: FC = () => {
   const { t } = useTranslation();
   const { open: openEmptyTrashModal } = useEmptyTrashModal();
   const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
@@ -40,7 +40,6 @@ const EmptyTrashButton = () => {
   }, [t, mutate]);
 
   const emptyTrashClickHandler = () => {
-    if (deletablePages.length === 0) { return }
     openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
   };
 
@@ -49,6 +48,7 @@ const EmptyTrashButton = () => {
       <button
         type="button"
         className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
+        disabled={deletablePages.length === 0}
         onClick={() => emptyTrashClickHandler()}
       >
         <i className="icon-fw icon-trash"></i>

+ 14 - 9
packages/app/src/components/Fab.jsx → packages/app/src/components/Fab.tsx

@@ -10,18 +10,18 @@ import { useCurrentPagePath, useCurrentUser } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
-import CreatePageIcon from './Icons/CreatePageIcon';
-import ReturnTopIcon from './Icons/ReturnTopIcon';
+import { CreatePageIcon } from './Icons/CreatePageIcon';
+import { ReturnTopIcon } from './Icons/ReturnTopIcon';
 
 import styles from './Fab.module.scss';
 
 const logger = loggerFactory('growi:cli:Fab');
 
-const Fab = () => {
-  const { data: currentUser } = useCurrentUser();
+export const Fab = (): JSX.Element => {
 
-  const { open: openCreateModal } = usePageCreateModal();
+  const { data: currentUser } = useCurrentUser();
   const { data: currentPath = '' } = useCurrentPagePath();
+  const { open: openCreateModal } = usePageCreateModal();
 
   const [animateClasses, setAnimateClasses] = useState('invisible');
   const [buttonClasses, setButtonClasses] = useState('');
@@ -30,6 +30,9 @@ const Fab = () => {
   const createBtnRef = useRef(null);
   useRipple(createBtnRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
+  /*
+  * Comment out to prevent err >>> TypeError: Cannot read properties of null (reading 'bottom')
+  */
   const stickyChangeHandler = useCallback((event) => {
     logger.debug('StickyEvents.CHANGE detected');
 
@@ -55,7 +58,11 @@ const Fab = () => {
     };
   }, [stickyChangeHandler]);
 
-  function renderPageCreateButton() {
+  if (currentPath == null) {
+    return <></>;
+  }
+
+  const renderPageCreateButton = () => {
     return (
       <>
         <div className={`rounded-circle position-absolute ${animateClasses}`} style={{ bottom: '2.3rem', right: '4rem' }}>
@@ -70,7 +77,7 @@ const Fab = () => {
         </div>
       </>
     );
-  }
+  };
 
   return (
     <div className={`${styles['grw-fab']} grw-fab d-none d-md-block d-edit-none`} data-testid="grw-fab">
@@ -88,5 +95,3 @@ const Fab = () => {
   );
 
 };
-
-export default Fab;

+ 1 - 5
packages/app/src/components/Icons/CreatePageIcon.jsx → packages/app/src/components/Icons/CreatePageIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const CreatePageIcon = () => (
+export const CreatePageIcon = (): JSX.Element => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 27 30"
@@ -19,8 +19,4 @@ const CreatePageIcon = () => (
     />
     <rect fillOpacity="0" width="27" height="27" />
   </svg>
-
 );
-
-
-export default CreatePageIcon;

+ 1 - 3
packages/app/src/components/Icons/RecentlyCreatedIcon.jsx → packages/app/src/components/Icons/RecentlyCreatedIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const RecentlyCreatedIcon = () => (
+export const RecentlyCreatedIcon = (): JSX.Element => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     width="20"
@@ -40,5 +40,3 @@ const RecentlyCreatedIcon = () => (
     </g>
   </svg>
 );
-
-export default RecentlyCreatedIcon;

+ 1 - 6
packages/app/src/components/Icons/ReturnTopIcon.jsx → packages/app/src/components/Icons/ReturnTopIcon.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-const ReturnTopIcon = () => (
+export const ReturnTopIcon = (): JSX.Element => (
   <svg
     xmlns="http://www.w3.org/2000/svg"
     viewBox="0 0 23 23"
@@ -11,10 +11,5 @@ const ReturnTopIcon = () => (
     />
     <path d="M22.35,4.61H.65a.65.65,0,0,1,0-1.3h21.7a.65.65,0,1,1,0,1.3Z" />
     <rect fillOpacity="0" width="23" height="23" />
-
   </svg>
-
 );
-
-
-export default ReturnTopIcon;

+ 2 - 2
packages/app/src/components/IdenticalPathPage.tsx

@@ -3,7 +3,7 @@ import React, { FC } from 'react';
 import { DevidedPagePath } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-import { useCurrentPagePath, useIsSharedUser } from '~/stores/context';
+import { useCurrentPathname, useIsSharedUser } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 
@@ -52,7 +52,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
 export const IdenticalPathPage = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: currentPath } = useCurrentPagePath();
+  const { data: currentPath } = useCurrentPathname();
   const { data: isSharedUser } = useIsSharedUser();
 
   const { data: pages } = useSWRxPagesByPath(currentPath);

+ 0 - 217
packages/app/src/components/InstallerForm.jsx

@@ -1,217 +0,0 @@
-import React from 'react';
-
-import i18next from 'i18next';
-import { useTranslation, i18n } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import { i18n as i18nConfig } from '^/config/next-i18next.config';
-
-import { useCsrfToken } from '~/stores/context';
-
-class InstallerForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isValidUserName: true,
-      isSubmittingDisabled: false,
-    };
-    this.checkUserName = this.checkUserName.bind(this);
-
-    this.submitHandler = this.submitHandler.bind(this);
-  }
-
-  checkUserName(event) {
-    const axios = require('axios').create({
-      headers: {
-        'Content-Type': 'application/json',
-        'X-Requested-With': 'XMLHttpRequest',
-      },
-      responseType: 'json',
-    });
-    axios.get('/_api/v3/check-username', { params: { username: event.target.value } })
-      .then((res) => { return this.setState({ isValidUserName: res.data.valid }) });
-  }
-
-  submitHandler() {
-    if (this.state.isSubmittingDisabled) {
-      return;
-    }
-
-    this.setState({ isSubmittingDisabled: true });
-    setTimeout(() => {
-      this.setState({ isSubmittingDisabled: false });
-    }, 3000);
-  }
-
-  render() {
-    const { t } = this.props;
-    const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
-    const unavailableUserId = this.state.isValidUserName
-      ? ''
-      : <span><i className="icon-fw icon-ban" />{ this.props.t('installer.unavaliable_user_id') }</span>;
-
-    return (
-      <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
-        <div className="row">
-          <div className="col-md-12">
-            <p className="alert alert-success">
-              <strong>{ this.props.t('installer.create_initial_account') }</strong><br />
-              <small>{ this.props.t('installer.initial_account_will_be_administrator_automatically') }</small>
-            </p>
-          </div>
-        </div>
-        <div className="row">
-          <form role="form" action="/installer" method="post" id="register-form" className="col-md-12" onSubmit={this.submitHandler}>
-            <div className="dropdown mb-3">
-              <div className="d-flex dropdown-with-icon">
-                <i className="icon-bubbles border-0 rounded-0" />
-                <button
-                  type="button"
-                  className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
-                  id="dropdownLanguage"
-                  data-testid="dropdownLanguage"
-                  data-toggle="dropdown"
-                  aria-haspopup="true"
-                  aria-expanded="true"
-                >
-                  <span className="float-left">
-                    {t('meta.display_name')}
-                  </span>
-                </button>
-                <input
-                  type="hidden"
-                  name="registerForm[app:globalLang]"
-                />
-                <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
-                  {
-                    i18nConfig.locales.map((locale) => {
-                      const fixedT = i18n.getFixedT(locale);
-                      i18n.loadLanguages(i18nConfig.locales);
-
-                      return (
-                        <button
-                          key={locale}
-                          data-testid={`dropdownLanguageMenu-${locale}`}
-                          className="dropdown-item"
-                          type="button"
-                          onClick={() => { i18next.changeLanguage(locale) }}
-                        >
-                          {fixedT('meta.display_name')}
-                        </button>
-                      );
-                    })
-                  }
-                </div>
-              </div>
-            </div>
-
-            <div className={`input-group mb-3${hasErrorClass}`}>
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-user" /></span>
-              </div>
-              <input
-                data-testid="tiUsername"
-                type="text"
-                className="form-control"
-                placeholder={this.props.t('User ID')}
-                name="registerForm[username]"
-                defaultValue={this.props.userName}
-                // onBlur={this.checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
-                required
-              />
-            </div>
-            <p className="form-text">{ unavailableUserId }</p>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-tag" /></span>
-              </div>
-              <input
-                data-testid="tiName"
-                type="text"
-                className="form-control"
-                placeholder={this.props.t('Name')}
-                name="registerForm[name]"
-                defaultValue={this.props.name}
-                required
-              />
-            </div>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-envelope" /></span>
-              </div>
-              <input
-                data-testid="tiEmail"
-                type="email"
-                className="form-control"
-                placeholder={this.props.t('Email')}
-                name="registerForm[email]"
-                defaultValue={this.props.email}
-                required
-              />
-            </div>
-
-            <div className="input-group mb-3">
-              <div className="input-group-prepend">
-                <span className="input-group-text"><i className="icon-lock" /></span>
-              </div>
-              <input
-                data-testid="tiPassword"
-                type="password"
-                className="form-control"
-                placeholder={this.props.t('Password')}
-                name="registerForm[password]"
-                required
-              />
-            </div>
-
-            <input type="hidden" name="_csrf" value={this.props.csrfToken} />
-
-            <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-              <button
-                data-testid="btnSubmit"
-                type="submit"
-                className="btn-fill btn btn-register"
-                id="register"
-                disabled={this.state.isSubmittingDisabled}
-              >
-                <div className="eff"></div>
-                <span className="btn-label"><i className="icon-user-follow" /></span>
-                <span className="btn-label-text">{ this.props.t('Create') }</span>
-              </button>
-            </div>
-
-            <div className="input-group mt-4 d-flex justify-content-center">
-              <a href="https://growi.org" className="link-growi-org">
-                <span className="growi">GROWI</span>.<span className="org">ORG</span>
-              </a>
-            </div>
-          </form>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-InstallerForm.propTypes = {
-  // i18next
-  t: PropTypes.func.isRequired,
-  // for input value
-  userName: PropTypes.string,
-  name: PropTypes.string,
-  email: PropTypes.string,
-  csrfToken: PropTypes.string,
-};
-
-const InstallerFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
-
-  return <InstallerForm t={t} csrfToken={csrfToken} {...props} />;
-};
-
-export default InstallerFormWrapperFC;

+ 231 - 0
packages/app/src/components/InstallerForm.tsx

@@ -0,0 +1,231 @@
+import {
+  FormEventHandler, memo, useCallback, useState,
+} from 'react';
+
+import i18next from 'i18next';
+import { useTranslation, i18n } from 'next-i18next';
+
+import { i18n as i18nConfig } from '^/config/next-i18next.config';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+const InstallerForm = memo((): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isValidUserName, setValidUserName] = useState(true);
+  const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
+
+  const checkUserName = useCallback(async(event) => {
+    const axios = require('axios').create({
+      headers: {
+        'Content-Type': 'application/json',
+        'X-Requested-With': 'XMLHttpRequest',
+      },
+      responseType: 'json',
+    });
+    const res = await axios.get('/_api/v3/check-username', { params: { username: event.target.value } });
+    setValidUserName(res.data.valid);
+  }, []);
+
+  const submitHandler: FormEventHandler = useCallback(async(e: any) => {
+    e.preventDefault();
+
+    if (isSubmittingDisabled) {
+      return;
+    }
+
+    setSubmittingDisabled(true);
+    setTimeout(() => {
+      setSubmittingDisabled(false);
+    }, 3000);
+
+    if (e.target.elements == null) {
+      return;
+    }
+
+    const formData = e.target.elements;
+
+    const {
+      'registerForm[username]': { value: username },
+      'registerForm[name]': { value: name },
+      'registerForm[email]': { value: email },
+      'registerForm[password]': { value: password },
+    } = formData;
+
+    const data = {
+      registerForm: {
+        username,
+        name,
+        email,
+        password,
+        'app:globalLang': formData['registerForm[app:globalLang]'].value,
+      },
+    };
+
+    try {
+      await apiv3Post('/installer', data);
+      window.location.href = '/';
+    }
+    catch (errs) {
+      const err = errs[0];
+      const code = err.code;
+
+      if (code === 'failed_to_login_after_install') {
+        toastError(t('installer.failed_to_login_after_install'));
+        setTimeout(() => { window.location.href = '/login' }, 700); // Wait 700 ms to show toastr
+      }
+
+      toastError(t('installer.failed_to_install'));
+    }
+  }, [isSubmittingDisabled, t]);
+
+  const hasErrorClass = isValidUserName ? '' : ' has-error';
+  const unavailableUserId = isValidUserName
+    ? ''
+    : <span><i className="icon-fw icon-ban" />{ t('installer.unavaliable_user_id') }</span>;
+
+  return (
+    <div data-testid="installerForm" className={`noLogin-dialog p-3 mx-auto${hasErrorClass}`}>
+      <div className="row">
+        <div className="col-md-12">
+          <p className="alert alert-success">
+            <strong>{ t('installer.create_initial_account') }</strong><br />
+            <small>{ t('installer.initial_account_will_be_administrator_automatically') }</small>
+          </p>
+        </div>
+      </div>
+      <div className="row">
+        <form role="form" id="register-form" className="col-md-12" onSubmit={submitHandler}>
+          <div className="dropdown mb-3">
+            <div className="d-flex dropdown-with-icon">
+              <i className="icon-bubbles border-0 rounded-0" />
+              <button
+                type="button"
+                className="btn btn-secondary dropdown-toggle text-right w-100 border-0 shadow-none"
+                id="dropdownLanguage"
+                data-testid="dropdownLanguage"
+                data-toggle="dropdown"
+                aria-haspopup="true"
+                aria-expanded="true"
+              >
+                <span className="float-left">
+                  {t('meta.display_name')}
+                </span>
+              </button>
+              <input
+                type="hidden"
+                name="registerForm[app:globalLang]"
+              />
+              <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
+                {
+                  i18nConfig.locales.map((locale) => {
+                    let fixedT;
+                    if (i18n != null) {
+                      fixedT = i18n.getFixedT(locale);
+                      i18n.loadLanguages(i18nConfig.locales);
+                    }
+
+                    return (
+                      <button
+                        key={locale}
+                        data-testid={`dropdownLanguageMenu-${locale}`}
+                        className="dropdown-item"
+                        type="button"
+                        onClick={() => { i18next.changeLanguage(locale) }}
+                      >
+                        {fixedT?.('meta.display_name')}
+                      </button>
+                    );
+                  })
+                }
+              </div>
+            </div>
+          </div>
+
+          <div className={`input-group mb-3${hasErrorClass}`}>
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-user" /></span>
+            </div>
+            <input
+              data-testid="tiUsername"
+              type="text"
+              className="form-control"
+              placeholder={t('User ID')}
+              name="registerForm[username]"
+              // onBlur={checkUserName} // need not to check username before installation -- 2020.07.24 Yuki Takei
+              required
+            />
+          </div>
+          <p className="form-text">{ unavailableUserId }</p>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-tag" /></span>
+            </div>
+            <input
+              data-testid="tiName"
+              type="text"
+              className="form-control"
+              placeholder={t('Name')}
+              name="registerForm[name]"
+              required
+            />
+          </div>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-envelope" /></span>
+            </div>
+            <input
+              data-testid="tiEmail"
+              type="email"
+              className="form-control"
+              placeholder={t('Email')}
+              name="registerForm[email]"
+              required
+            />
+          </div>
+
+          <div className="input-group mb-3">
+            <div className="input-group-prepend">
+              <span className="input-group-text"><i className="icon-lock" /></span>
+            </div>
+            <input
+              data-testid="tiPassword"
+              type="password"
+              className="form-control"
+              placeholder={t('Password')}
+              name="registerForm[password]"
+              required
+            />
+          </div>
+
+          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+            <button
+              data-testid="btnSubmit"
+              type="submit"
+              className="btn-fill btn btn-register"
+              id="register"
+              disabled={isSubmittingDisabled}
+            >
+              <div className="eff"></div>
+              <span className="btn-label"><i className="icon-user-follow" /></span>
+              <span className="btn-label-text">{ t('Create') }</span>
+            </button>
+          </div>
+
+          <div className="input-group mt-4 d-flex justify-content-center">
+            <a href="https://growi.org" className="link-growi-org">
+              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+            </a>
+          </div>
+        </form>
+      </div>
+    </div>
+  );
+});
+
+InstallerForm.displayName = 'InstallerForm';
+
+export default InstallerForm;

+ 0 - 0
packages/app/src/components/Layout/Invited.module.scss → packages/app/src/components/Invited.module.scss


+ 111 - 0
packages/app/src/components/InvitedForm.tsx

@@ -0,0 +1,111 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useCsrfToken, useCurrentUser } from '../stores/context';
+
+export type InvitedFormProps = {
+  invitedFormUsername: string,
+  invitedFormName: string,
+}
+
+export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: csrfToken } = useCsrfToken();
+  const { data: user } = useCurrentUser();
+
+  const { invitedFormUsername, invitedFormName } = props;
+
+  if (user == null) {
+    return <></>;
+  }
+
+  return (
+    <div className="noLogin-dialog p-3 mx-auto" id="noLogin-dialog">
+      <p className="alert alert-success">
+        <strong>{ t('invited.discription_heading') }</strong><br></br>
+        <small>{ t('invited.discription') }</small>
+      </p>
+      <form role="form" action="/invited/activateInvited" method="post" id="invited-form">
+        {/* Email Form */}
+        <div className="input-group">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-envelope"></i>
+            </span>
+          </div>
+          <input
+            type="text"
+            className="form-control"
+            disabled
+            placeholder={t('Email')}
+            name="invitedForm[email]"
+            defaultValue={user.email}
+            required
+          />
+        </div>
+        {/* UserID Form */}
+        <div className="input-group" id="input-group-username">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-user"></i>
+            </span>
+          </div>
+          <input
+            type="text"
+            className="form-control"
+            placeholder={t('User ID')}
+            name="invitedForm[username]"
+            value={invitedFormUsername}
+            required
+          />
+        </div>
+        {/* Name Form */}
+        <div className="input-group">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-tag"></i>
+            </span>
+          </div>
+          <input
+            type="text"
+            className="form-control"
+            placeholder={t('Name')}
+            name="invitedForm[name]"
+            value={invitedFormName}
+            required
+          />
+        </div>
+        {/* Password Form */}
+        <div className="input-group">
+          <div className="input-group-prepend">
+            <span className="input-group-text">
+              <i className="icon-lock"></i>
+            </span>
+          </div>
+          <input
+            type="password"
+            className="form-control"
+            placeholder={t('Password')}
+            name="invitedForm[password]"
+            required
+          />
+        </div>
+        {/* Create Button */}
+        <div className="input-group justify-content-center d-flex mt-5">
+          <input type="hidden" name="_csrf" value={csrfToken} />
+          <button type="submit" className="btn btn-fill" id="register">
+            <div className="eff"></div>
+            <span className="btn-label"><i className="icon-user-follow"></i></span>
+            <span className="btn-label-text">{t('Create')}</span>
+          </button>
+        </div>
+      </form>
+      <div className="input-group mt-5 d-flex justify-content-center">
+        <a href="https://growi.org" className="link-growi-org">
+          <span className="growi">GROWI</span>.<span className="org">ORG</span>
+        </a>
+      </div>
+    </div>
+  );
+};

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

@@ -8,7 +8,7 @@ import Sidebar from '../Sidebar';
 import { RawLayout } from './RawLayout';
 // const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 
-// const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
+const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { 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 });
@@ -23,7 +23,7 @@ const PagePresentationModal = dynamic(() => import('../PagePresentationModal'),
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal'), { ssr: false });
 const DrawioModal = dynamic(() => import('../PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 // Fab
-const Fab = dynamic(() => import('../Fab'), { ssr: false });
+const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
 
 
 type Props = {
@@ -61,8 +61,8 @@ export const BasicLayout = ({
       <PageRenameModal />
       <PagePresentationModal />
       <PageAccessoriesModal />
-      {/* <HotkeysManager /> */}
       <DrawioModal />
+      <HotkeysManager />
 
       <Fab />
 

+ 1 - 2
packages/app/src/components/Layout/NoLoginLayout.tsx

@@ -34,10 +34,9 @@ export const NoLoginLayout = ({
                     <h1 className="my-3">GROWI</h1>
                     <div className="noLogin-form-errors px-3"></div>
                   </div>
+                  {children}
                 </div>
 
-                {children}
-
               </div>
             </div>
           </div>

+ 50 - 0
packages/app/src/components/Layout/ShareLinkLayout.tsx

@@ -0,0 +1,50 @@
+import React, { ReactNode } from 'react';
+
+import dynamic from 'next/dynamic';
+
+import { GrowiNavbar } from '../Navbar/GrowiNavbar';
+
+import { RawLayout } from './RawLayout';
+
+const PageCreateModal = dynamic(() => import('../PageCreateModal'), { ssr: false });
+const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
+const ShortcutsModal = dynamic(() => import('../ShortcutsModal'), { ssr: false });
+const SystemVersion = dynamic(() => import('../SystemVersion'), { ssr: false });
+
+// Fab
+const Fab = dynamic(() => import('../Fab').then(mod => mod.Fab), { ssr: false });
+
+
+type Props = {
+  title: string
+  className?: string,
+  expandContainer?: boolean,
+  children?: ReactNode
+}
+
+export const ShareLinkLayout = ({
+  children, title, className, expandContainer,
+}: Props): JSX.Element => {
+
+  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
+
+  return (
+    <RawLayout title={title} className={myClassName}>
+      <GrowiNavbar />
+
+      <div className="page-wrapper d-flex d-print-block">
+        <div className="flex-fill mw-0">
+          {children}
+        </div>
+      </div>
+
+      <GrowiNavbarBottom />
+
+      <Fab />
+
+      <ShortcutsModal />
+      <PageCreateModal />
+      <SystemVersion showShortcutsButton />
+    </RawLayout>
+  );
+};

+ 4 - 4
packages/app/src/components/LikeButtons.module.scss

@@ -1,11 +1,11 @@
 @use '~/styles/bootstrap/init' as bs;
 
-.btn-like {
-  :global {
+.btn-group-like :global {
+  .btn-like {
     box-shadow: none !important;
-  }
-  &:global {
+
     @include bs.button-outline-variant(rgba(bs.$secondary, 50%), lighten(bs.$red, 15%), rgba(lighten(bs.$red, 10%), 0.15), rgba(lighten(bs.$red, 10%), 0.5));
+
     &:not(:disabled):not(.disabled):active,
     &:not(:disabled):not(.disabled).active {
       color: lighten(bs.$red, 15%);

+ 2 - 2
packages/app/src/components/LikeButtons.tsx

@@ -45,12 +45,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   }, [isGuestUser, isLiked]);
 
   return (
-    <div className="btn-group" role="group" aria-label="Like buttons">
+    <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">
       <button
         type="button"
         id="like-button"
         onClick={onLikeClicked}
-        className={`shadow-none btn btn-like ${styles['btn-like']} border-0
+        className={`shadow-none btn btn-like border-0
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
         <i className={`fa ${isLiked ? 'fa-heart' : 'fa-heart-o'}`}></i>

+ 174 - 144
packages/app/src/components/LoginForm.jsx → packages/app/src/components/LoginForm.tsx

@@ -1,44 +1,64 @@
-import React from 'react';
+import React, {
+  useState, useEffect, useCallback,
+} from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
+import { useRouter } from 'next/router';
 import ReactCardFlip from 'react-card-flip';
 
+import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCsrfToken } from '~/stores/context';
 
-class LoginForm extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isRegistering: false,
-    };
-
-    this.switchForm = this.switchForm.bind(this);
-    this.handleLoginWithExternalAuth = this.handleLoginWithExternalAuth.bind(this);
-    this.renderLocalOrLdapLoginForm = this.renderLocalOrLdapLoginForm.bind(this);
-    this.renderExternalAuthLoginForm = this.renderExternalAuthLoginForm.bind(this);
-    this.renderExternalAuthInput = this.renderExternalAuthInput.bind(this);
-    this.renderRegisterForm = this.renderRegisterForm.bind(this);
+type LoginFormProps = {
+  username?: string,
+  name?: string,
+  email?: string,
+  isRegistrationEnabled: boolean,
+  isEmailAuthenticationEnabled: boolean,
+  registrationMode?: string,
+  registrationWhiteList: string[],
+  isPasswordResetEnabled: boolean,
+  isLocalStrategySetup: boolean,
+  isLdapStrategySetup: boolean,
+  objOfIsExternalAuthEnableds?: any,
+  isMailerSetup?: boolean
+}
+export const LoginForm = (props: LoginFormProps): JSX.Element => {
+  const { t } = useTranslation();
+  const router = useRouter();
+  const { data: csrfToken } = useCsrfToken();
 
+  const {
+    isLocalStrategySetup, isLdapStrategySetup, isPasswordResetEnabled, isRegistrationEnabled,
+    isEmailAuthenticationEnabled, registrationMode, registrationWhiteList, isMailerSetup,
+  } = props;
+  const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
+  // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
+  const isSomeExternalAuthEnabled = true;
+
+  // states
+  const [isRegistering, setIsRegistering] = useState(false);
+  const [username, setUsername] = useState('');
+  const [name, setName] = useState('');
+  const [email, setEmail] = useState('');
+  const [password, setPassword] = useState('');
+  const [registerErrors, setRegisterErrors] = useState<Error[]>([]);
+
+  useEffect(() => {
     const { hash } = window.location;
     if (hash === '#register') {
-      this.state.isRegistering = true;
+      setIsRegistering(true);
     }
-  }
-
-  switchForm() {
-    this.setState({ isRegistering: !this.state.isRegistering });
-  }
+  }, []);
 
-  handleLoginWithExternalAuth(e) {
+  // functions
+  const handleLoginWithExternalAuth = useCallback((e) => {
     const auth = e.currentTarget.id;
-    window.location.href = `/passport/${auth}`;
-  }
 
-  renderLocalOrLdapLoginForm() {
-    const { t, csrfToken, isLdapStrategySetup } = this.props;
+    window.location.href = `/passport/${auth}`;
+  }, []);
+  const renderLocalOrLdapLoginForm = useCallback(() => {
+    const { isLdapStrategySetup } = props;
 
     return (
       <form role="form" action="/login" method="post">
@@ -79,10 +99,8 @@ class LoginForm extends React.Component {
         </div>
       </form>
     );
-  }
-
-  renderExternalAuthInput(auth) {
-    const { t } = this.props;
+  }, [csrfToken, props, t]);
+  const renderExternalAuthInput = useCallback((auth) => {
     const authIconNames = {
       google: 'google',
       github: 'github',
@@ -95,7 +113,7 @@ class LoginForm extends React.Component {
 
     return (
       <div key={auth} className="col-6 my-2">
-        <button type="button" className="btn btn-fill rounded-0" id={auth} onClick={this.handleLoginWithExternalAuth}>
+        <button type="button" className="btn btn-fill rounded-0" id={auth} onClick={handleLoginWithExternalAuth}>
           <div className="eff"></div>
           <span className="btn-label">
             <i className={`fa fa-${authIconNames[auth]}`}></i>
@@ -105,10 +123,9 @@ class LoginForm extends React.Component {
         <div className="small text-right">by {auth} Account</div>
       </div>
     );
-  }
-
-  renderExternalAuthLoginForm() {
-    const { isLocalStrategySetup, isLdapStrategySetup, objOfIsExternalAuthEnableds } = this.props;
+  }, [handleLoginWithExternalAuth, t]);
+  const renderExternalAuthLoginForm = useCallback(() => {
+    const { isLocalStrategySetup, isLdapStrategySetup, objOfIsExternalAuthEnableds } = props;
     const isExternalAuthCollapsible = isLocalStrategySetup || isLdapStrategySetup;
     const collapsibleClass = isExternalAuthCollapsible ? 'collapse collapse-external-auth' : '';
 
@@ -121,7 +138,7 @@ class LoginForm extends React.Component {
                 if (!objOfIsExternalAuthEnableds[auth]) {
                   return;
                 }
-                return this.renderExternalAuthInput(auth);
+                return renderExternalAuthInput(auth);
               })}
             </div>
           </div>
@@ -140,22 +157,42 @@ class LoginForm extends React.Component {
         </div>
       </>
     );
-  }
+  }, [props, renderExternalAuthInput]);
 
-  renderRegisterForm() {
-    const {
-      t,
-      // appContainer,
-      csrfToken,
-      isEmailAuthenticationEnabled,
+  const handleRegisterFormSubmit = useCallback(async(e, requestPath) => {
+    e.preventDefault();
+
+    const registerForm = {
       username,
       name,
       email,
-      registrationMode,
-      registrationWhiteList,
-      isMailerSetup,
-    } = this.props;
+      password,
+    };
+    try {
+      const res = await apiv3Post(requestPath, { registerForm });
+      const { redirectTo } = res.data;
+      router.push(redirectTo);
+    }
+    catch (err) {
+      // Execute if error exists
+      if (err != null || err.length > 0) {
+        setRegisterErrors(err);
+      }
+    }
+    return;
+  }, [email, name, password, router, username]);
+
+  const resetRegisterErrors = useCallback(() => {
+    if (registerErrors.length === 0) return;
+    setRegisterErrors([]);
+  }, [registerErrors.length]);
+
+  const switchForm = useCallback(() => {
+    setIsRegistering(!isRegistering);
+    resetRegisterErrors();
+  }, [isRegistering, resetRegisterErrors]);
 
+  const renderRegisterForm = useCallback(() => {
     let registerAction = '/register';
 
     let submitText = t('Sign up');
@@ -179,7 +216,21 @@ class LoginForm extends React.Component {
           </p>
         )}
 
-        <form role="form" action={registerAction} method="post" id="register-form">
+        {
+          registerErrors != null && registerErrors.length > 0 && (
+            <p className="alert alert-danger">
+              {registerErrors.map((err, index) => {
+                return (
+                  <span key={index}>
+                    {t(`message.${err.message}`)}<br/>
+                  </span>
+                );
+              })}
+            </p>
+          )
+        }
+
+        <form role="form" onSubmit={e => handleRegisterFormSubmit(e, registerAction) } id="register-form">
 
           {!isEmailAuthenticationEnabled && (
             <div>
@@ -189,12 +240,14 @@ class LoginForm extends React.Component {
                     <i className="icon-user"></i>
                   </span>
                 </div>
+                {/* username */}
                 <input
                   type="text"
                   className="form-control rounded-0"
+                  onChange={(e) => { setUsername(e.target.value) }}
                   placeholder={t('User ID')}
-                  name="registerForm[username]"
-                  defaultValue={username}
+                  name="username"
+                  defaultValue={props.username}
                   required
                 />
               </div>
@@ -207,7 +260,14 @@ class LoginForm extends React.Component {
                     <i className="icon-tag"></i>
                   </span>
                 </div>
-                <input type="text" className="form-control rounded-0" placeholder={t('Name')} name="registerForm[name]" defaultValue={name} required />
+                {/* name */}
+                <input type="text"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setName(e.target.value) }}
+                  placeholder={t('Name')}
+                  name="name"
+                  defaultValue={props.name}
+                  required />
               </div>
             </div>
           )}
@@ -218,7 +278,15 @@ class LoginForm extends React.Component {
                 <i className="icon-envelope"></i>
               </span>
             </div>
-            <input type="email" className="form-control rounded-0" placeholder={t('Email')} name="registerForm[email]" defaultValue={email} required />
+            {/* email */}
+            <input type="email"
+              className="form-control rounded-0"
+              onChange={(e) => { setEmail(e.target.value) }}
+              placeholder={t('Email')}
+              name="email"
+              defaultValue={props.email}
+              required
+            />
           </div>
 
           {registrationWhiteList.length > 0 && (
@@ -244,14 +312,24 @@ class LoginForm extends React.Component {
                     <i className="icon-lock"></i>
                   </span>
                 </div>
-                <input type="password" className="form-control rounded-0" placeholder={t('Password')} name="registerForm[password]" required />
+                {/* Password */}
+                <input type="password"
+                  className="form-control rounded-0"
+                  onChange={(e) => { setPassword(e.target.value) }}
+                  placeholder={t('Password')}
+                  name="password"
+                  required />
               </div>
             </div>
           )}
 
+          {/* Sign up button (submit) */}
           <div className="input-group justify-content-center my-4">
-            <input type="hidden" name="_csrf" value={csrfToken} />
-            <button type="submit" className="btn btn-fill rounded-0" id="register" disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}>
+            <button
+              className="btn btn-fill rounded-0"
+              id="register"
+              disabled={(!isMailerSetup && isEmailAuthenticationEnabled)}
+            >
               <div className="eff"></div>
               <span className="btn-label">
                 <i className="icon-user-follow"></i>
@@ -265,7 +343,7 @@ class LoginForm extends React.Component {
 
         <div className="row">
           <div className="text-right col-12 mt-2 py-2">
-            <a href="#login" id="login" className="link-switch" onClick={this.switchForm}>
+            <a href="#login" id="login" className="link-switch" onClick={switchForm}>
               <i className="icon-fw icon-login"></i>
               {t('Sign in is here')}
             </a>
@@ -273,93 +351,45 @@ class LoginForm extends React.Component {
         </div>
       </React.Fragment>
     );
-  }
-
-  render() {
-    const {
-      t,
-      isLocalStrategySetup,
-      isLdapStrategySetup,
-      isRegistrationEnabled,
-      isPasswordResetEnabled,
-      objOfIsExternalAuthEnableds,
-    } = this.props;
-
-
-    const isLocalOrLdapStrategiesEnabled = isLocalStrategySetup || isLdapStrategySetup;
-    // const isSomeExternalAuthEnabled = Object.values(objOfIsExternalAuthEnableds).some(elem => elem);
-    const isSomeExternalAuthEnabled = true;
-
-    return (
-      <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
-        <div className="row mx-0">
-          <div className="col-12">
-            <ReactCardFlip isFlipped={this.state.isRegistering} flipDirection="horizontal" cardZIndex="3">
-              <div className="front">
-                {isLocalOrLdapStrategiesEnabled && this.renderLocalOrLdapLoginForm()}
-                {isSomeExternalAuthEnabled && this.renderExternalAuthLoginForm()}
-                {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
-                  <div className="text-right mb-2">
-                    <a href="/forgot-password" className="d-block link-switch">
-                      <i className="icon-key"></i> {t('forgot_password.forgot_password')}
-                    </a>
-                  </div>
-                )}
-                {isRegistrationEnabled && (
-                  <div className="text-right mb-2">
-                    <a href="#register" id="register" className="link-switch" onClick={this.switchForm}>
-                      <i className="ti ti-check-box"></i> {t('Sign up is here')}
-                    </a>
-                  </div>
-                )}
-              </div>
-              <div className="back">
-                {isRegistrationEnabled && this.renderRegisterForm()}
-              </div>
-            </ReactCardFlip>
-          </div>
+  }, [handleRegisterFormSubmit, isEmailAuthenticationEnabled, isMailerSetup,
+      props.email, props.name, props.username,
+      registerErrors, registrationMode, registrationWhiteList, switchForm, t]);
+
+  return (
+    <div className="noLogin-dialog mx-auto" id="noLogin-dialog">
+      <div className="row mx-0">
+        <div className="col-12">
+          <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
+            <div className="front">
+              {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
+              {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
+              {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
+                <div className="text-right mb-2">
+                  <a href="/forgot-password" className="d-block link-switch">
+                    <i className="icon-key"></i> {t('forgot_password.forgot_password')}
+                  </a>
+                </div>
+              )}
+              {/* Sign up link */}
+              {isRegistrationEnabled && (
+                <div className="text-right mb-2">
+                  <a href="#register" id="register" className="link-switch" onClick={switchForm}>
+                    <i className="ti ti-check-box"></i> {t('Sign up is here')}
+                  </a>
+                </div>
+              )}
+            </div>
+            <div className="back">
+              {/* Register form for /login#register */}
+              {isRegistrationEnabled && renderRegisterForm()}
+            </div>
+          </ReactCardFlip>
         </div>
-        <a href="https://growi.org" className="link-growi-org pl-3">
-          <span className="growi">GROWI</span>.<span className="org">ORG</span>
-        </a>
       </div>
-    );
-  }
-
-}
+      <a href="https://growi.org" className="link-growi-org pl-3">
+        <span className="growi">GROWI</span>.<span className="org">ORG</span>
+      </a>
+    </div>
+  );
 
-LoginForm.propTypes = {
-  // i18next
-  t: PropTypes.func.isRequired,
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-
-  csrfToken: PropTypes.string,
-  isRegistering: PropTypes.bool,
-  username: PropTypes.string,
-  name: PropTypes.string,
-  email: PropTypes.string,
-  isRegistrationEnabled: PropTypes.bool,
-  registrationMode: PropTypes.string,
-  registrationWhiteList: PropTypes.array,
-  isPasswordResetEnabled: PropTypes.bool,
-  isEmailAuthenticationEnabled: PropTypes.bool,
-  isLocalStrategySetup: PropTypes.bool,
-  isLdapStrategySetup: PropTypes.bool,
-  objOfIsExternalAuthEnableds: PropTypes.object,
-  isMailerSetup: PropTypes.bool,
 };
-
-const LoginFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  const { data: csrfToken } = useCsrfToken();
-
-  return <LoginForm t={t} csrfToken={csrfToken} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-// const LoginFormWrapper = withUnstatedContainers(LoginFormWrapperFC, [AppContainer]);
-
-// export default LoginForm;
-export default LoginFormWrapperFC;

+ 0 - 1
packages/app/src/components/Me/BasicInfoSettings.tsx

@@ -109,7 +109,6 @@ export const BasicInfoSettings = (): JSX.Element => {
             i18nConfig.locales.map((locale) => {
               if (i18n == null) { return }
               const fixedT = i18n.getFixedT(locale);
-              i18n.loadLanguages(i18nConfig.locales);
 
               return (
                 <div key={locale} className="custom-control custom-radio custom-control-inline">

+ 1 - 0
packages/app/src/components/Navbar/DrawerToggler.tsx

@@ -1,4 +1,5 @@
 import React, { FC } from 'react';
+
 import { useDrawerOpened } from '~/stores/ui';
 
 type Props = {

+ 31 - 42
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,6 +1,5 @@
 import React, { useState, useEffect, useCallback } from 'react';
 
-
 import { isPopulated } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -16,7 +15,7 @@ import { IResTagsUpdateApiv1 } from '~/interfaces/tag';
 import { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import {
   useCurrentPageId,
-  useCurrentPathname,
+  useCurrentPathname, useIsNotFound,
   useCurrentUser, useIsGuestUser, useIsSharedUser, useShareLinkId, useTemplateTagData,
 } from '~/stores/context';
 import { usePageTagsForEditors } from '~/stores/editor';
@@ -40,9 +39,20 @@ import { Skelton } from '../Skelton';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { SubNavButtonsProps } from './SubNavButtons';
 
+
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
+const PageEditorModeManager = dynamic(
+  () => import('./PageEditorModeManager'),
+  { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
+);
+const SubNavButtons = dynamic<SubNavButtonsProps>(
+  () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
+  { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+);
+
+
 type AdditionalMenuItemsProps = {
   pageId: string,
   revisionId: string,
@@ -156,15 +166,6 @@ type GrowiContextualSubNavigationProps = {
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
-  const PageEditorModeManager = dynamic(
-    () => import('./PageEditorModeManager'),
-    { ssr: false, loading: () => <Skelton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skelton']}`} /> },
-  );
-  const SubNavButtons = dynamic<SubNavButtonsProps>(
-    () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
-    { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
-  );
-
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const path = currentPage?.path;
 
@@ -178,6 +179,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isSharedUser } = useIsSharedUser();
+  const { data: isNotFound } = useIsNotFound();
   const { data: shareLinkId } = useShareLinkId();
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
@@ -294,14 +296,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
 
   const ControlComponents = useCallback(() => {
-    if (currentPage == null || pageId == null) {
-      return <></>;
-    }
-
-    function onPageEditorModeButtonClicked(viewType) {
-      mutateEditorMode(viewType);
-    }
-
     const additionalMenuItemsRenderer = () => {
       if (revisionId == null || pageId == null) {
         return <></>;
@@ -321,24 +315,26 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
           { isViewMode && (
             <div className="h-50 w-100">
-              <SubNavButtons
-                isCompactMode={isCompactMode}
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path}
-                disableSeenUserInfoPopover={isSharedUser}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-              />
+              { pageId != null && (
+                <SubNavButtons
+                  isCompactMode={isCompactMode}
+                  pageId={pageId}
+                  revisionId={revisionId}
+                  shareLinkId={shareLinkId}
+                  path={path}
+                  disableSeenUserInfoPopover={isSharedUser}
+                  showPageControlDropdown={isAbleToShowPageManagement}
+                  additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                  onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                  onClickRenameMenuItem={renameItemClickedHandler}
+                  onClickDeleteMenuItem={deleteItemClickedHandler}
+                />
+              ) }
             </div>
           ) }
           {isAbleToShowPageEditorModeManager && (
             <PageEditorModeManager
-              onPageEditorModeButtonClicked={onPageEditorModeButtonClicked}
+              onPageEditorModeButtonClicked={viewType => mutateEditorMode(viewType)}
               isBtnDisabled={isGuestUser}
               editorMode={editorMode}
             />
@@ -353,15 +349,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         )}
       </>
     );
-  }, [
-    currentPage, currentUser, pageId, revisionId, shareLinkId, path, editorMode,
-    isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager,
-    isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown,
-    duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler,
-    PageEditorModeManager, SubNavButtons,
-    mutateEditorMode,
-    templateMenuItemClickHandler,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [currentUser, pageId, revisionId, shareLinkId, path, editorMode, isCompactMode, isViewMode, isSharedUser, isAbleToShowPageManagement, isAbleToShowPageEditorModeManager, isLinkSharingDisabled, isGuestUser, isPageTemplateModalShown, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, mutateEditorMode, templateMenuItemClickHandler]);
 
   if (currentPathname == null) {
     return <></>;

+ 10 - 13
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -19,18 +19,17 @@ import { HasChildren } from '../../interfaces/common';
 import GrowiLogo from '../Icons/GrowiLogo';
 
 import { GlobalSearchProps } from './GlobalSearch';
-import PersonalDropdown from './PersonalDropdown';
 
 import styles from './GrowiNavbar.module.scss';
 
+const PersonalDropdown = dynamic(() => import('./PersonalDropdown'), { ssr: false });
+const InAppNotificationDropdown = dynamic(() => import('../InAppNotification/InAppNotificationDropdown')
+  .then(mod => mod.InAppNotificationDropdown), { ssr: false });
+const AppearanceModeDropdown = dynamic(() => import('./AppearanceModeDropdown').then(mod => mod.AppearanceModeDropdown), { ssr: false });
 
 const NavbarRight = memo((): JSX.Element => {
   const { t } = useTranslation();
 
-  const InAppNotificationDropdown = dynamic(() => import('../InAppNotification/InAppNotificationDropdown')
-    .then(mod => mod.InAppNotificationDropdown), { ssr: false });
-  const AppearanceModeDropdown = dynamic(() => import('./AppearanceModeDropdown').then(mod => mod.AppearanceModeDropdown), { ssr: false });
-
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isGuestUser } = useIsGuestUser();
 
@@ -71,7 +70,7 @@ const NavbarRight = memo((): JSX.Element => {
         </li>
       </>
     );
-  }, [InAppNotificationDropdown, t, AppearanceModeDropdown, isAuthenticated, openCreateModal, currentPagePath]);
+  }, [t, isAuthenticated, openCreateModal, currentPagePath]);
 
   const notAuthenticatedNavItem = useMemo(() => {
     return (
@@ -79,11 +78,10 @@ const NavbarRight = memo((): JSX.Element => {
         <li className="grw-apperance-mode-dropdown nav-item dropdown">
           <AppearanceModeDropdown isAuthenticated={isAuthenticated} />
         </li>
-
         <li id="login-user" className="nav-item"><a className="nav-link" href="/login">Login</a></li>;
       </>
     );
-  }, [AppearanceModeDropdown, isAuthenticated]);
+  }, [isAuthenticated]);
 
   return (
     <>
@@ -146,18 +144,17 @@ export const GrowiNavbar = (): JSX.Element => {
         {appTitle}
       </div>
 
-
       {/* Navbar Right  */}
       <ul className="navbar-nav ml-auto">
         <NavbarRight />
         <Confidential confidential={confidential} />
       </ul>
 
-      { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
-        <div className="grw-global-search-container position-absolute">
+      <div className="grw-global-search-container position-absolute">
+        { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
           <GlobalSearch />
-        </div>
-      ) }
+        ) }
+      </div>
     </nav>
   );
 

+ 21 - 33
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -8,46 +8,41 @@ import {
   EditorMode, useEditorMode,
 } from '~/stores/ui';
 
+import { TagLabelsSkelton } from '../Page/TagLabels';
 import PagePathNav from '../PagePathNav';
 import { Skelton } from '../Skelton';
 
 import DrawerToggler from './DrawerToggler';
 
-
-import TagLabelsStyles from '../Page/TagLabels.module.scss';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import styles from './GrowiSubNavigation.module.scss';
 
+const TagLabels = dynamic(() => import('../Page/TagLabels').then(mod => mod.TagLabels), {
+  ssr: false,
+  loading: () => <TagLabelsSkelton />,
+});
+const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
+  ssr: false,
+  loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
+});
+
 
 export type GrowiSubNavigationProps = {
   page: Partial<IPageHasId>,
-
   showDrawerToggler?: boolean,
   showTagLabel?: boolean,
   showPageAuthors?: boolean,
-
   isGuestUser?: boolean,
   isDrawerMode?: boolean,
   isCompactMode?: boolean,
-
   tags?: string[],
   tagsUpdatedHandler?: (newTags: string[]) => Promise<void> | void,
-
-  controls?: React.FunctionComponent,
+  controls: React.FunctionComponent,
   additionalClasses?: string[],
 }
 
 export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element => {
 
-  const TagLabels = dynamic(() => import('../Page/TagLabels'), {
-    ssr: false,
-    loading: () => <Skelton additionalClass={`${TagLabelsStyles['grw-tag-labels-skelton']} py-1`} />,
-  });
-  const AuthorInfo = dynamic(() => import('./AuthorInfo'), {
-    ssr: false,
-    loading: () => <Skelton additionalClass={`${AuthorInfoStyles['grw-author-info-skelton']} py-1`} />,
-  });
-
   const { data: editorMode } = useEditorMode();
 
   const {
@@ -59,35 +54,31 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
     additionalClasses = [],
   } = props;
 
+  const isViewMode = editorMode === EditorMode.View;
+  const isEditorMode = !isViewMode;
+  const compactModeClasses = isCompactMode ? 'grw-subnav-compact d-print-none' : '';
+
   const {
     _id: pageId, path, creator, lastUpdateUser,
     createdAt, updatedAt,
   } = page;
 
-  const isViewMode = editorMode === EditorMode.View;
-  const isEditorMode = !isViewMode;
-
   if (path == null) {
     return <></>;
   }
 
   return (
-    <div className={
-      `grw-subnav ${styles['grw-subnav']} d-flex align-items-center justify-content-between`
-      + ` ${additionalClasses.join(' ')}`
-      + ` ${isCompactMode ? 'grw-subnav-compact d-print-none' : ''}`}
-    >
-
+    <div className={`grw-subnav ${styles['grw-subnav']} d-flex align-items-center justify-content-between ${additionalClasses.join(' ')}
+    ${compactModeClasses}`} >
       {/* Left side */}
       <div className="d-flex grw-subnav-left-side">
-        { showDrawerToggler && isDrawerMode && (
+        { (showDrawerToggler && isDrawerMode) && (
           <div className={`d-none d-md-flex align-items-center ${isEditorMode ? 'mr-2 pr-2' : 'border-right mr-4 pr-4'}`}>
             <DrawerToggler />
           </div>
         ) }
-
         <div className="grw-path-nav-container">
-          { showTagLabel && !isCompactMode && (
+          { (showTagLabel && !isCompactMode) && (
             <div className="grw-taglabels-container">
               <TagLabels tags={tags} isGuestUser={isGuestUser ?? false} tagsUpdateInvoked={tagsUpdatedHandler} />
             </div>
@@ -95,12 +86,9 @@ export const GrowiSubNavigation = (props: GrowiSubNavigationProps): JSX.Element
           <PagePathNav pageId={pageId} pagePath={path} isSingleLineMode={isEditorMode} isCompactMode={isCompactMode} />
         </div>
       </div>
-
-      {/* Right side */}
+      {/* Right side. */}
       <div className="d-flex">
-
-        { Controls && <Controls></Controls> }
-
+        <Controls />
         {/* Page Authors */}
         { (showPageAuthors && !isCompactMode) && (
           <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>

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

@@ -1,13 +1,12 @@
 import React, { useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
 
+import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import PageListIcon from './Icons/PageListIcon';
 import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
-import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 
 const NotFoundPage = (): JSX.Element => {
   const { t } = useTranslation();
@@ -29,7 +28,6 @@ const NotFoundPage = (): JSX.Element => {
     };
   }, [t]);
 
-
   return (
     <div className="d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />

+ 7 - 17
packages/app/src/components/Page.tsx

@@ -10,10 +10,9 @@ import dynamic from 'next/dynamic';
 
 import { HtmlElementNode } from 'rehype-toc';
 
-import { blinkSectionHeaderAtBoot } from '~/client/util/blink-section-header';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
-  useIsGuestUser, useIsBlinkedHeaderAtBoot, useCurrentPageTocNode,
+  useIsGuestUser, useCurrentPageTocNode, useShareLinkId,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
@@ -32,13 +31,13 @@ import mdu from './PageEditor/MarkdownDrawioUtil';
 import mtu from './PageEditor/MarkdownTableUtil';
 
 
-// TODO: import dynamically
+declare const globalEmitter: EventEmitter;
+
+// const DrawioModal = dynamic(() => import('./PageEditor/DrawioModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 // const HandsontableModal = dynamic(() => import('./PageEditor/HandsontableModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
-declare const globalEmitter: EventEmitter;
-
 const logger = loggerFactory('growi:Page');
 
 type PageSubstanceProps = {
@@ -179,7 +178,7 @@ class PageSubstance extends React.Component<PageSubstanceProps> {
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
 
         { revisionId != null && (
-          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} pagePath={path} />
+          <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
         )}
 
         { !isGuestUser && (
@@ -208,7 +207,8 @@ export const Page = (props) => {
     tocRef.current = toc;
   }, []);
 
-  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: shareLinkId } = useShareLinkId();
+  const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { data: editorMode } = useEditorMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
@@ -217,20 +217,10 @@ export const Page = (props) => {
   const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
   const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  const { data: isBlinkedAtBoot, mutate: mutateBlinkedAtBoot } = useIsBlinkedHeaderAtBoot();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
   const pageRef = useRef(null);
 
-  useEffect(() => {
-    if (isBlinkedAtBoot) {
-      return;
-    }
-
-    blinkSectionHeaderAtBoot();
-    mutateBlinkedAtBoot(true);
-  }, [isBlinkedAtBoot, mutateBlinkedAtBoot]);
-
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps

+ 113 - 95
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,143 +1,161 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
-import { TabContent, TabPane } from 'reactstrap';
 
 // import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
-  useCurrentPagePath, useIsSharedUser, useIsEditable, useIsUserPage, usePageUser, useShareLinkId, useIsNotFound, useIsNotCreatable,
+  useCurrentPagePath, useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import CountBadge from '../Common/CountBadge';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import PageListIcon from '../Icons/PageListIcon';
 import { Page } from '../Page';
-// import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
-import UserInfo from '../User/UserInfo';
-
 
 import styles from './DisplaySwitcher.module.scss';
 
-
-const WIKI_HEADER_LINK = 120;
-
-const { isTopPage } = pagePathUtils;
+const { isTopPage, isUsersHomePage } = pagePathUtils;
 
 
 const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
+const PageEditorByHackmd = dynamic(() => import('../PageEditorByHackmd').then(mod => mod.PageEditorByHackmd), { ssr: false });
 const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
 const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
-const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
+const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons').then(mod => mod.ContentLinkButtons), { ssr: false });
 const NotFoundPage = dynamic(() => import('../NotFoundPage'), { ssr: false });
+const UserInfo = dynamic(() => import('../User/UserInfo').then(mod => mod.UserInfo), { ssr: false });
 
-const DisplaySwitcher = React.memo((): JSX.Element => {
-  const { t } = useTranslation();
 
-  // get element for smoothScroll
-  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+const PageView = React.memo((): JSX.Element => {
+  const { t } = useTranslation();
 
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: shareLinkId } = useShareLinkId();
-  const { data: isUserPage } = useIsUserPage();
-  const { data: isEditable } = useIsEditable();
-  const { data: pageUser } = usePageUser();
   const { data: isNotFound } = useIsNotFound();
-  const { data: isNotCreatable } = useIsNotCreatable();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
-
-  const { data: editorMode } = useEditorMode();
-
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
 
-  const isViewMode = editorMode === EditorMode.View;
   const isTopPagePath = isTopPage(currentPagePath ?? '');
-
-  const revision = currentPage?.revision;
+  const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
 
   return (
-    <>
-      <TabContent activeTab={editorMode}>
-        <TabPane tabId={EditorMode.View}>
-          <div className="d-flex flex-column flex-lg-row">
-
-            <div className="flex-grow-1 flex-basis-0 mw-0">
-              { isUserPage && <UserInfo pageUser={pageUser} />}
-              { !isNotFound && <Page /> }
-              { isNotFound && <NotFoundPage /> }
-            </div>
-
-            { !isNotFound && (
-              <div className="grw-side-contents-container">
-                <div className="grw-side-contents-sticky-container">
-
-                  {/* Page list */}
-                  <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                    { currentPagePath != null && !isSharedUser && (
-                      <button
-                        type="button"
-                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        onClick={() => openDescendantPageListModal(currentPagePath)}
-                        data-testid="pageListButton"
-                      >
-                        <div className="grw-page-accessories-control-icon">
-                          <PageListIcon />
-                        </div>
-                        {t('page_list')}
-                        <CountBadge count={currentPage?.descendantCount} offset={1} />
-                      </button>
-                    ) }
-                  </div>
-
-                  {/* Comments */}
-                  {/* { getCommentListDom != null && !isTopPagePath && ( */}
-                  { !isTopPagePath && (
-                    <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
-                      <button
-                        type="button"
-                        className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
-                        // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
-                      >
-                        <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
-                        <span>Comments</span>
-                        <CountBadge count={currentPage?.commentCount} />
-                      </button>
-                    </div>
-                  ) }
-
-                  <div className="d-none d-lg-block">
-                    <TableOfContents />
-                    <ContentLinkButtons />
+    <div className="d-flex flex-column flex-lg-row">
+
+      <div className="flex-grow-1 flex-basis-0 mw-0">
+        { isUsersHomePagePath && <UserInfo /> }
+        { !isNotFound && <Page /> }
+        { isNotFound && <NotFoundPage /> }
+      </div>
+
+      { !isNotFound && (
+        <div className="grw-side-contents-container">
+          <div className="grw-side-contents-sticky-container">
+
+            {/* Page list */}
+            <div className={`grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+              { currentPagePath != null && !isSharedUser && (
+                <button
+                  type="button"
+                  className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                  onClick={() => openDescendantPageListModal(currentPagePath)}
+                  data-testid="pageListButton"
+                >
+                  <div className="grw-page-accessories-control-icon">
+                    <PageListIcon />
                   </div>
+                  {t('page_list')}
+                  <CountBadge count={currentPage?.descendantCount} offset={1} />
+                </button>
+              ) }
+            </div>
 
-                </div>
+            {/* Comments */}
+            {/* { getCommentListDom != null && !isTopPagePath && ( */}
+            { !isTopPagePath && (
+              <div className={`mt-2 grw-page-accessories-control ${styles['grw-page-accessories-control']}`}>
+                <button
+                  type="button"
+                  className="btn btn-block btn-outline-secondary grw-btn-page-accessories rounded-pill d-flex justify-content-between align-items-center"
+                  // onClick={() => smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+                >
+                  <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
+                  <span>Comments</span>
+                  <CountBadge count={currentPage?.commentCount} />
+                </button>
               </div>
             ) }
 
-          </div>
-        </TabPane>
-        { isEditable && (
-          <TabPane tabId={EditorMode.Editor}>
-            <div data-testid="page-editor" id="page-editor">
-              <PageEditor />
-            </div>
-          </TabPane>
-        ) }
-        { isEditable && (
-          <TabPane tabId={EditorMode.HackMD}>
-            <div id="page-editor-with-hackmd">
-              {/* <PageEditorByHackmd /> */}
+            <div className="d-none d-lg-block">
+              <TableOfContents />
+              { isUsersHomePagePath && <ContentLinkButtons /> }
             </div>
-          </TabPane>
-        ) }
-      </TabContent>
-      { isEditable && !isViewMode && <EditorNavbarBottom /> }
 
+          </div>
+        </div>
+      ) }
+    </div>
+  );
+});
+PageView.displayName = 'PageView';
+
+
+const DisplaySwitcher = React.memo((): JSX.Element => {
+  // get element for smoothScroll
+  // const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+
+  const { data: isEditable } = useIsEditable();
+
+  const { data: editorMode = EditorMode.View } = useEditorMode();
+
+  const isViewMode = editorMode === EditorMode.View;
+
+  const navTabMapping = useMemo(() => {
+    return {
+      [EditorMode.View]: {
+        Content: () => (
+          <div data-testid="page-view" id="page-view">
+            <PageView />
+          </div>
+        ),
+      },
+      [EditorMode.Editor]: {
+        Content: () => (
+          isEditable
+            ? (
+              <div data-testid="page-editor" id="page-editor">
+                <PageEditor />
+              </div>
+            )
+            : <></>
+        ),
+      },
+      [EditorMode.HackMD]: {
+        Content: () => (
+          isEditable
+            ? (
+              <div id="page-editor-with-hackmd">
+                <PageEditorByHackmd />
+              </div>
+            )
+            : <></>
+        ),
+      },
+    };
+  }, [isEditable]);
+
+
+  return (
+    <>
+      <CustomTabContent activeTab={editorMode} navTabMapping={navTabMapping} />
+
+      { isEditable && !isViewMode && <EditorNavbarBottom /> }
       { isEditable && <HashChanged></HashChanged> }
     </>
   );

+ 0 - 2
packages/app/src/components/Page/RevisionLoader.jsx

@@ -111,8 +111,6 @@ class RevisionLoader extends React.Component {
       <RevisionRenderer
         rendererOptions={this.props.rendererOptions}
         markdown={markdown}
-        pagePath={this.props.pagePath}
-        highlightKeywords={this.props.highlightKeywords}
       />
     );
   }

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

@@ -2,102 +2,28 @@ import React from 'react';
 
 import ReactMarkdown from 'react-markdown';
 
-import { blinkElem } from '~/client/util/blink-section-header';
-import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
-import { CustomWindow } from '~/interfaces/global';
 import { RendererOptions } from '~/services/renderer/renderer';
-import { useCurrentPathname, useInterceptorManager } from '~/stores/context';
-import { useEditorSettings } from '~/stores/editor';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('components:Page:RevisionRenderer');
 
 
-// function getHighlightedBody(body: string, _keywords: string | string[]): string {
-//   const normalizedKeywordsArray: string[] = [];
-
-//   const keywords = (typeof _keywords === 'string') ? [_keywords] : _keywords;
-
-//   if (keywords.length === 0) {
-//     return body;
-//   }
-
-//   // !!TODO!!: add test code refs: https://redmine.weseek.co.jp/issues/86841
-//   // Separate keywords
-//   // - Surrounded by double quotation
-//   // - Split by both full-width and half-width spaces
-//   // [...keywords.match(/"[^"]+"|[^\u{20}\u{3000}]+/ug)].forEach((keyword, i) => {
-//   keywords.forEach((keyword, i) => {
-//     if (keyword === '') {
-//       return;
-//     }
-//     const k = keyword
-//       .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex operators
-//       .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-//     normalizedKeywordsArray.push(k);
-//   });
-
-//   const normalizedKeywords = `(${normalizedKeywordsArray.join('|')})`;
-//   const keywordRegxp = new RegExp(`${normalizedKeywords}(?!(.*?"))`, 'ig'); // prior https://regex101.com/r/oX7dq5/1
-//   let keywordRegexp2 = keywordRegxp;
-
-//   // for non-chrome browsers compatibility
-//   try {
-// eslint-disable-next-line regex/invalid, max-len
-//     keywordRegexp2 = new RegExp(`(?<!<)${normalizedKeywords}(?!(.*?("|>)))`, 'ig'); // inferior (this doesn't work well when html tags exist a lot) https://regex101.com/r/Dfi61F/1
-//   }
-//   catch (err) {
-//     logger.debug('Failed to initialize regex:', err);
-//   }
-
-//   const highlighter = (str) => { return str.replace(keywordRegxp, '<em class="highlighted-keyword">$&</em>') }; // prior
-//   const highlighter2 = (str) => { return str.replace(keywordRegexp2, '<em class="highlighted-keyword">$&</em>') }; // inferior
-
-//   const insideTagRegex = /<[^<>]*>/g;
-//   const betweenTagRegex = />([^<>]*)</g; // use (group) to ignore >< around
-
-//   const insideTagStrs = body.match(insideTagRegex);
-//   const betweenTagMatches = Array.from(body.matchAll(betweenTagRegex));
-
-//   let returnBody = body;
-//   const isSafeHtml = insideTagStrs?.length === betweenTagMatches.length + 1; // to check whether is safe to join
-//   if (isSafeHtml) {
-//     // highlight
-//     const betweenTagStrs: string[] = betweenTagMatches.map(match => highlighter(match[1])); // get only grouped part (exclude >< around)
-
-//     const arr: string[] = [];
-//     insideTagStrs.forEach((str, i) => {
-//       arr.push(str);
-//       arr.push(betweenTagStrs[i]);
-//     });
-//     returnBody = arr.join('');
-//   }
-//   else {
-//     // inferior highlighter
-//     returnBody = highlighter2(body);
-//   }
-
-//   return returnBody;
-// }
-
-
 type Props = {
   rendererOptions: RendererOptions,
   markdown: string,
-  pagePath: string,
-  highlightKeywords?: string | string[],
   additionalClassName?: string,
 }
 
 const RevisionRenderer = React.memo((props: Props): JSX.Element => {
 
   const {
-    rendererOptions, markdown, pagePath, highlightKeywords, additionalClassName,
+    rendererOptions, markdown, additionalClassName,
   } = props;
 
   return (
     <ReactMarkdown
+      data-testid="wiki"
       {...rendererOptions}
       className={`wiki ${additionalClassName ?? ''}`}
     >

+ 0 - 52
packages/app/src/components/Page/ShareLinkAlert.jsx

@@ -1,52 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-const ShareLinkAlert = () => {
-  const { t } = useTranslation();
-
-  const shareContent = document.getElementById('is-shared-page');
-  const expiredAt = shareContent.getAttribute('data-share-link-expired-at');
-  const createdAt = shareContent.getAttribute('data-share-link-created-at');
-
-  function generateRatio() {
-    const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
-    const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
-    return remainingTime / wholeTime;
-  }
-
-  let ratio = 1;
-
-  if (expiredAt !== '') {
-    ratio = generateRatio();
-  }
-
-  function specifyColor() {
-    let color;
-    if (ratio >= 0.75) {
-      color = 'success';
-    }
-    else if (ratio < 0.75 && ratio >= 0.5) {
-      color = 'info';
-    }
-    else if (ratio < 0.5 && ratio >= 0.25) {
-      color = 'warning';
-    }
-    else {
-      color = 'danger';
-    }
-    return color;
-  }
-
-  return (
-    <p className={`alert alert-${specifyColor()} py-3 px-4 d-edit-none`}>
-      <i className="icon-fw icon-link"></i>
-      {(expiredAt === '' ? <span>{t('page_page.notice.no_deadline')}</span>
-      // eslint-disable-next-line react/no-danger
-        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
-      )}
-    </p>
-  );
-};
-
-export default ShareLinkAlert;

+ 52 - 0
packages/app/src/components/Page/ShareLinkAlert.tsx

@@ -0,0 +1,52 @@
+import React, { FC } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+const generateRatio = (expiredAt: Date, createdAt: Date): number => {
+  const wholeTime = new Date(expiredAt).getTime() - new Date(createdAt).getTime();
+  const remainingTime = new Date(expiredAt).getTime() - new Date().getTime();
+  return remainingTime / wholeTime;
+};
+
+const getAlertColor = (ratio: number): string => {
+  let color: string;
+
+  if (ratio >= 0.75) {
+    color = 'success';
+  }
+  else if (ratio < 0.75 && ratio >= 0.5) {
+    color = 'info';
+  }
+  else if (ratio < 0.5 && ratio >= 0.25) {
+    color = 'warning';
+  }
+  else {
+    color = 'danger';
+  }
+  return color;
+};
+
+type Props = {
+  createdAt: Date,
+  expiredAt?: Date,
+}
+
+const ShareLinkAlert: FC<Props> = (props: Props) => {
+  const { t } = useTranslation();
+  const { expiredAt, createdAt } = props;
+
+  const ratio = expiredAt != null ? generateRatio(expiredAt, createdAt) : 1;
+  const alertColor = getAlertColor(ratio);
+
+  return (
+    <p className={`alert alert-${alertColor} my-3 px-4 d-edit-none`}>
+      <i className="icon-fw icon-link"></i>
+      {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
+      // eslint-disable-next-line react/no-danger
+        : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />
+      )}
+    </p>
+  );
+};
+
+export default ShareLinkAlert;

+ 15 - 16
packages/app/src/components/Page/TagLabels.tsx

@@ -1,5 +1,7 @@
 import React, { FC, useState } from 'react';
 
+import { Skelton } from '../Skelton';
+
 import RenderTagLabels from './RenderTagLabels';
 import TagEditModal from './TagEditModal';
 
@@ -11,8 +13,11 @@ type Props = {
   tagsUpdateInvoked?: (tags: string[]) => Promise<void> | void,
 }
 
+export const TagLabelsSkelton = (): JSX.Element => {
+  return <Skelton additionalClass={`${styles['grw-tag-labels-skelton']} py-1`} />;
+};
 
-const TagLabels:FC<Props> = (props: Props) => {
+export const TagLabels:FC<Props> = (props: Props) => {
   const { tags, isGuestUser, tagsUpdateInvoked } = props;
 
   const [isTagEditModalShown, setIsTagEditModalShown] = useState(false);
@@ -25,24 +30,20 @@ const TagLabels:FC<Props> = (props: Props) => {
     setIsTagEditModalShown(false);
   };
 
+  if (tags == null) {
+    return <TagLabelsSkelton />;
+  }
+
   return (
     <>
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`}>
         <i className="tag-icon icon-tag mr-2"></i>
-        { tags == null
-          ? (
-            <span className="grw-tag-label badge badge-secondary">―</span>
-          )
-          : (
-            <RenderTagLabels
-              tags={tags}
-              openEditorModal={openEditorModal}
-              isGuestUser={isGuestUser}
-            />
-          )
-        }
+        <RenderTagLabels
+          tags={tags}
+          openEditorModal={openEditorModal}
+          isGuestUser={isGuestUser}
+        />
       </div>
-
       <TagEditModal
         tags={tags}
         isOpen={isTagEditModalShown}
@@ -52,5 +53,3 @@ const TagLabels:FC<Props> = (props: Props) => {
     </>
   );
 };
-
-export default TagLabels;

+ 5 - 3
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -34,13 +34,15 @@ export const TrashPageAlert = (): JSX.Element => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
+  if (!isTrashPage) {
+    return <></>;
+  }
+
+
   const lastUpdateUserName = pageData?.lastUpdateUser?.name;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
 
-  if (!isTrashPage) {
-    return <></>;
-  }
 
   function openPutbackPageModalHandler() {
     if (pageId === undefined || pagePath === undefined) {

+ 62 - 65
packages/app/src/components/PageAttachment.tsx

@@ -1,65 +1,49 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
-import { useTranslation } from 'next-i18next';
+import { HasObjectId, IAttachment } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
 
-import DeleteAttachmentModal from './PageAttachment/DeleteAttachmentModal';
-import PageAttachmentList from './PageAttachment/PageAttachmentList';
+import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
+import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 
 // Utility
-const checkIfFileInUse = (markdown: string, attachment) => {
-  return markdown.match(attachment._id);
-};
-
-// Custom hook that handles processes related to inUseAttachments
-const useInUseAttachments = (attachments) => {
-  const { data: markdown } = useEditingMarkdown();
-  const [inUse, setInUse] = useState<any>({});
-
-  // Update inUse when either of attachments or markdown is updated
-  useEffect(() => {
-    if (markdown == null) {
-      return;
-    }
-
-    const newInUse = {};
-
-    for (const attachment of attachments) {
-      newInUse[attachment._id] = checkIfFileInUse(markdown, attachment);
-    }
-
-    setInUse(newInUse);
-  }, [attachments, markdown]);
-
-  return inUse;
+const checkIfFileInUse = (markdown: string, attachment): boolean => {
+  return markdown.indexOf(attachment._id) >= 0;
 };
 
 const PageAttachment = (): JSX.Element => {
-  const { t } = useTranslation();
-
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
+  const { data: markdown } = useEditingMarkdown();
 
   // States
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<any>(undefined);
+  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachment & HasObjectId) | null>(null);
   const [deleting, setDeleting] = useState(false);
   const [deleteError, setDeleteError] = useState('');
 
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
-  const {
-    attachments = [],
-    totalAttachments = 0,
-    limit,
-  } = dataAttachments ?? {};
 
   // Custom hooks
-  const inUseAttachments = useInUseAttachments(attachments);
+  const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
+    if (markdown == null || dataAttachments == null) {
+      return undefined;
+    }
+
+    const attachmentEntries = dataAttachments.attachments
+      .map((attachment) => {
+        return [attachment._id, checkIfFileInUse(markdown, attachment)];
+      });
+
+    return Object.fromEntries(attachmentEntries);
+  }, [dataAttachments, markdown]);
 
   // Methods
   const onChangePageHandler = useCallback((newPageNumber: number) => {
@@ -70,7 +54,7 @@ const PageAttachment = (): JSX.Element => {
     setAttachmentToDelete(attachment);
   }, []);
 
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment) => {
+  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachment & HasObjectId) => {
     setDeleting(true);
 
     try {
@@ -91,22 +75,32 @@ const PageAttachment = (): JSX.Element => {
   }, []);
 
   // Renderers
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isGuestUser) {
-      return <></>;
-    }
-
-    if (attachments.length === 0) {
+  const renderPageAttachmentList = useCallback(() => {
+    if (dataAttachments == null || inUseAttachmentsMap == null) {
       return (
-        <div data-testid="page-attachment">
-          {t('No_attachments_yet')}
+        <div className="text-muted text-center">
+          <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
         </div>
       );
     }
 
-    let deleteInUse = null;
-    if (attachmentToDelete != null) {
-      deleteInUse = inUseAttachments[attachmentToDelete._id] || false;
+    return (
+      <PageAttachmentList
+        attachments={dataAttachments.attachments}
+        inUse={inUseAttachmentsMap}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={!isGuestUser}
+      />
+    );
+  }, [dataAttachments, inUseAttachmentsMap, isGuestUser, onAttachmentDeleteClicked]);
+
+  const renderDeleteAttachmentModal = useCallback(() => {
+    if (isGuestUser) {
+      return <></>;
+    }
+
+    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
+      return <></>;
     }
 
     const isOpen = attachmentToDelete != null;
@@ -114,36 +108,39 @@ const PageAttachment = (): JSX.Element => {
     return (
       <DeleteAttachmentModal
         isOpen={isOpen}
-        animation="false"
         toggle={onToggleHandler}
         attachmentToDelete={attachmentToDelete}
-        inUse={deleteInUse}
         deleting={deleting}
         deleteError={deleteError}
         onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
       />
     );
   // eslint-disable-next-line max-len
-  }, [attachmentToDelete, attachments.length, deleteError, deleting, inUseAttachments, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler, t]);
+  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isGuestUser, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
 
-  return (
-    <div data-testid="page-attachment">
-      <PageAttachmentList
-        attachments={attachments}
-        inUse={inUseAttachments}
-        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
-        isUserLoggedIn={!isGuestUser}
-      />
-
-      {renderDeleteAttachmentModal()}
+  const renderPaginationWrapper = useCallback(() => {
+    if (dataAttachments == null || dataAttachments.attachments.length === 0) {
+      return <></>;
+    }
 
+    return (
       <PaginationWrapper
         activePage={pageNumber}
         changePage={onChangePageHandler}
-        totalItemsCount={totalAttachments}
-        pagingLimit={limit}
+        totalItemsCount={dataAttachments.totalAttachments}
+        pagingLimit={dataAttachments.limit}
         align="center"
       />
+    );
+  }, [dataAttachments, onChangePageHandler, pageNumber]);
+
+  return (
+    <div data-testid="page-attachment">
+      {renderPageAttachmentList()}
+
+      {renderDeleteAttachmentModal()}
+
+      {renderPaginationWrapper()}
     </div>
   );
 };

+ 0 - 97
packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx

@@ -1,97 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from 'react';
-
-import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { UserPicture } from '@growi/ui';
-import Username from '../User/Username';
-
-export default class DeleteAttachmentModal extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onDeleteConfirm = this._onDeleteConfirm.bind(this);
-  }
-
-  _onDeleteConfirm() {
-    this.props.onAttachmentDeleteClickedConfirm(this.props.attachmentToDelete);
-  }
-
-  iconNameByFormat(format) {
-    if (format.match(/image\/.+/i)) {
-      return 'icon-picture';
-    }
-
-    return 'icon-doc';
-  }
-
-  renderByFileFormat(attachment) {
-    const content = (attachment.fileFormat.match(/image\/.+/i))
-      ? <img src={attachment.filePathProxied} alt="deleting image" />
-      : '';
-
-
-    return (
-      <div className="attachment-delete-image">
-        <p>
-          <i className={this.iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
-        </p>
-        <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
-        </p>
-        {content}
-      </div>
-    );
-  }
-
-  render() {
-    const attachment = this.props.attachmentToDelete;
-    if (attachment === null) {
-      return null;
-    }
-
-    const props = Object.assign({}, this.props);
-    delete props.onAttachmentDeleteClickedConfirm;
-    delete props.attachmentToDelete;
-    delete props.inUse;
-    delete props.deleting;
-    delete props.deleteError;
-
-    let deletingIndicator = '';
-    if (this.props.deleting) {
-      deletingIndicator = <div className="speeding-wheel-sm"></div>;
-    }
-    if (this.props.deleteError) {
-      deletingIndicator = <span>{this.props.deleteError}</span>;
-    }
-
-    const renderAttachment = this.renderByFileFormat(attachment);
-
-    return (
-      <Modal {...props} className="attachment-delete-modal" bssize="large" aria-labelledby="contained-modal-title-lg">
-        <ModalHeader tag="h4" toggle={this.props.toggle} className="bg-danger text-light">
-          <span id="contained-modal-title-lg">Delete attachment?</span>
-        </ModalHeader>
-        <ModalBody>
-          {renderAttachment}
-        </ModalBody>
-        <ModalFooter>
-          <div className="mr-3 d-inline-block">
-            {deletingIndicator}
-          </div>
-          <Button
-            color="danger"
-            onClick={this._onDeleteConfirm}
-            disabled={this.props.deleting}
-          >Delete!
-          </Button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}

+ 98 - 0
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -0,0 +1,98 @@
+/* eslint-disable react/prop-types */
+import React, { useCallback } from 'react';
+
+import { HasObjectId, IAttachment } from '@growi/core';
+import { UserPicture } from '@growi/ui';
+import {
+  Button,
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import Username from '../User/Username';
+
+
+function iconNameByFormat(format: string): string {
+  if (format.match(/image\/.+/i)) {
+    return 'icon-picture';
+  }
+
+  return 'icon-doc';
+}
+
+
+type Props = {
+  isOpen: boolean,
+  toggle: () => void,
+  attachmentToDelete: IAttachment & HasObjectId | null,
+  deleting: boolean,
+  deleteError: string,
+  onAttachmentDeleteClickedConfirm?: (attachment: IAttachment & HasObjectId) => Promise<void>,
+}
+
+export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+
+  const {
+    isOpen, toggle,
+    attachmentToDelete, deleting, deleteError,
+    onAttachmentDeleteClickedConfirm,
+  } = props;
+
+  const onDeleteConfirm = useCallback(() => {
+    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+      return;
+    }
+    onAttachmentDeleteClickedConfirm(attachmentToDelete);
+  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
+
+  const renderByFileFormat = useCallback((attachment) => {
+    const content = (attachment.fileFormat.match(/image\/.+/i))
+      // eslint-disable-next-line @next/next/no-img-element
+      ? <img src={attachment.filePathProxied} alt="deleting image" />
+      : '';
+
+
+    return (
+      <div className="attachment-delete-image">
+        <p>
+          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+        </p>
+        <p>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+        </p>
+        {content}
+      </div>
+    );
+  }, []);
+
+  let deletingIndicator = <></>;
+  if (deleting) {
+    deletingIndicator = <div className="speeding-wheel-sm"></div>;
+  }
+  if (deleteError) {
+    deletingIndicator = <span>{deleteError}</span>;
+  }
+
+
+  return (
+    <Modal isOpen={isOpen} className="attachment-delete-modal" size="lg" aria-labelledby="contained-modal-title-lg" fade={false}>
+      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">Delete attachment?</span>
+      </ModalHeader>
+      <ModalBody>
+        {renderByFileFormat(attachmentToDelete)}
+      </ModalBody>
+      <ModalFooter>
+        <div className="mr-3 d-inline-block">
+          {deletingIndicator}
+        </div>
+        <Button
+          color="danger"
+          onClick={onDeleteConfirm}
+          disabled={deleting}
+        >Delete!
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+
+};

+ 0 - 45
packages/app/src/components/PageAttachment/PageAttachmentList.jsx

@@ -1,45 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { Attachment } from '@growi/ui';
-
-export default class PageAttachmentList extends React.Component {
-
-  render() {
-    if (this.props.attachments <= 0) {
-      return null;
-    }
-
-    const attachmentList = this.props.attachments.map((attachment, idx) => {
-      return (
-        <Attachment
-          key={`page:attachment:${attachment._id}`}
-          attachment={attachment}
-          inUse={this.props.inUse[attachment._id] || false}
-          onAttachmentDeleteClicked={this.props.onAttachmentDeleteClicked}
-          isUserLoggedIn={this.props.isUserLoggedIn}
-        />
-      );
-    });
-
-    return (
-      <div>
-        <ul className="pl-2">
-          {attachmentList}
-        </ul>
-      </div>
-    );
-  }
-
-}
-
-PageAttachmentList.propTypes = {
-  attachments: PropTypes.arrayOf(PropTypes.object),
-  inUse: PropTypes.objectOf(PropTypes.bool),
-  onAttachmentDeleteClicked: PropTypes.func,
-  isUserLoggedIn: PropTypes.bool,
-};
-PageAttachmentList.defaultProps = {
-  attachments: [],
-  inUse: {},
-};

+ 47 - 0
packages/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -0,0 +1,47 @@
+import React from 'react';
+
+
+import { HasObjectId, IAttachment } from '@growi/core';
+import { Attachment } from '@growi/ui';
+import { useTranslation } from 'next-i18next';
+
+
+type Props = {
+  attachments: (IAttachment & HasObjectId)[],
+  inUse: { [id: string]: boolean },
+  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  isUserLoggedIn?: boolean,
+}
+
+export const PageAttachmentList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    attachments, inUse, onAttachmentDeleteClicked, isUserLoggedIn,
+  } = props;
+
+  if (attachments.length === 0) {
+    return <>{t('No_attachments_yet')}</>;
+  }
+
+  const attachmentList = attachments.map((attachment) => {
+    return (
+      <Attachment
+        key={`page:attachment:${attachment._id}`}
+        attachment={attachment}
+        inUse={inUse[attachment._id] || false}
+        onAttachmentDeleteClicked={onAttachmentDeleteClicked}
+        isUserLoggedIn={isUserLoggedIn}
+      />
+    );
+  });
+
+  return (
+    <div>
+      <ul className="pl-2">
+        {attachmentList}
+      </ul>
+    </div>
+  );
+
+};

+ 29 - 50
packages/app/src/components/PageComment.tsx

@@ -1,15 +1,15 @@
 import React, {
-  FC, useEffect, useState, useMemo, memo, useCallback,
+  FC, useState, useMemo, memo, useCallback,
 } from 'react';
 
+import { IRevisionHasId, isPopulated, getIdForRef } from '@growi/core';
 import dynamic from 'next/dynamic';
 import { Button } from 'reactstrap';
 
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
-import { useCurrentPagePath } from '~/stores/context';
-import { useSWRxCurrentPage } from '~/stores/page';
-import { useCommentPreviewOptions } from '~/stores/renderer';
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
@@ -27,9 +27,11 @@ const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
   () => import('./PageComment/DeleteCommentModal').then(mod => mod.DeleteCommentModal), { ssr: false },
 );
 
-
-type PageCommentProps = {
-  pageId?: string,
+export type PageCommentProps = {
+  rendererOptions?: RendererOptions,
+  pageId: string,
+  revision: string | IRevisionHasId,
+  currentUser: any,
   isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
   highlightKeywords?: string[],
@@ -39,49 +41,24 @@ type PageCommentProps = {
 export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
 
   const {
-    pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+    rendererOptions: rendererOptionsByProps,
+    pageId, revision, currentUser, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
-  const { data: rendererOptions } = useCommentPreviewOptions();
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererOptionsForCurrentPage } = useCommentForCurrentPageOptions();
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
-  const [formatedComments, setFormatedComments] = useState<ICommentHasIdList | null>(null);
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
 
-  const commentsFromOldest = useMemo(() => (formatedComments != null ? [...formatedComments].reverse() : null), [formatedComments]);
+  const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
     () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
   );
   const allReplies = {};
 
-  const highlightComment = useCallback((comment: string):string => {
-    if (highlightKeywords == null) return comment;
-
-    let highlightedComment = '';
-    highlightKeywords.forEach((highlightKeyword) => {
-      highlightedComment = comment.replaceAll(highlightKeyword, '<em class="highlighted-keyword">$&</em>');
-    });
-    return highlightedComment;
-  }, [highlightKeywords]);
-
-  useEffect(() => {
-    if (comments != null) {
-      const preprocessedCommentList: string[] = comments.map((comment) => {
-        const highlightedComment: string = highlightComment(comment.comment);
-        return highlightedComment;
-      });
-      const preprocessedComments: ICommentHasIdList = comments.map((comment, index) => {
-        return { ...comment, comment: preprocessedCommentList[index] };
-      });
-      setFormatedComments(preprocessedComments);
-    }
-  }, [comments, highlightComment]);
-
   if (commentsFromOldest != null) {
     commentsFromOldest.forEach((comment) => {
       if (comment.replyTo != null) {
@@ -132,8 +109,10 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
-  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null || currentPagePath == null || currentPage == null) {
-    if (hideIfEmpty && comments?.length === 0) {
+  const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
+
+  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
+    if (hideIfEmpty) {
       return <></>;
     }
     return (
@@ -141,33 +120,33 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
     );
   }
 
-  if (currentPage.revision == null) {
-    return <></>;
-  }
+  const revisionId = getIdForRef(revision);
+  const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
 
   const generateCommentElement = (comment: ICommentHasId) => (
     <Comment
+      rendererOptions={rendererOptions}
       comment={comment}
+      revisionId={revisionId}
+      revisionCreatedAt={revisionCreatedAt as Date}
+      currentUser={currentUser}
       isReadOnly={isReadOnly}
+      highlightKeywords={highlightKeywords}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
-      rendererOptions={rendererOptions}
-      currentPagePath={currentPagePath}
-      currentRevisionId={currentPage.revision._id}
-      currentRevisionCreatedAt={currentPage.revision.createdAt}
     />
   );
 
   const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
     <ReplyComments
+      rendererOptions={rendererOptions}
       isReadOnly={isReadOnly}
+      revisionId={revisionId}
+      revisionCreatedAt={revisionCreatedAt as Date}
+      currentUser={currentUser}
       replyList={replyComments}
       deleteBtnClicked={onClickDeleteButton}
       onComment={mutate}
-      rendererOptions={rendererOptions}
-      currentPagePath={currentPagePath}
-      currentRevisionId={currentPage.revision._id}
-      currentRevisionCreatedAt={currentPage.revision.createdAt}
     />
   );
 
@@ -207,7 +186,7 @@ export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps):
                     )}
                     {(!isReadOnly && showEditorIds.has(comment._id)) && (
                       <CommentEditor
-                        rendererOptions={rendererOptions}
+                        pageId={pageId}
                         replyTo={comment._id}
                         onCancelButtonClicked={() => {
                           removeShowEditorId(comment._id);

+ 47 - 43
packages/app/src/components/PageComment/Comment.tsx

@@ -1,5 +1,6 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useMemo, useState } from 'react';
 
+import { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
@@ -7,7 +8,6 @@ import dynamic from 'next/dynamic';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import { RendererOptions } from '~/services/renderer/renderer';
-import { useCurrentUser } from '~/stores/context';
 
 import { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
@@ -22,27 +22,26 @@ import styles from './Comment.module.scss';
 
 const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
 
-
 type CommentProps = {
   comment: ICommentHasId,
+  rendererOptions: RendererOptions,
+  revisionId: string,
+  revisionCreatedAt: Date,
+  currentUser: IUser,
   isReadOnly: boolean,
+  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
-  rendererOptions: RendererOptions,
-  currentPagePath: string,
-  currentRevisionId: string,
-  currentRevisionCreatedAt: Date,
 }
 
 export const Comment = (props: CommentProps): JSX.Element => {
 
   const {
-    comment, isReadOnly, deleteBtnClicked, onComment, rendererOptions,
-    currentPagePath, currentRevisionId, currentRevisionCreatedAt,
+    comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
+    deleteBtnClicked, onComment,
   } = props;
 
   const { t } = useTranslation();
-  const { data: currentUser } = useCurrentUser();
 
   const [markdown, setMarkdown] = useState('');
   const [isReEdit, setIsReEdit] = useState(false);
@@ -55,18 +54,20 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const isEdited = createdAt < updatedAt;
 
   useEffect(() => {
+    if (revisionId == null) {
+      return;
+    }
+
     setMarkdown(comment.comment);
 
     const isCurrentRevision = () => {
-      return comment.revision === currentRevisionId;
+      return comment.revision === revisionId;
     };
     isCurrentRevision();
-
-  }, [comment, currentRevisionId]);
+  }, [comment, revisionId]);
 
   const isCurrentUserEqualsToAuthor = () => {
     const { creator }: any = comment;
-
     if (creator == null || currentUser == null) {
       return false;
     }
@@ -76,14 +77,17 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const getRootClassName = (comment: ICommentHasId) => {
     let className = 'page-comment flex-column';
 
-    if (comment.revision === currentRevisionId) {
-      className += ' page-comment-current';
-    }
-    else if (comment.createdAt.getTime() > currentRevisionCreatedAt.getTime()) {
-      className += ' page-comment-newer';
-    }
-    else {
-      className += ' page-comment-older';
+    // Conditional for called from SearchResultContext
+    if (revisionId != null && revisionCreatedAt != null) {
+      if (comment.revision === revisionId) {
+        className += ' page-comment-current';
+      }
+      else if (comment.createdAt.getTime() > revisionCreatedAt.getTime()) {
+        className += ' page-comment-newer';
+      }
+      else {
+        className += ' page-comment-older';
+      }
     }
 
     if (isCurrentUserEqualsToAuthor()) {
@@ -101,31 +105,32 @@ export const Comment = (props: CommentProps): JSX.Element => {
     return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
   };
 
-  const renderRevisionBody = () => {
-    return (
-      <RevisionRenderer
-        rendererOptions={rendererOptions}
-        markdown={markdown}
-        additionalClassName="comment"
-        pagePath={currentPagePath}
-      />
-    );
-  };
+  const commentBody = useMemo(() => {
+    if (rendererOptions == null) {
+      return <></>;
+    }
+
+    return isMarkdown
+      ? (
+        <RevisionRenderer
+          rendererOptions={rendererOptions}
+          markdown={markdown}
+          additionalClassName="comment"
+        />
+      )
+      : renderText(comment.comment);
+  }, [comment, isMarkdown, markdown, rendererOptions]);
 
   const rootClassName = getRootClassName(comment);
-  const commentBody = isMarkdown ? renderRevisionBody() : renderText(comment.comment);
   const revHref = `?revision=${comment.revision}`;
-
   const editedDateId = `editedDate-${comment._id}`;
-  const editedDateFormatted = isEdited
-    ? format(updatedAt, 'yyyy/MM/dd HH:mm')
-    : null;
+  const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
 
   return (
     <div className={`${styles['comment-styles']}`}>
-      {(isReEdit && !isReadOnly) ? (
+      { (isReEdit && !isReadOnly) ? (
         <CommentEditor
-          rendererOptions={rendererOptions}
+          pageId={comment._id}
           replyTo={undefined}
           currentCommentId={commentId}
           commentBody={comment.comment}
@@ -154,7 +159,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
                   <span id={editedDateId}>&nbsp;(edited)</span>
                   <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
                 </>
-              )}
+              ) }
               <span className="ml-2">
                 <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
                   <HistoryIcon />
@@ -164,7 +169,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
                 </UncontrolledTooltip>
               </span>
             </div>
-            {(isCurrentUserEqualsToAuthor() && !isReadOnly) && (
+            { (isCurrentUserEqualsToAuthor() && !isReadOnly) && (
               <CommentControl
                 onClickDeleteBtn={deleteBtnClickedHandler}
                 onClickEditBtn={() => setIsReEdit(true)}
@@ -172,8 +177,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
             ) }
           </div>
         </div>
-      )
-      }
+      ) }
     </div>
   );
 };

+ 6 - 10
packages/app/src/components/PageComment/CommentEditor.module.scss

@@ -1,5 +1,6 @@
 @use '~/styles/bootstrap/init' as bs;
-@use './_comment-inheritance';
+@use './comment-inheritance';
+@use '../PageEditor/page-editor-inheritance';
 
 // display cheatsheet for comment form only
 .comment-editor-styles :global {
@@ -30,14 +31,9 @@
     }
   }
 
-  // TODO: Refacotr Soft-coding
-  .page-comment-commenteditorlazyrenderer-body-skelton {
-    position: relative;
-    padding: 2.258rem 2rem;
-    margin-left: 4.5em;
-    line-height: 1.5;
-    @include bs.media-breakpoint-down(xs) {
-      margin-left: 3.5em;
-    }
+  .page-comment-editor-skelton {
+    height: comment-inheritance.$codemirror-default-height;
+    margin-top: page-editor-inheritance.$navbar-editor-height;
+    margin-bottom: bs.$line-height-base + bs.$btn-padding-y;
   }
 }

+ 27 - 25
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -3,30 +3,39 @@ import React, {
 } from 'react';
 
 import { UserPicture } from '@growi/ui';
+import dynamic from 'next/dynamic';
 import {
   Button, TabContent, TabPane,
 } from 'reactstrap';
 import * as toastr from 'toastr';
 
 import { apiPostForm } from '~/client/util/apiv1-client';
-import { RendererOptions } from '~/services/renderer/renderer';
+import { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useCurrentPagePath, useCurrentUser, useRevisionId, useIsSlackConfigured,
   useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
-import { SlackNotification } from '../SlackNotification';
+import { Skelton } from '../Skelton';
+
 
 import { CommentPreview } from './CommentPreview';
 
 import styles from './CommentEditor.module.scss';
 
 
+const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
+const Editor = dynamic(() => import('../PageEditor/Editor'),
+  {
+    ssr: false,
+    loading: () => <Skelton additionalClass="grw-skelton page-comment-editor-skelton" />,
+  });
+
+
 const navTabMapping = {
   comment_editor: {
     Icon: () => <i className="icon-settings" />,
@@ -41,7 +50,7 @@ const navTabMapping = {
 };
 
 export type CommentEditorProps = {
-  rendererOptions: RendererOptions,
+  pageId: string,
   isForNewComment?: boolean,
   replyTo?: string,
   currentCommentId?: string,
@@ -50,23 +59,17 @@ export type CommentEditorProps = {
   onCommentButtonClicked?: () => void,
 }
 
-type EditorRef = {
-  setValue: (value: string) => void,
-  insertText: (text: string) => void,
-  terminateUploadingState: () => void,
-}
 
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const {
-    rendererOptions, isForNewComment, replyTo,
+    pageId, isForNewComment, replyTo,
     currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
 
   const { data: currentUser } = useCurrentUser();
   const { data: currentPagePath } = useCurrentPagePath();
-  const { data: currentPageId } = useCurrentPageId();
-  const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
+  const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
   const { data: revisionId } = useRevisionId();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
@@ -80,7 +83,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
 
-  const editorRef = useRef<EditorRef>(null);
+  const editorRef = useRef<IEditorMethods>(null);
 
   const handleSelect = useCallback((activeTab: string) => {
     setActiveTab(activeTab);
@@ -173,7 +176,6 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     if (editorRef.current == null) { return }
 
     const pagePath = currentPagePath;
-    const pageId = currentPageId;
     const endpoint = '/attachments.add';
     const formData = new FormData();
     formData.append('file', file);
@@ -199,21 +201,15 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [apiErrorHandler, currentPageId, currentPagePath]);
+  }, [apiErrorHandler, currentPagePath, pageId]);
 
   const getCommentHtml = useCallback(() => {
     if (currentPagePath == null) {
       return <></>;
     }
 
-    return (
-      <CommentPreview
-        rendererOptions={rendererOptions}
-        markdown={comment}
-        path={currentPagePath}
-      />
-    );
-  }, [currentPagePath, comment, rendererOptions]);
+    return <CommentPreview markdown={comment} />;
+  }, [currentPagePath, comment]);
 
   const renderBeforeReady = useCallback((): JSX.Element => {
     return (
@@ -236,7 +232,13 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
     const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
     const cancelButton = (
-      <Button outline color="danger" size="xs" className="btn btn-outline-danger rounded-pill" onClick={cancelButtonClickedHandler}>
+      <Button
+        outline
+        color="danger"
+        size="xs"
+        className="btn btn-outline-danger rounded-pill"
+        onClick={cancelButtonClickedHandler}
+      >
         Cancel
       </Button>
     );

+ 0 - 47
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,47 +0,0 @@
-import React from 'react';
-
-import dynamic from 'next/dynamic';
-
-import { RendererOptions } from '~/services/renderer/renderer';
-
-import { useSWRxPageComment } from '../../stores/comment';
-import { Skelton } from '../Skelton';
-
-import { CommentEditorProps } from './CommentEditor';
-
-import CommentEditorStyles from './CommentEditor.module.scss';
-
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor),
-  {
-    ssr: false,
-    loading: () => <div className={`${CommentEditorStyles['comment-editor-styles']} form page-comment-form`}>
-      <div className='comment-form'>
-        <div className='comment-form-user'>
-          <Skelton additionalClass='rounded-circle picture' roundedPill />
-        </div>
-        <Skelton additionalClass="page-comment-commenteditorlazyrenderer-body-skelton grw-skelton" />
-      </div>
-    </div>,
-  });
-
-type Props = {
-  pageId?: string,
-  rendererOptions: RendererOptions,
-}
-
-export const CommentEditorLazyRenderer = (props: Props): JSX.Element => {
-
-  const { pageId, rendererOptions } = props;
-
-  const { mutate } = useSWRxPageComment(pageId);
-
-  return (
-    <CommentEditor
-      rendererOptions={rendererOptions}
-      isForNewComment
-      replyTo={undefined}
-      onCommentButtonClicked={mutate}
-    />
-  );
-
-};

+ 9 - 0
packages/app/src/components/PageComment/CommentPreview.module.scss

@@ -0,0 +1,9 @@
+@use '~/styles/bootstrap/init' as bs;
+@use './comment-inheritance';
+@use '../PageEditor/page-editor-inheritance';
+
+.grw-comment-preview {
+  min-height: page-editor-inheritance.$navbar-editor-height
+    + comment-inheritance.$codemirror-default-height
+    + bs.$line-height-base;
+}

+ 12 - 6
packages/app/src/components/PageComment/CommentPreview.tsx

@@ -1,25 +1,31 @@
-import { RendererOptions } from '~/services/renderer/renderer';
+import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import RevisionRenderer from '../Page/RevisionRenderer';
 
 
+import styles from './CommentPreview.module.scss';
+
+
 type CommentPreviewPorps = {
-  rendererOptions: RendererOptions,
   markdown: string,
-  path: string,
 }
 
 export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
 
-  const { rendererOptions, markdown, path } = props;
+  const { markdown } = props;
+
+  const { data: rendererOptions } = useCommentPreviewOptions();
+
+  if (rendererOptions == null) {
+    return <></>;
+  }
 
   return (
-    <div className="page-comment-preview-body">
+    <div className={`grw-comment-preview ${styles['grw-comment-preview']}`}>
       <RevisionRenderer
         rendererOptions={rendererOptions}
         markdown={markdown}
         additionalClassName="comment"
-        pagePath={path}
       />
     </div>
   );

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

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';

+ 13 - 10
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -6,6 +6,7 @@ import { Collapse } from 'reactstrap';
 import { RendererOptions } from '~/services/renderer/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
+import { IUser } from '../../interfaces/user';
 import { useIsAllReplyShown } from '../../stores/context';
 
 import { Comment } from './Comment';
@@ -14,21 +15,22 @@ import styles from './ReplyComments.module.scss';
 
 
 type ReplycommentsProps = {
+  rendererOptions: RendererOptions,
   isReadOnly: boolean,
+  revisionId: string,
+  revisionCreatedAt: Date,
+  currentUser: IUser,
   replyList: ICommentHasIdList,
+  highlightKeywords?: string[],
   deleteBtnClicked: (comment: ICommentHasId) => void,
   onComment: () => void,
-  rendererOptions: RendererOptions,
-  currentPagePath: string,
-  currentRevisionId: string,
-  currentRevisionCreatedAt: Date,
 }
 
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const {
-    isReadOnly, replyList, deleteBtnClicked, onComment, rendererOptions,
-    currentPagePath, currentRevisionId, currentRevisionCreatedAt,
+    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList, highlightKeywords,
+    deleteBtnClicked, onComment,
   } = props;
 
   const { data: isAllReplyShown } = useIsAllReplyShown();
@@ -39,14 +41,15 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
     return (
       <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
         <Comment
+          rendererOptions={rendererOptions}
           comment={reply}
+          revisionId={revisionId}
+          revisionCreatedAt={revisionCreatedAt}
+          currentUser={currentUser}
           isReadOnly={isReadOnly}
+          highlightKeywords={highlightKeywords}
           deleteBtnClicked={deleteBtnClicked}
           onComment={onComment}
-          rendererOptions={rendererOptions}
-          currentPagePath={currentPagePath}
-          currentRevisionId={currentRevisionId}
-          currentRevisionCreatedAt={currentRevisionCreatedAt}
         />
       </div>
     );

+ 2 - 0
packages/app/src/components/PageComment/_comment-inheritance.scss

@@ -32,3 +32,5 @@
     height: 2em;
   }
 }
+
+$codemirror-default-height: 300px;

+ 27 - 12
packages/app/src/components/PageContentFooter.tsx

@@ -1,5 +1,6 @@
-import React, { memo } from 'react';
+import React from 'react';
 
+import { IPage, IUser } from '@growi/core';
 import dynamic from 'next/dynamic';
 
 import { useSWRxCurrentPage } from '~/stores/page';
@@ -8,27 +9,41 @@ import { Skelton } from './Skelton';
 
 import styles from './PageContentFooter.module.scss';
 
-const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'),
-  { ssr: false, loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} /> });
+const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'), {
+  ssr: false,
+  loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} />,
+});
 
-export const PageContentFooter = memo((): JSX.Element => {
+export type PageContentFooterProps = {
+  page: IPage,
+}
 
-  const { data: page } = useSWRxCurrentPage();
+export const PageContentFooter = (props: PageContentFooterProps): JSX.Element => {
 
-  if (page == null || page.revision == null) {
-    return <></>;
-  }
+  const { page } = props;
+
+  const {
+    creator, lastUpdateUser, createdAt, updatedAt,
+  } = page;
 
   return (
     <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
       <div className="grw-container-convertible">
         <div className="page-meta">
-          <AuthorInfo user={page.creator} date={page.createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={page.revision.author} date={page.updatedAt} mode="update" locate="footer" />
+          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={lastUpdateUser as IUser} date={updatedAt} mode="update" locate="footer" />
         </div>
       </div>
     </div>
   );
-});
+};
+
+export const CurrentPageContentFooter = (): JSX.Element => {
+  const { data: currentPage } = useSWRxCurrentPage();
+
+  if (currentPage == null) {
+    return <></>;
+  }
 
-PageContentFooter.displayName = 'PageContentFooter';
+  return <PageContentFooter page={currentPage} />;
+};

+ 9 - 17
packages/app/src/components/PageEditor.tsx

@@ -11,6 +11,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import { saveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
+import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
   useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPathname, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
@@ -39,14 +40,6 @@ const logger = loggerFactory('growi:PageEditor');
 declare const globalEmitter: EventEmitter;
 
 
-type EditorRef = {
-  setValue: (markdown: string) => void,
-  setCaretLine: (line: number) => void,
-  insertText: (text: string) => void,
-  forceToFocus: () => void,
-  terminateUploadingState: () => void,
-}
-
 // for scrolling
 let lastScrolledDateWithCursor: Date | null = null;
 let isOriginOfScrollSyncEditor = false;
@@ -69,7 +62,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
-  const { data: isEnabledUnsavedWarning, mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
@@ -84,21 +77,20 @@ const PageEditor = React.memo((): JSX.Element => {
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
 
-  const editorRef = useRef<EditorRef>(null);
+  const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
-  const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, (value) => {
+  const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     setMarkdownToPreview(value);
+
     // Displays an unsaved warning alert
-    if (!isEnabledUnsavedWarning) {
-      mutateIsEnabledUnsavedWarning(true);
-    }
-  })), [isEnabledUnsavedWarning, mutateIsEnabledUnsavedWarning]);
+    mutateIsEnabledUnsavedWarning(!isClean);
+  })), [mutateIsEnabledUnsavedWarning]);
 
 
-  const markdownChangedHandler = useCallback((value: string): void => {
-    setMarkdownWithDebounce(value);
+  const markdownChangedHandler = useCallback((value: string, isClean: boolean): void => {
+    setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
   const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {

+ 9 - 2
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -102,7 +102,7 @@ class CodeMirrorEditor extends AbstractEditor {
     this.state = {
       isGfmMode: this.props.isGfmMode,
       isLoadingKeymap: false,
-      isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
+      isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value?.length === 0,
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
       isEmojiPickerShown: false,
@@ -169,6 +169,9 @@ class CodeMirrorEditor extends AbstractEditor {
     // ensure to be able to resolve 'this' to use 'codemirror.commands.save'
     this.getCodeMirror().codeMirrorEditor = this;
 
+    // mark clean
+    this.getCodeMirror().getDoc().markClean();
+
     // fold drawio section
     this.foldDrawioSection();
 
@@ -245,6 +248,9 @@ class CodeMirrorEditor extends AbstractEditor {
    */
   setValue(newValue) {
     this.getCodeMirror().getDoc().setValue(newValue);
+
+    // mark clean
+    this.getCodeMirror().getDoc().markClean();
   }
 
   /**
@@ -565,7 +571,8 @@ class CodeMirrorEditor extends AbstractEditor {
 
   changeHandler(editor, data, value) {
     if (this.props.onChange != null) {
-      this.props.onChange(value);
+      const isClean = data.origin == null || editor.isClean();
+      this.props.onChange(value, isClean);
     }
 
     this.updateCheatsheetStates(null, value);

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

@@ -59,6 +59,11 @@
     pre.CodeMirror-line-like.CodeMirror-placeholder {
       color: bs.$text-muted;
     }
+
+    // overwrite .CodeMirror-scroll
+    .CodeMirror-scroll {
+      box-sizing: border-box;
+    }
   }
 
   // patch to fix https://github.com/codemirror/CodeMirror/issues/4089

+ 2 - 1
packages/app/src/components/PageEditor/Editor.module.scss

@@ -1,5 +1,6 @@
 @use '~/styles/mixins' as ms;
 @use '~/styles/bootstrap/init' as bs;
+@use './page-editor-inheritance';
 
 
 .editor-container :global {
@@ -127,7 +128,7 @@
 
   // for Navbar editor
   .navbar-editor {
-    height: 30px;
+    height: page-editor-inheritance.$navbar-editor-height;
     padding: 0;
 
     border-bottom: 1px solid transparent;

+ 28 - 46
packages/app/src/components/PageEditor/Editor.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useRef, useImperativeHandle, useCallback, useMemo,
+  useState, useRef, useImperativeHandle, useCallback, ForwardRefRenderFunction, forwardRef,
 } from 'react';
 
 import Dropzone from 'react-dropzone';
@@ -22,14 +22,14 @@ import TextAreaEditor from './TextAreaEditor';
 
 import styles from './Editor.module.scss';
 
-type EditorPropsType = {
+export type EditorPropsType = {
   value?: string,
   isGfmMode?: boolean,
   noCdn?: boolean,
   isUploadable?: boolean,
   isUploadableFile?: boolean,
   isTextlintEnabled?: boolean,
-  onChange?: (newValue: string) => void,
+  onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
   indentSize?: number,
   onScroll?: ({ line: number }) => void,
@@ -44,7 +44,7 @@ type DropzoneRef = {
   open: () => void
 }
 
-const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
+const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props, ref): JSX.Element => {
   const {
     onUpload, isUploadable, isUploadableFile, indentSize, isGfmMode = true,
   } = props;
@@ -66,45 +66,29 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
     return isMobile ? taEditorRef.current : cmEditorRef.current;
   }, [isMobile]);
 
-  const methods: Partial<IEditorMethods> = useMemo(() => {
-    return {
-      forceToFocus: () => {
-        editorSubstance()?.forceToFocus();
-      },
-      setValue: (newValue: string) => {
-        editorSubstance()?.setValue(newValue);
-      },
-      setGfmMode: (bool: boolean) => {
-        editorSubstance()?.setGfmMode(bool);
-      },
-      setCaretLine: (line: number) => {
-        editorSubstance()?.setCaretLine(line);
-      },
-      setScrollTopByLine: (line: number) => {
-        editorSubstance()?.setScrollTopByLine(line);
-      },
-      insertText: (text: string) => {
-        editorSubstance()?.insertText(text);
-      },
-      getNavbarItems: (): JSX.Element[] => {
-        // concat common items and items specific to CodeMirrorEditor or TextAreaEditor
-        const navbarItems = editorSubstance()?.getNavbarItems() ?? [];
-        return navbarItems;
-      },
-    };
-  }, [editorSubstance]);
-
   // methods for ref
   useImperativeHandle(ref, () => ({
-    forceToFocus: methods.forceToFocus,
-    setValue: methods.setValue,
-    setGfmMode: methods.setGfmMode,
-    setCaretLine: methods.setCaretLine,
-    setScrollTopByLine: methods.setScrollTopByLine,
-    insertText: methods.insertText,
+    forceToFocus: () => {
+      editorSubstance()?.forceToFocus();
+    },
+    setValue: (newValue: string) => {
+      editorSubstance()?.setValue(newValue);
+    },
+    setGfmMode: (bool: boolean) => {
+      editorSubstance()?.setGfmMode(bool);
+    },
+    setCaretLine: (line: number) => {
+      editorSubstance()?.setCaretLine(line);
+    },
+    setScrollTopByLine: (line: number) => {
+      editorSubstance()?.setScrollTopByLine(line);
+    },
+    insertText: (text: string) => {
+      editorSubstance()?.insertText(text);
+    },
     /**
-   * remove overlay and set isUploading to false
-   */
+     * remove overlay and set isUploading to false
+     */
     terminateUploadingState: () => {
       setDropzoneActive(false);
       setIsUploading(false);
@@ -239,14 +223,14 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
     return (
       <div className="m-0 navbar navbar-default navbar-editor" style={{ minHeight: 'unset' }}>
         <ul className="pl-2 nav nav-navbar">
-          { methods.getNavbarItems?.().map((item, idx) => {
+          { (editorSubstance()?.getNavbarItems() ?? []).map((item, idx) => {
             // eslint-disable-next-line react/no-array-index-key
             return <li key={`navbarItem-${idx}`}>{item}</li>;
           }) }
         </ul>
       </div>
     );
-  }, [methods]);
+  }, [editorSubstance]);
 
   const renderCheatsheetModal = useCallback(() => {
     const hideCheatsheetModal = () => {
@@ -355,8 +339,6 @@ const Editor = React.forwardRef((props: EditorPropsType, ref): JSX.Element => {
       </div>
     </>
   );
-});
-
-Editor.displayName = 'Editor';
+};
 
-export default Editor;
+export default forwardRef(Editor);

+ 2 - 2
packages/app/src/components/PageEditor/Preview.tsx

@@ -35,8 +35,8 @@ const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>):
         }
       }}
     >
-      { markdown != null && pagePath != null && (
-        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} pagePath={pagePath}></RevisionRenderer>
+      { markdown != null && (
+        <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown}></RevisionRenderer>
       ) }
     </div>
   );

+ 1 - 0
packages/app/src/components/PageEditor/_page-editor-inheritance.scss

@@ -0,0 +1 @@
+$navbar-editor-height: 30px;

+ 117 - 98
packages/app/src/components/PageEditorByHackmd.tsx

@@ -1,47 +1,54 @@
 import React, {
-  useCallback, useEffect, useRef, useState,
+  useCallback, useRef, useState, useEffect,
 } from 'react';
 
+import EventEmitter from 'events';
+
 import { useTranslation } from 'react-i18next';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+import { saveOrUpdate } from '~/client/services/page-operation';
+import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
-import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
+import {
+  useCurrentPagePath, useCurrentPageId, useHackmdUri, usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced,
+} from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
 import {
+  EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
-import { withUnstatedContainers } from './UnstatedUtils';
 
 const logger = loggerFactory('growi:PageEditorByHackmd');
 
-type PageEditorByHackmdProps = {
-  appContainer: AppContainer,
-  pageContainer: PageContainer,
-};
+declare const globalEmitter: EventEmitter;
 
 type HackEditorRef = {
-  getValue: () => string
+  getValue: () => Promise<string>
 };
 
-const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
-  const { appContainer, pageContainer } = props; // wip
+export const PageEditorByHackmd = (): JSX.Element => {
 
   const { t } = useTranslation();
-  const { data: editorMode } = useEditorMode();
+  const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPathname } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
   const { data: grant } = useSelectedGrant();
+  const { data: hackmdUri } = useHackmdUri();
+
+  // pageData
+  const { data: pageData, mutate: updatePageData } = useSWRxCurrentPage();
+  const revision = pageData?.revision;
 
   const slackChannels = slackChannelsData?.toString();
 
@@ -52,41 +59,61 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
   const [errorMessage, setErrorMessage] = useState('');
   const [errorReason, setErrorReason] = useState('');
 
+  // state from pageContainer
+  const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
+  const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
+  const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
+  const [remoteRevisionId, setRemoteRevisionId] = useState(revision?._id); // initialize
+
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
+  const saveAndReturnToViewHandler = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}) => {
+    if (editorMode !== EditorMode.HackMD) {
+      return;
+    }
+
+    if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+      return;
+    }
+
+    let optionsToSave;
+
+    const currentOptionsToSave = getOptionsToSave(
+      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+    );
+
+    if (opts != null) {
+      optionsToSave = Object.assign(currentOptionsToSave, {
+        ...opts,
+      });
+    }
+    else {
+      optionsToSave = currentOptionsToSave;
+    }
+
+    const markdown = await hackmdEditorRef.current.getValue();
+
+    await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, markdown);
+    await updatePageData();
+    mutateEditorMode(EditorMode.View);
+  }, [currentPagePath, currentPathname, editorMode, grant, isSlackEnabled, pageId, pageTags, revision, slackChannels, mutateEditorMode, updatePageData]);
+
+  // set handler to save and reload Page
   useEffect(() => {
-    const pageEditorByHackmdInstance = {
-      getMarkdown: () => {
-        if (!isInitialized) {
-          return Promise.reject(new Error(t('hackmd.not_initialized')));
-        }
-
-        if (hackmdEditorRef.current == null) { return }
-
-        return hackmdEditorRef.current.getValue();
-      },
-      reset: () => {
-        setIsInitialized(false);
-      },
-    };
-    // appContainer.registerComponentInstance('PageEditorByHackmd', pageEditorByHackmdInstance);
-  }, [appContainer, isInitialized, t]);
+    globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
 
-  const getHackmdUri = useCallback(() => {
-    const envVars = appContainer.getConfig().env;
-    return envVars.HACKMD_URI;
-  }, [appContainer]);
+    return function cleanup() {
+      globalEmitter.removeListener('saveAndReturnToView', saveAndReturnToViewHandler);
+    };
+  }, [saveAndReturnToViewHandler]);
 
   const isResume = useCallback(() => {
-    const {
-      pageIdOnHackmd, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
-    } = pageContainer.state;
     const isPageExistsOnHackmd = (pageIdOnHackmd != null);
     return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime;
-  }, [pageContainer.state]);
+  }, [hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime, pageIdOnHackmd]);
 
   const startToEdit = useCallback(async() => {
-    const hackmdUri = getHackmdUri();
 
     if (hackmdUri == null) {
       // do nothing
@@ -103,13 +130,11 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         throw new Error(res.error);
       }
 
-      await pageContainer.setState({
-        pageIdOnHackmd: res.pageIdOnHackmd,
-        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-      });
+      mutatePageIdOnHackmd(res.pageIdOnHackmd);
+      mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
     }
     catch (err) {
-      pageContainer.showErrorToastr(err);
+      toastError(err);
 
       setHasError(true);
       setErrorMessage('GROWI server failed to connect to HackMD.');
@@ -118,7 +143,7 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
 
     setIsInitialized(true);
     setIsInitializing(false);
-  }, [getHackmdUri, pageContainer, pageId]);
+  }, [pageId, hackmdUri, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced]);
 
   /**
    * Start to edit w/o any api request
@@ -128,7 +153,8 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
   }, []);
 
   const discardChanges = useCallback(async() => {
-    const { pageId } = pageContainer.state;
+
+    if (pageId == null) { return }
 
     try {
       const res = await apiPost<IResHackmdDiscard>('/hackmd.discard', { pageId });
@@ -137,89 +163,91 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         throw new Error(res.error);
       }
 
-      pageContainer.setState({
-        isHackmdDraftUpdatingInRealtime: false,
-        hasDraftOnHackmd: false,
-        pageIdOnHackmd: res.pageIdOnHackmd,
-        remoteRevisionId: res.revisionIdHackmdSynced,
-        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-      });
+      setIsHackmdDraftUpdatingInRealtime(false);
+      mutateHasDraftOnHackmd(false);
+      mutatePageIdOnHackmd(res.pageIdOnHackmd);
+      setRemoteRevisionId(res.revisionIdHackmdSynced);
+      mutateRevisionIdHackmdSynced(res.revisionIdHackmdSynced);
+
+
     }
     catch (err) {
       logger.error(err);
-      pageContainer.showErrorToastr(err);
+      toastError(err);
     }
-  }, [pageContainer]);
+  }, [setIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, pageId]);
 
   /**
    * save and update state of containers
    * @param {string} markdown
    */
   const onSaveWithShortcut = useCallback(async(markdown) => {
-    if (isSlackEnabled == null || grant == null || slackChannels == null) { return }
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? []);
+    if (
+      isSlackEnabled == null || grant == null || slackChannels == null || pageId == null || revisionIdHackmdSynced == null || currentPathname == null
+    ) { return }
+    const optionsToSave = getOptionsToSave(
+      isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? [], true,
+    );
 
     try {
-      // disable unsaved warning
-      // editorContainer.disableUnsavedWarning(); commentout because disableUnsavedWarning doesn't exitst
+      const res = await saveOrUpdate(optionsToSave, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, markdown);
 
-      // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
-      logger.debug('success to save');
+      // update pageData
+      updatePageData();
 
-      pageContainer.showSuccessToastr();
+      // set updated data
+      setRemoteRevisionId(res.revision._id);
+      mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
+      mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
+      updatePageTagsForEditors(res.tags);
 
-      updatePageTagsForEditors(tags);
+      // call reset
+      setIsInitialized(false);
+
+      logger.debug('success to save');
+
+      toastSuccess(t('successfully_saved_the_page'));
     }
     catch (error) {
       logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
+      toastError(error);
     }
-  }, [editorMode, grant, isSlackEnabled, pageContainer, pageTags, slackChannels, updatePageTagsForEditors]);
+  }, [
+    grant, isSlackEnabled, pageTags, slackChannels, updatePageTagsForEditors, pageId, currentPagePath, currentPathname,
+    revisionIdHackmdSynced, updatePageData, mutateHasDraftOnHackmd, mutateRevisionIdHackmdSynced, t,
+  ]);
 
   /**
    * onChange event of HackmdEditor handler
    */
   const hackmdEditorChangeHandler = useCallback(async(body) => {
-    const hackmdUri = getHackmdUri();
 
-    if (hackmdUri == null) {
+    if (hackmdUri == null || pageId == null) {
       // do nothing
       return;
     }
 
-    // do nothing if contents are same
-    if (pageContainer.state.markdown === body) {
+    if (revision?.body === body) {
       return;
     }
 
-    // enable unsaved warning
-    // editorContainer.enableUnsavedWarning(); commentout because enableUnsavedWarning doesn't exitst
-
-    const params = {
-      pageId: pageContainer.state.pageId,
-    };
     try {
-      await apiPost('/hackmd.saveOnHackmd', params);
+      await apiPost('/hackmd.saveOnHackmd', { pageId });
     }
     catch (err) {
       logger.error(err);
     }
-  }, [getHackmdUri, pageContainer.state.markdown, pageContainer.state.pageId]);
+  }, [pageId, revision?.body, hackmdUri]);
 
   const penpalErrorOccuredHandler = useCallback((error) => {
-    pageContainer.showErrorToastr(error);
+    toastError(error);
 
     setHasError(true);
     setErrorMessage(t('hackmd.fail_to_connect'));
     setErrorReason(error.toString());
-  }, [pageContainer, t]);
+  }, [t]);
 
   const renderPreInitContent = useCallback(() => {
-    const hackmdUri = getHackmdUri();
-    const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId, pageId,
-    } = pageContainer.state;
     const isPageNotFound = pageId == null;
 
     let content;
@@ -316,7 +344,7 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
      * Start to edit
      */
     else {
-      const isRevisionOutdated = revisionId !== remoteRevisionId;
+      const isRevisionOutdated = revision?._id !== remoteRevisionId;
 
       content = (
         <div>
@@ -342,30 +370,25 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
         {content}
       </div>
     );
-  }, [discardChanges, getHackmdUri, isInitializing, isResume, pageContainer.state, resumeToEdit, startToEdit, t]);
+  }, [discardChanges, isInitializing, isResume, resumeToEdit, startToEdit, t, hackmdUri, pageId, remoteRevisionId, revisionIdHackmdSynced, revision?._id]);
 
-  if (editorMode == null) {
-    return null;
+  if (editorMode == null || revision == null) {
+    return <></>;
   }
 
-  const hackmdUri = getHackmdUri();
-  const {
-    markdown, pageIdOnHackmd,
-  } = pageContainer.state;
-
   let content;
 
   // TODO: typescriptize
   // using any because ref cann't used between FC and class conponent with type safe
   const AnyEditor = HackmdEditor as any;
 
-  if (isInitialized) {
+  if (isInitialized && hackmdUri != null) {
     content = (
       <AnyEditor
         ref={hackmdEditorRef}
         hackmdUri={hackmdUri}
         pageIdOnHackmd={pageIdOnHackmd}
-        initializationMarkdown={isResume() ? null : markdown}
+        initializationMarkdown={isResume() ? null : revision.body}
         onChange={hackmdEditorChangeHandler}
         onSaveWithShortcut={(document) => {
           onSaveWithShortcut(document);
@@ -403,7 +426,3 @@ const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
   );
 
 };
-
-const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer]);
-
-export default PageEditorByHackmdWrapper;

+ 15 - 28
packages/app/src/components/PageList/BookmarkList.jsx → packages/app/src/components/PageList/BookmarkList.tsx

@@ -1,27 +1,27 @@
 import React, { useState, useCallback, useEffect } from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 
 import { toastError } from '~/client/util/apiNotification';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { MyBookmarkList } from '~/interfaces/bookmark-info';
 import loggerFactory from '~/utils/logger';
 
 import PaginationWrapper from '../PaginationWrapper';
 
-
-import PageListItemS from './PageListItemS';
-
+import { PageListItemS } from './PageListItemS';
 
 const logger = loggerFactory('growi:BookmarkList');
 
-const BookmarkList = (props) => {
-  const { t } = useTranslation();
+type BookmarkListProps = {
+  userId: string
+}
 
+export const BookmarkList = (props: BookmarkListProps): JSX.Element => {
   const { userId } = props;
 
-  const [pages, setPages] = useState([]);
-
+  const { t } = useTranslation();
+  const [pages, setPages] = useState<MyBookmarkList>([]);
   const [activePage, setActivePage] = useState(1);
   const [totalItemsCount, setTotalItemsCount] = useState(0);
   const [pagingLimit, setPagingLimit] = useState(10);
@@ -51,24 +51,18 @@ const BookmarkList = (props) => {
     getMyBookmarkList();
   }, [getMyBookmarkList]);
 
-  /**
-   * generate Elements of Page
-   *
-   * @param {any} pages Array of pages Model Obj
-   *
-   */
-  const generatePageList = pages.map(page => (
-    <li key={`my-bookmarks:${page._id}`} className="mt-4">
-      <PageListItemS page={page.page} />
-    </li>
-  ));
-
   return (
     <div className="bookmarks-list-container">
       {pages.length === 0 ? t('No bookmarks yet') : (
         <>
           <ul className="page-list-ul page-list-ul-flat mb-3">
-            {generatePageList}
+
+            {pages.map(page => (
+              <li key={`my-bookmarks:${page._id}`} className="mt-4">
+                <PageListItemS page={page.page} />
+              </li>
+            ))}
+
           </ul>
           <PaginationWrapper
             activePage={activePage}
@@ -82,11 +76,4 @@ const BookmarkList = (props) => {
       )}
     </div>
   );
-
 };
-
-BookmarkList.propTypes = {
-  userId: PropTypes.string.isRequired,
-};
-
-export default BookmarkList;

+ 0 - 39
packages/app/src/components/PageList/PageListItemS.jsx

@@ -1,39 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
-
-
-export default class PageListItemS extends React.Component {
-
-  render() {
-    const {
-      page, noLink,
-    } = this.props;
-
-    let pagePathElem = <PagePathLabel path={page.path} additionalClassNames={['mx-1']} />;
-    if (!noLink) {
-      pagePathElem = <a className="text-break" href={page.path}>{pagePathElem}</a>;
-    }
-
-    return (
-      <>
-        <UserPicture user={page.lastUpdateUser} noLink={noLink} />
-        {pagePathElem}
-        <span className="ml-2">
-          <PageListMeta page={page} />
-        </span>
-      </>
-    );
-  }
-
-}
-
-PageListItemS.propTypes = {
-  page: PropTypes.object.isRequired,
-  noLink: PropTypes.bool,
-};
-
-PageListItemS.defaultProps = {
-  noLink: false,
-};

+ 32 - 0
packages/app/src/components/PageList/PageListItemS.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
+import { IPageHasId } from '~/interfaces/page';
+
+
+type PageListItemSProps = {
+  page: IPageHasId,
+  noLink?: boolean,
+}
+
+export const PageListItemS = (props: PageListItemSProps): JSX.Element => {
+
+  const { page, noLink = false } = props;
+
+  let pagePathElement = <PagePathLabel path={page.path} additionalClassNames={['mx-1']} />;
+  if (!noLink) {
+    pagePathElement = <a className="text-break" href={page.path}>{pagePathElement}</a>;
+  }
+
+  return (
+    <>
+      <UserPicture user={page.lastUpdateUser} noLink={noLink} />
+      {pagePathElement}
+      <span className="ml-2">
+        <PageListMeta page={page} />
+      </span>
+    </>
+  );
+
+};

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

@@ -21,6 +21,7 @@ const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkProps): JS
   const {
     linkedPagePath, linkedPagePathByHtml, basePath, isInTrash,
   } = props;
+
   // render root element
   if (linkedPagePath.isRoot) {
     if (basePath != null) {

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

@@ -1,6 +1,6 @@
 import React, { FC } from 'react';
 
-import { DevidedPagePath } from '@growi/core';
+import { DevidedPagePath, pagePathUtils } from '@growi/core';
 import dynamic from 'next/dynamic';
 
 import { useIsNotFound } from '~/stores/context';
@@ -9,6 +9,7 @@ import LinkedPagePath from '../models/linked-page-path';
 
 import PagePathHierarchicalLink from './PagePathHierarchicalLink';
 
+const { isTrashPage } = pagePathUtils;
 
 type Props = {
   pagePath: string,
@@ -17,6 +18,8 @@ type Props = {
   isCompactMode?:boolean,
 }
 
+const CopyDropdown = dynamic(() => import('./Page/CopyDropdown'), { ssr: false });
+
 const PagePathNav: FC<Props> = (props: Props) => {
   const {
     pageId, pagePath, isSingleLineMode, isCompactMode,
@@ -25,7 +28,7 @@ const PagePathNav: FC<Props> = (props: Props) => {
 
   const { data: isNotFound } = useIsNotFound();
 
-  const CopyDropdown = dynamic(() => import('./Page/CopyDropdown'), { ssr: false });
+  const isInTrash = isTrashPage(pagePath);
 
   let formerLink;
   let latterLink;
@@ -33,14 +36,14 @@ const PagePathNav: FC<Props> = (props: Props) => {
   // one line
   if (dPagePath.isRoot || dPagePath.isFormerRoot || isSingleLineMode) {
     const linkedPagePath = new LinkedPagePath(pagePath);
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} />;
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePath} isInTrash={isInTrash} />;
   }
   // two line
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />;
-    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} />;
+    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />;
+    latterLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />;
   }
 
   const copyDropdownId = `copydropdown${isCompactMode ? '-subnav-compact' : ''}-${pageId}`;

Some files were not shown because too many files changed in this diff