Răsfoiți Sursa

Merge pull request #5743 from weseek/master

Release v5.0.4
Yuki Takei 4 ani în urmă
părinte
comite
6bd8441be5
81 a modificat fișierele cu 1402 adăugiri și 568 ștergeri
  1. 24 0
      .github/workflows/ci-app-prod.yml
  2. 12 1
      .github/workflows/ci-app.yml
  3. 8 0
      .github/workflows/ci-slackbot-proxy.yml
  4. 6 0
      .github/workflows/codeql-analysis.yml
  5. 3 10
      .github/workflows/release-rc.yml
  6. 4 11
      .github/workflows/release-slackbot-proxy.yml
  7. 4 12
      .github/workflows/release.yml
  8. 1 1
      lerna.json
  9. 1 1
      package.json
  10. 2 6
      packages/app/docker/Dockerfile
  11. 7 7
      packages/app/package.json
  12. 14 1
      packages/app/resource/locales/en_US/translation.json
  13. 15 2
      packages/app/resource/locales/ja_JP/translation.json
  14. 14 1
      packages/app/resource/locales/zh_CN/translation.json
  15. 41 38
      packages/app/src/client/app.jsx
  16. 10 6
      packages/app/src/client/nologin.jsx
  17. 9 7
      packages/app/src/client/services/ContextExtractor.tsx
  18. 8 7
      packages/app/src/client/util/GrowiRenderer.js
  19. 50 0
      packages/app/src/client/util/markdown-it/link-by-relative-path.ts
  20. 17 0
      packages/app/src/components/Common/CountBadge.tsx
  21. 14 4
      packages/app/src/components/InAppNotification/InAppNotificationElm.tsx
  22. 52 0
      packages/app/src/components/MaintenanceModeContent.tsx
  23. 7 5
      packages/app/src/components/Me/ProfileImageSettings.jsx
  24. 17 27
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  25. 16 14
      packages/app/src/components/Page/DisplaySwitcher.tsx
  26. 82 7
      packages/app/src/components/PrivateLegacyPages.tsx
  27. 3 3
      packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx
  28. 8 5
      packages/app/src/components/PutbackPageModal.jsx
  29. 73 0
      packages/app/src/components/Sidebar/InfiniteScroll.tsx
  30. 15 28
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  31. 18 12
      packages/app/src/components/Sidebar/RecentChanges.tsx
  32. 1 1
      packages/app/src/components/Sidebar/SidebarNav.tsx
  33. 54 16
      packages/app/src/components/Sidebar/Tag.tsx
  34. 32 15
      packages/app/src/components/TagCloudBox.tsx
  35. 76 0
      packages/app/src/components/TagList.tsx
  36. 59 0
      packages/app/src/components/TagPage.tsx
  37. 0 85
      packages/app/src/components/TagsList.tsx
  38. 48 0
      packages/app/src/interfaces/activity.ts
  39. 7 0
      packages/app/src/interfaces/errors/v5-conversion-error.ts
  40. 6 2
      packages/app/src/interfaces/tag.ts
  41. 6 8
      packages/app/src/server/models/activity.ts
  42. 12 0
      packages/app/src/server/models/errors.ts
  43. 6 5
      packages/app/src/server/models/in-app-notification.ts
  44. 0 2
      packages/app/src/server/models/obsolete-page.js
  45. 43 2
      packages/app/src/server/models/page.ts
  46. 3 4
      packages/app/src/server/models/subscription.ts
  47. 0 0
      packages/app/src/server/models/vo/search-error.ts
  48. 28 0
      packages/app/src/server/models/vo/v5-conversion-error.ts
  49. 7 2
      packages/app/src/server/routes/apiv3/index.js
  50. 16 0
      packages/app/src/server/routes/apiv3/logout.js
  51. 50 10
      packages/app/src/server/routes/apiv3/pages.js
  52. 10 8
      packages/app/src/server/routes/index.js
  53. 7 1
      packages/app/src/server/routes/page.js
  54. 1 1
      packages/app/src/server/routes/search.ts
  55. 9 7
      packages/app/src/server/service/comment.ts
  56. 126 29
      packages/app/src/server/service/page.ts
  57. 1 1
      packages/app/src/server/service/search.ts
  58. 0 52
      packages/app/src/server/util/activityDefine.ts
  59. 3 19
      packages/app/src/server/views/maintenance-mode.html
  60. 12 12
      packages/app/src/stores/modal.tsx
  61. 17 4
      packages/app/src/stores/page.tsx
  62. 1 3
      packages/app/src/stores/tag.tsx
  63. 2 2
      packages/app/src/styles/_page-tree.scss
  64. 12 15
      packages/app/src/styles/_page_list.scss
  65. 6 1
      packages/app/src/styles/theme/_apply-colors-dark.scss
  66. 6 1
      packages/app/src/styles/theme/_apply-colors-light.scss
  67. 3 2
      packages/app/test/cypress/integration/6-home/home.spec.ts
  68. 134 4
      packages/app/test/integration/models/v5.page.test.js
  69. 1 1
      packages/codemirror-textlint/package.json
  70. 1 1
      packages/core/package.json
  71. 1 1
      packages/plugin-attachment-refs/package.json
  72. 1 1
      packages/plugin-lsx/package.json
  73. 1 1
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  74. 2 2
      packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx
  75. 11 4
      packages/plugin-lsx/src/server/routes/lsx.js
  76. 1 1
      packages/plugin-pukiwiki-like-linker/package.json
  77. 1 1
      packages/slack/package.json
  78. 6 8
      packages/slackbot-proxy/docker/Dockerfile
  79. 2 2
      packages/slackbot-proxy/package.json
  80. 1 1
      packages/ui/package.json
  81. 14 14
      packages/ui/src/components/PagePath/PageListMeta.tsx

+ 24 - 0
.github/workflows/ci-app-prod.yml

@@ -4,10 +4,34 @@ on:
   push:
     branches:
       - master
+    paths:
+      - .github/workflows/ci-app-prod.yml
+      - .github/workflows/reusable-app-prod.yml
+      - .github/workflows/reusable-app-reg-suit.yml
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-**
   pull_request:
     branches:
         - master
     types: [opened, reopened, synchronize]
+    paths:
+      - .github/workflows/ci-app-prod.yml
+      - .github/workflows/reusable-app-prod.yml
+      - .github/workflows/reusable-app-reg-suit.yml
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-**
 
 jobs:
 

+ 12 - 1
.github/workflows/ci-app.yml

@@ -7,6 +7,17 @@ on:
       - rc/**
       - chore/**
       - support/prepare-v**
+    paths:
+      - .github/workflows/ci-app.yml
+      - .eslint*
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/app/**
+      - '!packages/app/docker/**'
+      - packages/core/**
+      - packages/slack/**
+      - packages/ui/**
+      - packages/plugin-*/**
 
 jobs:
   lint:
@@ -45,7 +56,7 @@ jobs:
           yarn lerna run lint --scope @growi/plugin-*
       - name: lerna run lint for app
         run: |
-          yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
+          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/ui
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master

+ 8 - 0
.github/workflows/ci-slackbot-proxy.yml

@@ -7,6 +7,14 @@ on:
       - rc/**
       - chore/**
       - support/prepare-v**
+    paths:
+      - .github/workflows/ci-slackbot-proxy.yml
+      - .eslint*
+      - tsconfig.base.json
+      - yarn.lock
+      - packages/slackbot-proxy/**
+      - '!packages/slackbot-proxy/docker/**'
+      - packages/slack/**
 
 jobs:
 

+ 6 - 0
.github/workflows/codeql-analysis.yml

@@ -14,9 +14,15 @@ name: "CodeQL"
 on:
   push:
     branches: [ master, dev/*, release/current ]
+    paths:
+      - .github/workflows/codeql-analysis.yml
+      - packages/**
   pull_request:
     # The branches below must be a subset of the branches above
     branches: [ master ]
+    paths:
+      - .github/workflows/codeql-analysis.yml
+      - packages/**
   schedule:
     - cron: '28 20 * * 6'
 

+ 3 - 10
.github/workflows/release-rc.yml

@@ -43,14 +43,6 @@ jobs:
     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v1
 
-    - name: Cache Docker layers
-      uses: actions/cache@v3
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.os }}-buildx-app-${{ github.sha }}
-        restore-keys: |
-          ${{ runner.os }}-buildx-app-
-
     - name: Build and push
       uses: docker/build-push-action@v2
       with:
@@ -58,8 +50,9 @@ jobs:
         file: ./packages/app/docker/Dockerfile
         platforms: linux/amd64
         push: true
-        cache-from: type=local,src=/tmp/.buildx-cache
-        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
     - name: Move cache

+ 4 - 11
.github/workflows/release-slackbot-proxy.yml

@@ -56,14 +56,6 @@ jobs:
     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v1
 
-    - name: Cache Docker layers
-      uses: actions/cache@v3
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.os }}-buildx-slackbot-proxy-${{ github.sha }}
-        restore-keys: |
-          ${{ runner.os }}-buildx-slackbot-proxy-
-
     - name: Build and push
       uses: docker/build-push-action@v2
       with:
@@ -71,8 +63,9 @@ jobs:
         file: ./packages/slackbot-proxy/docker/Dockerfile
         platforms: linux/amd64
         push: true
-        cache-from: type=local,src=/tmp/.buildx-cache
-        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
     - name: Move cache
@@ -127,7 +120,7 @@ jobs:
         workingDir: packages/slackbot-proxy
 
     - name: Commit
-      uses: github-actions-x/commit@v2.8
+      uses: github-actions-x/commit@v2.9
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}

+ 4 - 12
.github/workflows/release.yml

@@ -103,7 +103,7 @@ jobs:
       id: package-json
 
     - name: Commit
-      uses: github-actions-x/commit@v2.8
+      uses: github-actions-x/commit@v2.9
       with:
         github-token: ${{ secrets.GITHUB_TOKEN }}
         push-branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
@@ -169,15 +169,6 @@ jobs:
     - name: Set up Docker Buildx
       uses: docker/setup-buildx-action@v1
 
-    - name: Cache Docker layers
-      uses: actions/cache@v3
-      with:
-        path: /tmp/.buildx-cache
-        key: ${{ runner.os }}-buildx-app-${{ matrix.flavor }}-${{ github.sha }}
-        restore-keys: |
-          ${{ runner.os }}-buildx-app-${{ matrix.flavor }}-
-          ${{ runner.os }}-buildx-app-
-
     - name: Build and push
       uses: docker/build-push-action@v2
       with:
@@ -187,8 +178,9 @@ jobs:
         push: true
         build-args: |
           flavor=${{ matrix.flavor }}
-        cache-from: type=local,src=/tmp/.buildx-cache
-        cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new
+        builder: ${{ steps.buildx.outputs.name }}
+        cache-from: type=gha
+        cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
 
     - name: Move cache

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 2 - 6
packages/app/docker/Dockerfile

@@ -1,4 +1,4 @@
-# syntax = docker/dockerfile:1
+# syntax = docker/dockerfile:1.4
 
 ARG flavor=default
 
@@ -157,11 +157,7 @@ RUN tar -xf node_modules.tar
 RUN tar -xf packages.tar
 RUN rm node_modules.tar packages.tar
 
-USER root
-
-COPY packages/app/docker/docker-entrypoint.sh /
-RUN chmod 700 /docker-entrypoint.sh
-RUN chown node:node ${appDir}
+COPY --chown=node:node --chmod=700 packages/app/docker/docker-entrypoint.sh /
 
 WORKDIR ${appDir}/packages/app
 

+ 7 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.3",
-    "@growi/plugin-attachment-refs": "^5.0.3",
-    "@growi/plugin-lsx": "^5.0.3",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.3",
-    "@growi/slack": "^5.0.3",
+    "@growi/codemirror-textlint": "^5.0.4-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.4-RC.0",
+    "@growi/plugin-lsx": "^5.0.4-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.4-RC.0",
+    "@growi/slack": "^5.0.4-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.3",
+    "@growi/ui": "^5.0.4-RC.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",

+ 14 - 1
packages/app/resource/locales/en_US/translation.json

@@ -100,6 +100,7 @@
   "Connected": "Connected",
   "Show": "Show",
   "Hide": "Hide",
+  "Loading": "Loading...",
   "Disclose E-mail": "Disclose E-mail",
   "page exists": "this page already exists",
   "Error occurred": "Error occurred",
@@ -148,6 +149,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "popular_tags": "Popular tags",
   "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
@@ -394,7 +396,7 @@
   },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
-    "already_exists": "New page is already exists.",
+    "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
     "user_not_admin": "Only admin user can delete"
   },
@@ -640,6 +642,7 @@
   "private_legacy_pages": {
     "bulk_operation": "Bulk operation",
     "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "input_path_to_convert": "Input a path to convert pages",
     "alert_title": "Old v4 compatible format private pages exist.",
     "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
     "nopages_title": "Congratulations. Ready to use GROWI v5!",
@@ -651,6 +654,16 @@
       "convert_recursively_label": "Convert child pages recursively.",
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "button_label": "Convert"
+    },
+    "by_path_modal": {
+      "title": "Convert to new v5 compatible format",
+      "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
+      "button_label": "Convert",
+      "success": "Successfully requested conversion.",
+      "error": "Failed to request conversion.",
+      "error_grant_invalid": "Page permissions are incorrect. Please correct it and try again.",
+      "error_page_not_found": "Page not found.",
+      "error_duplicate_pages_found": "Multiple pages with the same path name were found. Please rename or delete and try again."
     }
   },
   "security_setting": {

+ 15 - 2
packages/app/resource/locales/ja_JP/translation.json

@@ -100,6 +100,7 @@
   "Connected": "接続されています",
   "Show": "公開",
   "Hide": "非公開",
+  "Loading": "読み込み中...",
   "Disclose E-mail": "メールアドレスの公開",
   "page exists": "このページはすでに存在しています",
   "Error occurred": "エラーが発生しました",
@@ -147,7 +148,8 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
-  "Check All tags": "全てのタグをチェックする",
+  "popular_tags": "人気のタグ",
+  "Check All tags": "全てのタグを見る",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",
@@ -393,7 +395,7 @@
   },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
-    "already_exists": "新しいページが既に存在しています。",
+    "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
     "user_not_admin": "権限のあるユーザーのみが削除できます"
   },
@@ -639,6 +641,7 @@
   "private_legacy_pages": {
     "bulk_operation": "一括操作",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
+    "input_path_to_convert": "パスを入力して変換",
     "alert_title": "古い v4 互換形式のプライベートページが存在します",
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
@@ -650,6 +653,16 @@
       "convert_recursively_label": "再起的に変換",
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "button_label": "変換"
+    },
+    "by_path_modal": {
+      "title": "新しい v5 互換形式への変換",
+      "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
+      "button_label": "変換",
+      "success": "正常に変換を開始しました",
+      "error": "変換を開始できませんでした",
+      "error_grant_invalid": "ページの権限が正しくありません。修正してから再度実行してください",
+      "error_page_not_found": "ページが見つかりませんでした",
+      "error_duplicate_pages_found": "同名のパスを持つページが複数見つかりました。リネームまたは削除してから再度実行してください"
     }
   },
   "security_setting": {

+ 14 - 1
packages/app/resource/locales/zh_CN/translation.json

@@ -107,6 +107,7 @@
 	"Connected": "Connected",
 	"Show": "显示",
 	"Hide": "隐藏",
+  "Loading": "加载...",
 	"Reset": "重置",
 	"Disclose E-mail": "显示邮箱",
 	"page exists": "页面已存在",
@@ -156,6 +157,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "popular_tags": "流行标签",
   "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
@@ -372,7 +374,7 @@
   },
 	"page_api_error": {
 		"notfound_or_forbidden": "未找到或禁止原始页。",
-		"already_exists": "新建页面已存在",
+		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
 		"user_not_admin": "仅管理员用户可以删除"
   },
@@ -926,6 +928,7 @@
   "private_legacy_pages": {
     "bulk_operation": "批量操作",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
+		"input_path_to_convert": "输入一个转换页面的路径",
     "alert_title": "存在旧的v4兼容格式的私人网页。",
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
@@ -937,6 +940,16 @@
       "convert_recursively_label": "递归地转换子页面。",
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "button_label": "转换"
+    },
+    "by_path_modal": {
+      "title": "转换为新的v5兼容格式",
+      "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
+      "button_label": "转换",
+      "success": "成功地请求转换。",
+      "error": "请求转换失败。",
+      "error_grant_invalid": "页面权限不正确。请更正并重试。",
+      "error_page_not_found": "没有找到页面。",
+      "error_duplicate_pages_found": "发现多个具有相同路径名称的页面。请重新命名或删除并重试。"
     }
   },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",

+ 41 - 38
packages/app/src/client/app.jsx

@@ -1,57 +1,58 @@
 import React from 'react';
-import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
-import { I18nextProvider } from 'react-i18next';
+
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
-
+import ReactDOM from 'react-dom';
+import { I18nextProvider } from 'react-i18next';
 import { SWRConfig } from 'swr';
+import { Provider } from 'unstated';
 
+import CommentContainer from '~/client/services/CommentContainer';
+import ContextExtractor from '~/client/services/ContextExtractor';
+import EditorContainer from '~/client/services/EditorContainer';
+import PageContainer from '~/client/services/PageContainer';
+import PageHistoryContainer from '~/client/services/PageHistoryContainer';
+import PersonalContainer from '~/client/services/PersonalContainer';
+import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
+import TagContainer from '~/client/services/TagContainer';
+import IdenticalPathPage from '~/components/IdenticalPathPage';
+import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
-import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import ErrorBoundary from '../components/ErrorBoudary';
-import Sidebar from '../components/Sidebar';
-import { SearchPage } from '../components/SearchPage';
-import TagsList from '../components/TagsList';
-import DisplaySwitcher from '../components/Page/DisplaySwitcher';
-import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
-import Page from '../components/Page';
-import PageContentFooter from '../components/PageContentFooter';
-import PageComment from '../components/PageComment';
-import PageTimeline from '../components/PageTimeline';
-import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
-import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import RedirectedAlert from '../components/Page/RedirectedAlert';
-import TrashPageList from '../components/TrashPageList';
-import TrashPageAlert from '../components/Page/TrashPageAlert';
-import NotFoundPage from '../components/NotFoundPage';
-import NotFoundAlert from '../components/Page/NotFoundAlert';
+import Fab from '../components/Fab';
 import ForbiddenPage from '../components/ForbiddenPage';
-import PageStatusAlert from '../components/PageStatusAlert';
-import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
-import MyDraftList from '../components/MyDraftList/MyDraftList';
-import BookmarkList from '../components/PageList/BookmarkList';
-import Fab from '../components/Fab';
+import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
+import MaintenanceModeContent from '../components/MaintenanceModeContent';
 import PersonalSettings from '../components/Me/PersonalSettings';
+import MyDraftList from '../components/MyDraftList/MyDraftList';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
-import IdenticalPathPage from '~/components/IdenticalPathPage';
-
-import ContextExtractor from '~/client/services/ContextExtractor';
-import PageContainer from '~/client/services/PageContainer';
-import PageHistoryContainer from '~/client/services/PageHistoryContainer';
-import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
-import CommentContainer from '~/client/services/CommentContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import TagContainer from '~/client/services/TagContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
+import NotFoundPage from '../components/NotFoundPage';
+import Page from '../components/Page';
+import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import NotFoundAlert from '../components/Page/NotFoundAlert';
+import RedirectedAlert from '../components/Page/RedirectedAlert';
+import ShareLinkAlert from '../components/Page/ShareLinkAlert';
+import TrashPageAlert from '../components/Page/TrashPageAlert';
+import PageComment from '../components/PageComment';
+import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
+import PageContentFooter from '../components/PageContentFooter';
+import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
+import BookmarkList from '../components/PageList/BookmarkList';
+import PageStatusAlert from '../components/PageStatusAlert';
+import PageTimeline from '../components/PageTimeline';
+import RecentCreated from '../components/RecentCreated/RecentCreated';
+import { SearchPage } from '../components/SearchPage';
+import Sidebar from '../components/Sidebar';
+import TagPage from '../components/TagPage';
+import TrashPageList from '../components/TrashPageList';
 
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
-import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
+
 
 const logger = loggerFactory('growi:cli:app');
 
@@ -90,10 +91,12 @@ Object.assign(componentMappings, {
   'identical-path-page': <IdenticalPathPage />,
 
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'tags-page': <TagsList crowi={appContainer} />,
+  'tags-page': <TagPage />,
 
   'grw-page-status-alert-container': <PageStatusAlert />,
 
+  'maintenance-mode-content': <MaintenanceModeContent />,
+
   'trash-page-alert': <TrashPageAlert />,
 
   'trash-page-list-container': <TrashPageList />,

+ 10 - 6
packages/app/src/client/nologin.jsx

@@ -1,17 +1,19 @@
 import React from 'react';
+
 import ReactDOM from 'react-dom';
-import { Provider } from 'unstated';
 import { I18nextProvider } from 'react-i18next';
+import { Provider } from 'unstated';
 
-import { i18nFactory } from './util/i18n';
 
 import AppContainer from '~/client/services/AppContainer';
+import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
 
 import InstallerForm from '../components/InstallerForm';
 import LoginForm from '../components/LoginForm';
-import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
 import PasswordResetExecutionForm from '../components/PasswordResetExecutionForm';
-import CompleteUserRegistrationForm from '~/components/CompleteUserRegistrationForm';
+import PasswordResetRequestForm from '../components/PasswordResetRequestForm';
+
+import { i18nFactory } from './util/i18n';
 
 const i18n = i18nFactory();
 
@@ -85,10 +87,12 @@ if (loginFormElem) {
   );
 }
 
-// render PasswordResetRequestForm
-const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 const appContainer = new AppContainer();
 appContainer.initApp();
+
+
+// render PasswordResetRequestForm
+const passwordResetRequestFormElem = document.getElementById('password-reset-request-form');
 if (passwordResetRequestFormElem) {
 
   ReactDOM.render(

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

@@ -1,6 +1,15 @@
 import React, { FC, useEffect, useState } from 'react';
+
 import { pagePathUtils } from '@growi/core';
 
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import {
+  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+} from '~/stores/ui';
+import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
+
 import {
   useSiteUrl,
   useCurrentCreatedAt, useDeleteUsername, useDeletedAt, useHasChildren, useHasDraftOnHackmd,
@@ -10,13 +19,6 @@ import {
   useSlackChannels, useNotFoundTargetPathOrId, useIsSearchPage, useIsForbidden, useIsIdenticalPath,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader, useIsNotFoundPermalink,
 } from '../../stores/context';
-import {
-  useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
-  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
-  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
-} from '~/stores/ui';
-import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
-import { IUserUISettings } from '~/interfaces/user-ui-settings';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
 

+ 8 - 7
packages/app/src/client/util/GrowiRenderer.js

@@ -2,24 +2,24 @@ import MarkdownIt from 'markdown-it';
 
 import loggerFactory from '~/utils/logger';
 
-import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
 import EasyGrid from './PreProcessor/EasyGrid';
+import Linker from './PreProcessor/Linker';
 import XssFilter from './PreProcessor/XssFilter';
-
+import BlockdiagConfigurer from './markdown-it/blockdiag';
+import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
 import EmojiConfigurer from './markdown-it/emoji';
 import FooternoteConfigurer from './markdown-it/footernote';
-import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
 import HeaderConfigurer from './markdown-it/header';
+import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
+import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
+import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
 import MathJaxConfigurer from './markdown-it/mathjax';
 import PlantUMLConfigurer from './markdown-it/plantuml';
 import TableConfigurer from './markdown-it/table';
+import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
 import TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
-import BlockdiagConfigurer from './markdown-it/blockdiag';
-import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
-import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
-import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
 
 const logger = loggerFactory('growi:util:GrowiRenderer');
 
@@ -68,6 +68,7 @@ export default class GrowiRenderer {
     this.isMarkdownItConfigured = false;
 
     this.markdownItConfigurers = [
+      new LinkerByRelativePathConfigurer(appContainer),
       new TaskListsConfigurer(appContainer),
       new HeaderConfigurer(appContainer),
       new EmojiConfigurer(appContainer),

+ 50 - 0
packages/app/src/client/util/markdown-it/link-by-relative-path.ts

@@ -0,0 +1,50 @@
+import path from 'path';
+
+// https://regex101.com/r/vV8LUe/1
+const PATTERN_RELATIVE_PATH = new RegExp(/^(\.{1,2})(\/.*)?$/);
+
+export default class LinkerByRelativePathConfigurer {
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  appContainer: any;
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  constructor(appContainer) {
+    this.appContainer = appContainer;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  configure(md): void {
+    const pageContainer = this.appContainer.getContainer('PageContainer');
+
+    // Remember old renderer, if overridden, or proxy to default renderer
+    const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
+      return self.renderToken(tokens, idx, options);
+    };
+
+    md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
+      if (tokens[idx] == null || (typeof tokens[idx].attrIndex !== 'function')) {
+        return defaultRender(tokens, idx, options, env, self);
+      }
+
+      // get href
+      const hrefIndex = tokens[idx].attrIndex('href');
+
+      if (hrefIndex != null && hrefIndex >= 0) {
+        const href: string = tokens[idx].attrs[hrefIndex][1];
+        const currentPath: string | null = pageContainer?.state.path;
+
+        // resolve relative path and replace
+        if (PATTERN_RELATIVE_PATH.test(href) && currentPath != null) {
+          const newHref = path.resolve(path.dirname(currentPath), href);
+          tokens[idx].attrs[hrefIndex][1] = newHref;
+        }
+      }
+
+      // pass token to default renderer.
+      return defaultRender(tokens, idx, options, env, self);
+    };
+
+  }
+
+}

+ 17 - 0
packages/app/src/components/Common/CountBadge.tsx

@@ -0,0 +1,17 @@
+import React, { FC } from 'react';
+
+type CountProps = {
+  count: number
+}
+
+const CountBadge: FC<CountProps> = (props:CountProps) => {
+  return (
+    <>
+      <span className="grw-count-badge px-2 badge badge-pill badge-light">
+        {props.count}
+      </span>
+    </>
+  );
+};
+
+export default CountBadge;

+ 14 - 4
packages/app/src/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,16 +1,18 @@
 import React, {
   FC, useRef,
 } from 'react';
-import { DropdownItem } from 'reactstrap';
 
 import { UserPicture } from '@growi/ui';
-import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { DropdownItem } from 'reactstrap';
+
+import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
+import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 // Change the display for each targetmodel
 import PageModelNotification from './PageNotification/PageModelNotification';
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
-import { apiv3Post } from '~/client/util/apiv3-client';
+
 
 interface Props {
   notification: IInAppNotification & HasObjectId
@@ -101,6 +103,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'renamed';
       actionIcon = 'icon-action-redo';
       break;
+    case 'PAGE_DUPLICATE':
+      actionMsg = 'duplicated';
+      actionIcon = 'icon-docs';
+      break;
     case 'PAGE_DELETE':
       actionMsg = 'deleted';
       actionIcon = 'icon-trash';
@@ -109,6 +115,10 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       actionMsg = 'completely deleted';
       actionIcon = 'icon-fire';
       break;
+    case 'PAGE_REVERT':
+      actionMsg = 'reverted';
+      actionIcon = 'icon-action-undo';
+      break;
     case 'COMMENT_CREATE':
       actionMsg = 'commented on';
       actionIcon = 'icon-bubble';

+ 52 - 0
packages/app/src/components/MaintenanceModeContent.tsx

@@ -0,0 +1,52 @@
+import React from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { useCurrentUser } from '~/stores/context';
+
+
+const MaintenanceModeContent = () => {
+  const { t } = useTranslation();
+
+  const { data: currentUser } = useCurrentUser();
+
+  const logoutHandler = async() => {
+    try {
+      await apiv3Post('/logout');
+      window.location.reload();
+    }
+    catch (err) {
+      toastError(err);
+    }
+
+  };
+
+  return (
+    <div className="text-left">
+      <p>
+        <i className="icon-arrow-right"></i>
+        <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
+      </p>
+      {currentUser != null
+        ? (
+          <p>
+            <i className="icon-arrow-right"></i>
+            <a className="btn btn-link" onClick={logoutHandler} id="maintanounse-mode-logout">{ t('maintenance_mode.logout') }</a>
+          </p>
+        )
+        : (
+          <p>
+            <i className="icon-arrow-right"></i>
+            <a className="btn btn-link" href="/login">{ t('maintenance_mode.login') }</a>
+          </p>
+        )
+      }
+    </div>
+  );
+
+};
+
+
+export default MaintenanceModeContent;

+ 7 - 5
packages/app/src/components/Me/ProfileImageSettings.jsx

@@ -1,13 +1,15 @@
 import React from 'react';
+
+import md5 from 'md5';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
-import md5 from 'md5';
 
+import AppContainer from '~/client/services/AppContainer';
+import PersonalContainer from '~/client/services/PersonalContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
 import { withUnstatedContainers } from '../UnstatedUtils';
 
-import AppContainer from '~/client/services/AppContainer';
-import PersonalContainer from '~/client/services/PersonalContainer';
 
 import ImageCropModal from './ImageCropModal';
 
@@ -115,14 +117,14 @@ class ProfileImageSettings extends React.Component {
                   onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
                 />
                 <label className="custom-control-label" htmlFor="radioGravatar">
-                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" /> Gravatar
+                  <img src="https://gravatar.com/avatar/00000000000000000000000000000000?s=24" data-hide-in-vrt /> Gravatar
                 </label>
                 <a href="https://gravatar.com/">
                   <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
                 </a>
               </div>
             </h4>
-            <img src={this.generateGravatarSrc()} width="64" />
+            <img src={this.generateGravatarSrc()} width="64" data-hide-in-vrt />
           </div>
 
           <div className="col-md-6 col-12">

+ 17 - 27
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,13 +1,12 @@
 import React, { useState, useCallback } from 'react';
 
 import { UserPicture } from '@growi/ui';
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
+import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
-
-import AppContainer from '~/client/services/AppContainer';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
 import {
   isUserPreferenceExists,
   isDarkMode as isDarkModeByUtil,
@@ -16,6 +15,7 @@ import {
   updateUserPreference,
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
+import { useCurrentUser } from '~/stores/context';
 import { usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser } from '~/stores/ui';
 
 
@@ -23,13 +23,13 @@ import MoonIcon from '../Icons/MoonIcon';
 import SidebarDockIcon from '../Icons/SidebarDockIcon';
 import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SunIcon from '../Icons/SunIcon';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
-const PersonalDropdown = (props) => {
+const PersonalDropdown = () => {
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
 
-  const { t, appContainer } = props;
-  const user = appContainer.currentUser || {};
+  const user = currentUser || {};
 
   const [useOsSettings, setOsSettings] = useState(!isUserPreferenceExists());
   const [isDarkMode, setIsDarkMode] = useState(isDarkModeByUtil());
@@ -38,13 +38,14 @@ const PersonalDropdown = (props) => {
   const { data: isPreferDrawerModeOnEdit, mutate: mutatePreferDrawerModeOnEdit } = usePreferDrawerModeOnEditByUser();
   const { scheduleToPut } = useUserUISettings();
 
-  const logoutHandler = () => {
-    const { interceptorManager } = appContainer;
-
-    const context = {};
-    interceptorManager.process('logout', context);
-
-    window.location.href = '/logout';
+  const logoutHandler = async() => {
+    try {
+      await apiv3Post('/logout');
+      window.location.reload();
+    }
+    catch (err) {
+      toastError(err);
+    }
   };
 
   const preferDrawerModeSwitchModifiedHandler = useCallback((bool) => {
@@ -229,15 +230,4 @@ const PersonalDropdown = (props) => {
 
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PersonalDropdownWrapper = withUnstatedContainers(PersonalDropdown, [AppContainer]);
-
-
-PersonalDropdown.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-};
-
-export default withTranslation()(PersonalDropdownWrapper);
+export default PersonalDropdown;

+ 16 - 14
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,27 +1,28 @@
 import React, { useMemo } from 'react';
+
+import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { TabContent, TabPane } from 'reactstrap';
 
-import { pagePathUtils } from '@growi/core';
 
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import { useDescendantsPageListModal } from '~/stores/modal';
+import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
 import {
   useCurrentPagePath, useIsSharedUser, useIsEditable, useCurrentPageId, useIsUserPage, usePageUser,
 } from '~/stores/context';
+import { useDescendantsPageListModal } from '~/stores/modal';
+import { useSWRxPageByPath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
 
-
-import { smoothScrollIntoView } from '~/client/util/smooth-scroll';
-
+import CountBadge from '../Common/CountBadge';
+import ContentLinkButtons from '../ContentLinkButtons';
+import HashChanged from '../EventListeneres/HashChanged';
 import PageListIcon from '../Icons/PageListIcon';
-import Editor from '../PageEditor';
 import Page from '../Page';
-import UserInfo from '../User/UserInfo';
-import TableOfContents from '../TableOfContents';
-import ContentLinkButtons from '../ContentLinkButtons';
-import PageEditorByHackmd from '../PageEditorByHackmd';
+import Editor from '../PageEditor';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
-import HashChanged from '../EventListeneres/HashChanged';
+import PageEditorByHackmd from '../PageEditorByHackmd';
+import TableOfContents from '../TableOfContents';
+import UserInfo from '../User/UserInfo';
 
 
 const WIKI_HEADER_LINK = 120;
@@ -43,6 +44,7 @@ const DisplaySwitcher = (): JSX.Element => {
   const { data: isUserPage } = useIsUserPage();
   const { data: isEditable } = useIsEditable();
   const { data: pageUser } = usePageUser();
+  const { data: currentPage } = useSWRxPageByPath(currentPath);
 
   const { data: editorMode } = useEditorMode();
 
@@ -74,7 +76,7 @@ const DisplaySwitcher = (): JSX.Element => {
                           <PageListIcon />
                         </div>
                         {t('page_list')}
-                        <span></span> {/* for a count badge */}
+                        {currentPage?.descendantCount != null && <CountBadge count={currentPage.descendantCount + 1} />}
                       </button>
                     ) }
                   </div>
@@ -89,7 +91,7 @@ const DisplaySwitcher = (): JSX.Element => {
                       >
                         <i className="icon-fw icon-bubbles grw-page-accessories-control-icon"></i>
                         <span>Comments</span>
-                        <span></span> {/* for a count badge */}
+                        {currentPage?.commentCount != null && <CountBadge count={currentPage.commentCount} />}
                       </button>
                     </div>
                   ) }

+ 82 - 7
packages/app/src/components/PrivateLegacyPages.tsx

@@ -4,18 +4,18 @@ import React, {
 import { useTranslation } from 'react-i18next';
 
 import {
-  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import { IFormattedSearchResult } from '~/interfaces/search';
 import AppContainer from '~/client/services/AppContainer';
 import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import { toastSuccess } from '~/client/util/apiNotification';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import {
   useSWRxSearch,
 } from '~/stores/search';
 import {
-  ILegacyPrivatePage, useLegacyPrivatePagesMigrationModal,
+  ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
 } from '~/stores/modal';
 
 import PaginationWrapper from './PaginationWrapper';
@@ -23,10 +23,12 @@ import { OperateAllControl } from './SearchPage/OperateAllControl';
 
 import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage2/SearchPageBase';
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
-import { LegacyPrivatePagesMigrationModal } from './LegacyPrivatePagesMigrationModal';
+import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import SearchControl from './SearchPage/SearchControl';
 import { useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import { V5MigrationStatus } from '~/interfaces/page-listing-results';
+import { apiv3Post } from '~/client/util/apiv3-client';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 
 
 // TODO: replace with "customize:showPageLimitationS"
@@ -124,6 +126,38 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   );
 });
 
+/*
+ * ConvertByPathModal
+ */
+type ConvertByPathModalProps = {
+  isOpen: boolean,
+  close?: () => void,
+  onSubmit?: (convertPath: string) => Promise<void> | void,
+}
+const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [currentInput, setInput] = useState<string>('');
+
+  return (
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={props.close} className="bg-primary text-light">
+        { t('private_legacy_pages.by_path_modal.title') }
+      </ModalHeader>
+      <ModalBody>
+        <p>{t('private_legacy_pages.by_path_modal.description')}</p>
+        <input type="text" className="form-control" placeholder="/" value={currentInput} onChange={e => setInput(e.target.value)} />
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-primary" onClick={() => props.onSubmit?.(currentInput)}>
+          <i className="icon-fw icon-refresh" aria-hidden="true"></i>
+          { t('private_legacy_pages.by_path_modal.button_label') }
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+});
+
 
 /**
  * LegacyPage
@@ -133,7 +167,7 @@ type Props = {
   appContainer: AppContainer,
 }
 
-export const PrivateLegacyPages = (props: Props): JSX.Element => {
+const PrivateLegacyPages = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
@@ -144,6 +178,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
   const [keyword, setKeyword] = useState<string>(initQ);
   const [offset, setOffset] = useState<number>(0);
   const [limit, setLimit] = useState<number>(INITIAL_PAGING_SIZE);
+  const [isOpenConvertModal, setOpenConvertModal] = useState<boolean>(false);
 
   const [isControlEnabled, setControlEnabled] = useState(false);
 
@@ -165,7 +200,7 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
     setOffset(0);
   }, []);
 
-  const { open: openModal, close: closeModal } = useLegacyPrivatePagesMigrationModal();
+  const { open: openModal, close: closeModal } = usePrivateLegacyPagesMigrationModal();
 
   const selectAllCheckboxChangedHandler = useCallback((isChecked: boolean) => {
     const instance = searchPageBaseRef.current;
@@ -282,6 +317,11 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
             </UncontrolledButtonDropdown>
           </OperateAllControl>
         </div>
+        <div className="d-flex pl-md-2">
+          <button type="button" className="btn btn-light" onClick={() => setOpenConvertModal(true)}>
+            {t('private_legacy_pages.input_path_to_convert')}
+          </button>
+        </div>
       </div>
     );
   }, [convertMenuItemClickedHandler, deleteAllButtonClickedHandler, hitsCount, isControlEnabled, selectAllCheckboxChangedHandler, t]);
@@ -347,7 +387,42 @@ export const PrivateLegacyPages = (props: Props): JSX.Element => {
         searchPager={searchPager}
       />
 
-      <LegacyPrivatePagesMigrationModal />
+      <PrivateLegacyPagesMigrationModal />
+      <ConvertByPathModal
+        isOpen={isOpenConvertModal}
+        close={() => setOpenConvertModal(false)}
+        onSubmit={async(convertPath: string) => {
+          try {
+            await apiv3Post<void>('/pages/legacy-pages-migration', {
+              convertPath,
+            });
+            toastSuccess(t('private_legacy_pages.by_path_modal.success'));
+            setOpenConvertModal(false);
+          }
+          catch (errs) {
+            if (errs.length === 1) {
+              switch (errs[0].code) {
+                case V5ConversionErrCode.GRANT_INVALID:
+                  toastError(t('private_legacy_pages.by_path_modal.error_grant_invalid'));
+                  break;
+                case V5ConversionErrCode.PAGE_NOT_FOUND:
+                  toastError(t('private_legacy_pages.by_path_modal.error_page_not_found'));
+                  break;
+                case V5ConversionErrCode.DUPLICATE_PAGES_FOUND:
+                  toastError(t('private_legacy_pages.by_path_modal.error_duplicate_pages_found'));
+                  break;
+                default:
+                  toastError(t('private_legacy_pages.by_path_modal.error'));
+              }
+            }
+            else {
+              toastError(t('private_legacy_pages.by_path_modal.error'));
+            }
+          }
+        }}
+      />
     </>
   );
 };
+
+export default PrivateLegacyPages;

+ 3 - 3
packages/app/src/components/LegacyPrivatePagesMigrationModal.tsx → packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx

@@ -5,7 +5,7 @@ import {
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { useLegacyPrivatePagesMigrationModal } from '~/stores/modal';
+import { usePrivateLegacyPagesMigrationModal } from '~/stores/modal';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -14,10 +14,10 @@ type Props = {
 
 }
 
-export const LegacyPrivatePagesMigrationModal = (props: Props): JSX.Element => {
+export const PrivateLegacyPagesMigrationModal = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
-  const { data: status, close } = useLegacyPrivatePagesMigrationModal();
+  const { data: status, close } = usePrivateLegacyPagesMigrationModal();
 
   const isOpened = status?.isOpened ?? false;
 

+ 8 - 5
packages/app/src/components/PutbackPageModal.jsx

@@ -1,13 +1,14 @@
 import React, { useState } from 'react';
 
+
+import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { useTranslation } from 'react-i18next';
-
-import { usePutBackPageModal } from '~/stores/modal';
 import { apiPost } from '~/client/util/apiv1-client';
+import { PathAlreadyExistsError } from '~/server/models/errors';
+import { usePutBackPageModal } from '~/stores/modal';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
@@ -20,6 +21,7 @@ const PutBackPageModal = () => {
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
   const [errs, setErrs] = useState(null);
+  const [targetPath, setTargetPath] = useState(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
 
@@ -46,7 +48,8 @@ const PutBackPageModal = () => {
       closePutBackPageModal();
     }
     catch (err) {
-      setErrs(err);
+      setTargetPath(err.data);
+      setErrs([err]);
     }
   }
 
@@ -78,7 +81,7 @@ const PutBackPageModal = () => {
         </div>
       </ModalBody>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
+        <ApiErrorMessageList errs={errs} targetPath={targetPath} />
         <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
         </button>

+ 73 - 0
packages/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -0,0 +1,73 @@
+import React, {
+  Ref, useEffect, useState,
+} from 'react';
+import type { SWRInfiniteResponse } from 'swr/infinite';
+
+type Props<T> = {
+  swrInifiniteResponse : SWRInfiniteResponse<T>
+  children: React.ReactChild | ((item: T) => React.ReactNode),
+  loadingIndicator?: React.ReactNode
+  endingIndicator?: React.ReactNode
+  isReachingEnd?: boolean,
+  offset?: number
+}
+
+const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
+  const [intersecting, setIntersecting] = useState<boolean>(false);
+  const [element, setElement] = useState<HTMLElement>();
+  useEffect(() => {
+    if (element != null) {
+      const observer = new IntersectionObserver((entries) => {
+        setIntersecting(entries[0]?.isIntersecting);
+      });
+      observer.observe(element);
+      return () => observer.unobserve(element);
+    }
+    return;
+  }, [element]);
+  return [intersecting, el => el && setElement(el)];
+};
+
+const LoadingIndicator = (): React.ReactElement => {
+  return (
+    <div className="text-muted text-center">
+      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
+    </div>
+  );
+};
+
+const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
+  const {
+    swrInifiniteResponse: {
+      setSize, data, isValidating,
+    },
+    children,
+    loadingIndicator,
+    endingIndicator,
+    isReachingEnd,
+    offset = 0,
+  } = props;
+
+  const [intersecting, ref] = useIntersection<HTMLDivElement>();
+
+  useEffect(() => {
+    if (intersecting && !isValidating && !isReachingEnd) {
+      setSize(size => size + 1);
+    }
+  }, [setSize, intersecting]);
+
+  return (
+    <>
+      {typeof children === 'function' ? data?.map(item => children(item)) : children}
+      <div style={{ position: 'relative' }}>
+        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
+        {isReachingEnd
+          ? endingIndicator
+          : loadingIndicator || <LoadingIndicator />
+        }
+      </div>
+    </>
+  );
+};
+
+export default InfiniteScroll;

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

@@ -1,32 +1,33 @@
 import React, {
   useCallback, useState, FC, useEffect,
 } from 'react';
-import { DropdownToggle } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
-
-import { useDrag, useDrop } from 'react-dnd';
 
 import nodePath from 'path';
 
 import { pathUtils, pagePathUtils } from '@growi/core';
+import { useDrag, useDrop } from 'react-dnd';
+import { useTranslation } from 'react-i18next';
+import { DropdownToggle } from 'reactstrap';
 
-import loggerFactory from '~/utils/logger';
 
+import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
-
-import { useSWRxPageChildren } from '~/stores/page-listing';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import TriangleIcon from '~/components/Icons/TriangleIcon';
+import {
+  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
+} from '~/interfaces/page';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useSWRxPageChildren } from '~/stores/page-listing';
+import { usePageTreeDescCountMap } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
 
-import TriangleIcon from '~/components/Icons/TriangleIcon';
-import { bookmark, unbookmark } from '~/client/services/page-operation';
 import ClosableTextInput, { AlertInfo, AlertType } from '../../Common/ClosableTextInput';
+import CountBadge from '../../Common/CountBadge';
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
+
 import { ItemNode } from './ItemNode';
-import { usePageTreeDescCountMap } from '~/stores/ui';
-import {
-  IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
-} from '~/interfaces/page';
 
 
 const logger = loggerFactory('growi:cli:Item');
@@ -94,20 +95,6 @@ const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPa
 };
 
 
-type ItemCountProps = {
-  descendantCount: number
-}
-
-const ItemCount: FC<ItemCountProps> = (props:ItemCountProps) => {
-  return (
-    <>
-      <span className="grw-pagetree-count badge badge-pill badge-light">
-        {props.descendantCount}
-      </span>
-    </>
-  );
-};
-
 const Item: FC<ItemProps> = (props: ItemProps) => {
   const { t } = useTranslation();
   const {
@@ -465,7 +452,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
           )}
         {descendantCount > 0 && !isRenameInputShown && (
           <div className="grw-pagetree-count-wrapper">
-            <ItemCount descendantCount={descendantCount} />
+            <CountBadge count={descendantCount} />
           </div>
         )}
         <div className="grw-pagetree-control d-flex">

+ 18 - 12
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -10,11 +10,11 @@ import { UserPicture, FootstampIcon } from '@growi/ui';
 import { DevidedPagePath } from '@growi/core';
 
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import { useSWRxRecentlyUpdated } from '~/stores/page';
+import { useSWRInifinitexRecentlyUpdated } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import LinkedPagePath from '~/models/linked-page-path';
-
+import InfiniteScroll from './InfiniteScroll';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 
@@ -120,14 +120,13 @@ SmallPageItem.propTypes = {
   page: PropTypes.any,
 };
 
-
 const RecentChanges = (): JSX.Element => {
-
+  const PER_PAGE = 20;
   const { t } = useTranslation();
-  const { data: pages, mutate } = useSWRxRecentlyUpdated();
-
+  const swr = useSWRInifinitexRecentlyUpdated();
   const [isRecentChangesSidebarSmall, setIsRecentChangesSidebarSmall] = useState(false);
-
+  const isEmpty = swr.data?.[0].length === 0;
+  const isReachingEnd = isEmpty || (swr.data && swr.data[swr.data.length - 1]?.length < PER_PAGE);
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
       setIsRecentChangesSidebarSmall(true);
@@ -148,7 +147,7 @@ const RecentChanges = (): JSX.Element => {
     <>
       <div className="grw-sidebar-content-header p-3 d-flex">
         <h3 className="mb-0  text-nowrap">{t('Recent Changes')}</h3>
-        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => mutate()}>
+        <button type="button" className="btn btn-sm ml-auto grw-btn-reload" onClick={() => swr.mutate()}>
           <i className="icon icon-reload"></i>
         </button>
         <div className="d-flex align-items-center">
@@ -167,14 +166,21 @@ const RecentChanges = (): JSX.Element => {
       </div>
       <div className="grw-recent-changes p-3">
         <ul className="list-group list-group-flush">
-          {(pages || []).map(page => (isRecentChangesSidebarSmall
-            ? <SmallPageItem key={page._id} page={page} />
-            : <LargePageItem key={page._id} page={page} />))}
+          <InfiniteScroll
+            swrInifiniteResponse={swr}
+            isReachingEnd={isReachingEnd}
+          >
+            {pages => pages.map(page => (
+              isRecentChangesSidebarSmall
+                ? <SmallPageItem key={page._id} page={page} />
+                : <LargePageItem key={page._id} page={page} />
+            ))
+            }
+          </InfiniteScroll>
         </ul>
       </div>
     </>
   );
 
 };
-
 export default RecentChanges;

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -86,7 +86,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
         <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="fa fa-bookmark-o" /> */}
-        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} />
+        <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onItemSelected={onItemSelected} />
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
         {/* eslint-enable max-len */}
       </div>

+ 54 - 16
packages/app/src/components/Sidebar/Tag.tsx

@@ -1,43 +1,81 @@
-import React, { FC, useState, useEffect } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
-import TagsList from '../TagsList';
+import { ITagCountHasId } from '~/interfaces/tag';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import TagCloudBox from '../TagCloudBox';
+import TagList from '../TagList';
+
+
+const PAGING_LIMIT = 10;
 
 const Tag: FC = () => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+
   const { t } = useTranslation('');
-  const [isOnReload, setIsOnReload] = useState<boolean>(false);
 
-  useEffect(() => {
-    setIsOnReload(false);
-  }, [isOnReload]);
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  const onReload = useCallback(() => {
+    mutateTagDataList();
+  }, [mutateTagDataList]);
 
+  // todo: adjust design by XD
   return (
-    <div data-testid="grw-sidebar-content-tags">
-      <div className="grw-sidebar-content-header p-3 d-flex">
+    <div className="grw-container-convertible px-4 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+      <div className="grw-sidebar-content-header py-3 d-flex">
         <h3 className="mb-0">{t('Tags')}</h3>
         <button
           type="button"
           className="btn btn-sm ml-auto grw-btn-reload-rc"
-          onClick={() => {
-            setIsOnReload(true);
-          }}
+          onClick={onReload}
         >
           <i className="icon icon-reload"></i>
         </button>
       </div>
-      <div className="d-flex justify-content-center">
+      <h2 className="my-3">{t('popular_tags')}</h2>
+
+      <div className="px-3 text-center">
+        <TagCloudBox tags={tagData} />
+      </div>
+
+      <div className="d-flex justify-content-center my-5">
         <button
-          className="btn btn-primary my-4"
+          className="btn btn-primary rounded px-5"
           type="button"
           onClick={() => { window.location.href = '/tags' }}
         >
           {t('Check All tags')}
         </button>
       </div>
-      <div className="grw-container-convertible mb-5 pb-5">
-        <TagsList isOnReload={isOnReload} />
-      </div>
+
+      { isLoading
+        ? (
+          <div className="text-muted text-center">
+            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+          </div>
+        )
+        : (
+          <TagList
+            tagData={tagData}
+            totalTags={totalCount}
+            activePage={activePage}
+            onChangePage={setOffsetByPageNumber}
+            pagingLimit={PAGING_LIMIT}
+          />
+        )
+      }
     </div>
   );
 

+ 32 - 15
packages/app/src/components/TagCloudBox.tsx

@@ -1,34 +1,51 @@
-import React, { FC } from 'react';
-
+import React, { FC, memo } from 'react';
 import { TagCloud } from 'react-tagcloud';
-
-import { ITagHasCount } from '~/interfaces/tag';
+import { ITagCountHasId } from '~/interfaces/tag';
 
 type Props = {
-  tags: ITagHasCount[],
+  tags:ITagCountHasId[],
   minSize?: number,
   maxSize?: number,
-}
+  maxTagTextLength?: number,
+  isDisableRandomColor?: boolean,
+};
+
+const defaultProps = {
+  isDisableRandomColor: true,
+};
+
+const MIN_FONT_SIZE = 10;
+const MAX_FONT_SIZE = 24;
+const MAX_TAG_TEXT_LENGTH = 8;
 
-const MIN_FONT_SIZE = 12;
-const MAX_FONT_SIZE = 36;
+const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
+  const {
+    tags, minSize, maxSize, isDisableRandomColor,
+  } = props;
+  const maxTagTextLength: number = props.maxTagTextLength ?? MAX_TAG_TEXT_LENGTH;
 
-const TagCloudBox: FC<Props> = (props:Props) => {
   return (
     <>
       <TagCloud
-        minSize={props.minSize || MIN_FONT_SIZE}
-        maxSize={props.maxSize || MAX_FONT_SIZE}
-        tags={props.tags.map((tag) => {
-          return { value: tag.name, count: tag.count };
+        minSize={minSize ?? MIN_FONT_SIZE}
+        maxSize={maxSize ?? MAX_FONT_SIZE}
+        tags={tags.map((tag:ITagCountHasId) => {
+          return {
+            // text truncation
+            value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,
+            count: tag.count,
+          };
         })}
+        disableRandomColor={isDisableRandomColor}
         style={{ cursor: 'pointer' }}
-        className="simple-cloud"
+        className="simple-cloud text-secondary"
         onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
       />
     </>
   );
 
-};
+});
+
+TagCloudBox.defaultProps = defaultProps;
 
 export default TagCloudBox;

+ 76 - 0
packages/app/src/components/TagList.tsx

@@ -0,0 +1,76 @@
+import React, {
+  FC, useCallback,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { ITagCountHasId } from '~/interfaces/tag';
+
+import PaginationWrapper from './PaginationWrapper';
+
+type TagListProps = {
+  tagData: ITagCountHasId[],
+  totalTags: number,
+  activePage: number,
+  onChangePage?: (selectedPageNumber: number) => void,
+  pagingLimit: number,
+  isPaginationShown?: boolean,
+}
+
+const defaultProps = {
+  isPaginationShown: true,
+};
+
+const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) => {
+  const {
+    tagData, totalTags, activePage, onChangePage, pagingLimit, isPaginationShown,
+  } = props;
+  const isTagExist: boolean = tagData.length > 0;
+  const { t } = useTranslation('');
+
+  const generateTagList = useCallback((tagData) => {
+    return tagData.map((tag:ITagCountHasId, index:number) => {
+      const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
+
+      return (
+        <a
+          key={tag._id}
+          href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
+          className={tagListClasses}
+        >
+          <div className="text-truncate">{tag.name}</div>
+          <div className="ml-4 my-auto py-1 px-2 list-tag-count badge badge-secondary text-white">{tag.count}</div>
+        </a>
+      );
+    });
+  }, []);
+
+  if (!isTagExist) {
+    return <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
+  }
+
+  return (
+    <>
+      <ul className="list-group text-left mb-4">
+        {generateTagList(tagData)}
+      </ul>
+      {isPaginationShown
+      && (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={onChangePage}
+          totalItemsCount={totalTags}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="md"
+        />
+      )
+      }
+    </>
+  );
+
+};
+
+TagList.defaultProps = defaultProps;
+
+export default TagList;

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

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

+ 0 - 85
packages/app/src/components/TagsList.tsx

@@ -1,85 +0,0 @@
-import React, { FC, useEffect, useState } from 'react';
-
-import { useTranslation } from 'react-i18next';
-
-import { useSWRxTagsList } from '~/stores/tag';
-
-import PaginationWrapper from './PaginationWrapper';
-import TagCloudBox from './TagCloudBox';
-
-
-const PAGING_LIMIT = 10;
-
-type Props = {
-  isOnReload: boolean
-}
-
-const TagsList: FC<Props> = (props: Props) => {
-  const { t } = useTranslation();
-
-  const [activePage, setActivePage] = useState<number>(1);
-  const [pagingOffset, setPagingOffset] = useState<number>(0);
-
-  const { data: tagsList, error, mutate } = useSWRxTagsList(PAGING_LIMIT, pagingOffset);
-
-  const handlePage = (selectedPageNumber: number) => {
-    setActivePage(selectedPageNumber);
-    setPagingOffset((selectedPageNumber - 1) * PAGING_LIMIT);
-  };
-
-  useEffect(() => {
-    if (props.isOnReload) {
-      mutate();
-    }
-  }, [mutate, props.isOnReload]);
-
-  const isLoading = tagsList === undefined && error == null;
-  if (isLoading) {
-    return (
-      <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-      </div>
-    );
-  }
-
-  return (
-    <div data-testid="grw-tags-list">
-      <header className="py-0">
-        <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${tagsList?.totalCount || 0})`}</h1>
-      </header>
-      <div className="row text-center">
-        <div className="col-12 mb-5 px-5">
-          <TagCloudBox tags={tagsList?.data || []} minSize={20} />
-        </div>
-        <div className="col-12 tag-list mb-4">
-          <ul className="list-group text-left">
-            {
-              tagsList?.data != null && tagsList.data.length > 0
-                ? tagsList.data.map((tag) => {
-                  return (
-                    <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="list-group-item">
-                      <i className="icon-tag mr-2"></i>{tag.name}
-                      <span className="ml-4 list-tag-count badge badge-secondary text-muted">{tag.count}</span>
-                    </a>
-                  );
-                })
-                : <h3>{ t('You have no tag, You can set tags on pages') }</h3>
-            }
-          </ul>
-        </div>
-        <div className="col-12 tag-list-pagination">
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={handlePage}
-            totalItemsCount={tagsList?.totalCount || 0}
-            pagingLimit={PAGING_LIMIT}
-            align="center"
-            size="md"
-          />
-        </div>
-      </div>
-    </div>
-  );
-};
-
-export default TagsList;

+ 48 - 0
packages/app/src/interfaces/activity.ts

@@ -0,0 +1,48 @@
+// Model
+const MODEL_PAGE = 'Page';
+const MODEL_COMMENT = 'Comment';
+
+// Action
+const ACTION_PAGE_LIKE = 'PAGE_LIKE';
+const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
+const ACTION_PAGE_CREATE = 'PAGE_CREATE';
+const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
+const ACTION_PAGE_RENAME = 'PAGE_RENAME';
+const ACTION_PAGE_DUPLICATE = 'PAGE_DUPLICATE';
+const ACTION_PAGE_DELETE = 'PAGE_DELETE';
+const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
+const ACTION_PAGE_REVERT = 'PAGE_REVERT';
+const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
+const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
+
+
+export const SUPPORTED_TARGET_MODEL_TYPE = {
+  MODEL_PAGE,
+} as const;
+
+export const SUPPORTED_EVENT_MODEL_TYPE = {
+  MODEL_COMMENT,
+} as const;
+
+export const SUPPORTED_ACTION_TYPE = {
+  ACTION_PAGE_LIKE,
+  ACTION_PAGE_BOOKMARK,
+  ACTION_PAGE_CREATE,
+  ACTION_PAGE_UPDATE,
+  ACTION_PAGE_RENAME,
+  ACTION_PAGE_DUPLICATE,
+  ACTION_PAGE_DELETE,
+  ACTION_PAGE_DELETE_COMPLETELY,
+  ACTION_PAGE_REVERT,
+  ACTION_COMMENT_CREATE,
+  ACTION_COMMENT_UPDATE,
+} as const;
+
+
+export const AllSupportedTargetModelType = Object.values(SUPPORTED_TARGET_MODEL_TYPE);
+export const AllSupportedEventModelType = Object.values(SUPPORTED_EVENT_MODEL_TYPE);
+export const AllSupportedActionType = Object.values(SUPPORTED_ACTION_TYPE);
+
+// type supportedTargetModelType = typeof SUPPORTED_TARGET_MODEL_NAMES[keyof typeof SUPPORTED_TARGET_MODEL_NAMES];
+// type supportedEventModelType = typeof SUPPORTED_EVENT_MODEL_NAMES[keyof typeof SUPPORTED_EVENT_MODEL_NAMES];
+// type supportedActionType = typeof SUPPORTED_ACTION_NAMES[keyof typeof SUPPORTED_ACTION_NAMES];

+ 7 - 0
packages/app/src/interfaces/errors/v5-conversion-error.ts

@@ -0,0 +1,7 @@
+export const V5ConversionErrCode = {
+  GRANT_INVALID: 'GrantInvalid',
+  PAGE_NOT_FOUND: 'PageNotFound',
+  DUPLICATE_PAGES_FOUND: 'DuplicatePagesFound',
+} as const;
+
+export type V5ConversionErrCode = typeof V5ConversionErrCode[keyof typeof V5ConversionErrCode];

+ 6 - 2
packages/app/src/interfaces/tag.ts

@@ -1,9 +1,13 @@
+import { HasObjectId } from './has-object-id';
+
 export type ITag = {
   name: string,
   createdAt: Date;
 }
 
-export type ITagHasCount = ITag & { count: number }
+export type ITagCount = Omit<ITag, 'createdAt'> & {count: number}
+
+export type ITagCountHasId = ITagCount & HasObjectId
 
 export type ITagsSearchApiv1Result = {
   ok: boolean,
@@ -12,6 +16,6 @@ export type ITagsSearchApiv1Result = {
 
 export type ITagsListApiv1Result = {
   ok: boolean,
-  data: ITagHasCount[],
+  data: ITagCountHasId[],
   totalCount: number,
 }

+ 6 - 8
packages/app/src/server/models/activity.ts

@@ -1,19 +1,17 @@
+import { getOrCreateModel, getModelSafely } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { getOrCreateModel, getModelSafely } from '@growi/core';
-import loggerFactory from '../../utils/logger';
-
+import { AllSupportedTargetModelType, AllSupportedEventModelType, AllSupportedActionType } from '~/interfaces/activity';
 
-import ActivityDefine from '../util/activityDefine';
+import loggerFactory from '../../utils/logger';
 import activityEvent from '../events/activity';
 
 import Subscription from './subscription';
 
 const logger = loggerFactory('growi:models:activity');
 
-
 export interface ActivityDocument extends Document {
   _id: Types.ObjectId
   user: Types.ObjectId | any
@@ -40,7 +38,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   targetModel: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
+    enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
@@ -50,7 +48,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   action: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportActionNames(),
+    enum: AllSupportedActionType,
   },
   event: {
     type: Schema.Types.ObjectId,
@@ -58,7 +56,7 @@ const activitySchema = new Schema<ActivityDocument, ActivityModel>({
   },
   eventModel: {
     type: String,
-    enum: ActivityDefine.getSupportEventModelNames(),
+    enum: AllSupportedEventModelType,
   },
 }, {
   timestamps: true,

+ 12 - 0
packages/app/src/server/models/errors.ts

@@ -0,0 +1,12 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+export class PathAlreadyExistsError extends ExtensibleCustomError {
+
+  targetPath: string;
+
+  constructor(message: string, targetPath: string) {
+    super(message);
+    this.targetPath = targetPath;
+  }
+
+}

+ 6 - 5
packages/app/src/server/models/in-app-notification.ts

@@ -1,13 +1,14 @@
+import { getOrCreateModel } from '@growi/core';
 import {
   Types, Document, Schema, Model,
 } from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 
-import { getOrCreateModel } from '@growi/core';
+import { AllSupportedTargetModelType, AllSupportedActionType } from '~/interfaces/activity';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+
 import { ActivityDocument } from './activity';
-import ActivityDefine from '../util/activityDefine';
 
-import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 
 const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
@@ -45,7 +46,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
   targetModel: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
+    enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,
@@ -55,7 +56,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
   action: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportActionNames(),
+    enum: AllSupportedActionType,
   },
   activities: [
     {

+ 0 - 2
packages/app/src/server/models/obsolete-page.js

@@ -485,7 +485,6 @@ export const getPageSchema = (crowi) => {
     validateCrowi();
 
     const User = crowi.model('User');
-
     const opt = Object.assign({ sort: 'updatedAt', desc: -1 }, option);
     const sortOpt = {};
     sortOpt[opt.sort] = opt.desc;
@@ -505,7 +504,6 @@ export const getPageSchema = (crowi) => {
     builder.addConditionToPagenate(opt.offset, opt.limit, sortOpt);
     builder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
     const pages = await builder.query.lean().clone().exec('find');
-
     const result = {
       pages, totalCount, offset: opt.offset, limit: opt.limit,
     };

+ 43 - 2
packages/app/src/server/models/page.ts

@@ -44,17 +44,24 @@ type TargetAndAncestorsResult = {
   rootPage: PageDocument
 }
 
+type PaginatedPages = {
+  pages: PageDocument[],
+  totalCount: number,
+  limit: number,
+  offset: number
+}
+
 export type CreateMethod = (path: string, body: string, user, options) => Promise<PageDocument & { _id: any }>
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete methods
   createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
-
+  findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
     user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
@@ -488,6 +495,7 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: a
     aggregationPipeline.push({
       $match: {
         $or: [
+          { grant: GRANT_PUBLIC },
           { parent: { $ne: null } },
           { path: '/' },
         ],
@@ -700,6 +708,39 @@ schema.statics.findByPathAndViewer = async function(
   return queryBuilder.query.exec();
 };
 
+schema.statics.findRecentUpdatedPages = async function(
+    path: string, user, options, includeEmpty = false,
+): Promise<PaginatedPages> {
+
+  const sortOpt = {};
+  sortOpt[options.sort] = options.desc;
+
+  const Page = this;
+  const User = mongoose.model('User') as any;
+
+  if (path == null) {
+    throw new Error('path is required.');
+  }
+
+  const baseQuery = this.find({});
+  const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
+  if (!options.includeTrashed) {
+    queryBuilder.addConditionToExcludeTrashed();
+  }
+
+  queryBuilder.addConditionToListWithDescendants(path, options);
+  queryBuilder.populateDataToList(User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+  await addViewerCondition(queryBuilder, user);
+  const pages = await Page.paginate(queryBuilder.query.clone(), {
+    lean: true, sort: sortOpt, offset: options.offset, limit: options.limit,
+  });
+  const results = {
+    pages: pages.docs, totalCount: pages.totalDocs, offset: options.offset, limit: options.limit,
+  };
+
+  return results;
+};
+
 
 /*
  * Find all ancestor pages by path. When duplicate pages found, it uses the oldest page as a result

+ 3 - 4
packages/app/src/server/models/subscription.ts

@@ -1,12 +1,11 @@
+import { getOrCreateModel } from '@growi/core';
 import {
   Types, Document, Model, Schema,
 } from 'mongoose';
 
-import { getOrCreateModel } from '@growi/core';
-
+import { AllSupportedTargetModelType } from '~/interfaces/activity';
 import { SubscriptionStatusType, AllSubscriptionStatusType } from '~/interfaces/subscription';
 
-import ActivityDefine from '../util/activityDefine';
 
 export interface ISubscription {
   user: Types.ObjectId
@@ -39,7 +38,7 @@ const subscriptionSchema = new Schema<SubscriptionDocument, SubscriptionModel>({
   targetModel: {
     type: String,
     require: true,
-    enum: ActivityDefine.getSupportTargetModelNames(),
+    enum: AllSupportedTargetModelType,
   },
   target: {
     type: Schema.Types.ObjectId,

+ 0 - 0
packages/app/src/server/models/vo/error-search.ts → packages/app/src/server/models/vo/search-error.ts


+ 28 - 0
packages/app/src/server/models/vo/v5-conversion-error.ts

@@ -0,0 +1,28 @@
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+
+export class V5ConversionError extends ExtensibleCustomError {
+
+  readonly id = 'V5ConversionError'
+
+  code!: V5ConversionErrCode
+
+  constructor(message: string, code: V5ConversionErrCode) {
+    super(message);
+    this.code = code;
+  }
+
+}
+
+export const isV5ConversionError = (err: any): err is V5ConversionError => {
+  if (err == null || typeof err !== 'object') {
+    return false;
+  }
+
+  if (err instanceof V5ConversionError) {
+    return true;
+  }
+
+  return err?.id === 'V5ConversionError';
+};

+ 7 - 2
packages/app/src/server/routes/apiv3/index.js

@@ -1,8 +1,9 @@
 import loggerFactory from '~/utils/logger';
-import * as userActivation from './user-activation';
+
 import injectUserRegistrationOrderByTokenMiddleware from '../../middlewares/inject-user-registration-order-by-token-middleware';
 
 import pageListing from './page-listing';
+import * as userActivation from './user-activation';
 
 const logger = loggerFactory('growi:routes:apiv3'); // eslint-disable-line no-unused-vars
 
@@ -10,6 +11,7 @@ const express = require('express');
 
 const router = express.Router();
 const routerForAdmin = express.Router();
+const routerForAuth = express.Router();
 
 module.exports = (crowi) => {
 
@@ -34,6 +36,9 @@ module.exports = (crowi) => {
   routerForAdmin.use('/slack-integration-settings', require('./slack-integration-settings')(crowi));
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
 
+  // auth
+  routerForAuth.use('/logout', require('./logout')(crowi));
+
 
   router.use('/in-app-notification', require('./in-app-notification')(crowi));
 
@@ -75,5 +80,5 @@ module.exports = (crowi) => {
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
-  return [router, routerForAdmin];
+  return [router, routerForAdmin, routerForAuth];
 };

+ 16 - 0
packages/app/src/server/routes/apiv3/logout.js

@@ -0,0 +1,16 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:routes:apiv3:logout'); // eslint-disable-line no-unused-vars
+
+const express = require('express');
+
+const router = express.Router();
+
+module.exports = (crowi) => {
+  router.post('/', async(req, res) => {
+    req.session.destroy();
+    return res.send();
+  });
+
+  return router;
+};

+ 50 - 10
packages/app/src/server/routes/apiv3/pages.js

@@ -1,16 +1,17 @@
+import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
 
-import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
-const express = require('express');
 const { pathUtils, pagePathUtils } = require('@growi/core');
-const mongoose = require('mongoose');
-
+const express = require('express');
 const { body } = require('express-validator');
 const { query } = require('express-validator');
+const mongoose = require('mongoose');
 
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
@@ -196,8 +197,10 @@ module.exports = (crowi) => {
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
     legacyPagesMigration: [
-      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+      body('pageIds').optional().isArray().withMessage('pageIds must be an array'),
       body('isRecursively')
+        .optional()
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
@@ -341,6 +344,20 @@ module.exports = (crowi) => {
       }
     }
 
+    // create activity
+    try {
+      const parameters = {
+        user: req.user._id,
+        targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
+        target: createdPage,
+        action: SUPPORTED_ACTION_TYPE.PAGE_CREATE,
+      };
+      await crowi.activityService.createByParameter(parameters);
+    }
+    catch (err) {
+      logger.error('Failed to create activity', err);
+    }
+
     // create subscription
     try {
       await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
@@ -366,18 +383,17 @@ module.exports = (crowi) => {
   router.get('/recent', accessTokenParser, loginRequired, async(req, res) => {
     const limit = 20;
     const offset = parseInt(req.query.offset) || 0;
-
+    const skip = offset > 0 ? (offset - 1) * limit : offset;
     const queryOptions = {
-      offset,
+      offset: skip,
       limit,
       includeTrashed: false,
       isRegExpEscapedFromPath: true,
       sort: 'updatedAt',
       desc: -1,
     };
-
     try {
-      const result = await Page.findListWithDescendants('/', req.user, queryOptions);
+      const result = await Page.findRecentUpdatedPages('/', req.user, queryOptions);
       if (result.pages.length > limit) {
         result.pages.pop();
       }
@@ -786,12 +802,36 @@ module.exports = (crowi) => {
 
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds: _pageIds, isRecursively } = req.body;
+    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+
+    // Convert by path
+    if (convertPath != null) {
+      const normalizedPath = pathUtils.normalizePath(convertPath);
+      try {
+        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isV5ConversionError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      }
+
+      return res.apiv3({});
+    }
+
+    // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
 
     if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
     }
+    if (pageIds.length === 0) {
+      return res.apiv3Err(new ErrorV3('No page is selected.'), 400);
+    }
 
     try {
       await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);

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

@@ -1,23 +1,23 @@
 import express from 'express';
 
+import apiV1FormValidator from '../middlewares/apiv1-form-validator';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
-import apiV1FormValidator from '../middlewares/apiv1-form-validator';
+import * as loginFormValidator from '../middlewares/login-form-validator';
+import * as registerFormValidator from '../middlewares/register-form-validator';
 import {
   generateUnavailableWhenMaintenanceModeMiddleware, generateUnavailableWhenMaintenanceModeMiddlewareForApi,
 } from '../middlewares/unavailable-when-maintenance-mode';
 
-import * as loginFormValidator from '../middlewares/login-form-validator';
-import * as registerFormValidator from '../middlewares/register-form-validator';
 
+import * as allInAppNotifications from './all-in-app-notifications';
 import * as forgotPassword from './forgot-password';
 import * as privateLegacyPages from './private-legacy-pages';
-import * as allInAppNotifications from './all-in-app-notifications';
 import * as userActivation from './user-activation';
 
+const rateLimit = require('express-rate-limit');
 const multer = require('multer');
 const autoReap = require('multer-autoreap');
-const rateLimit = require('express-rate-limit');
 
 const apiLimiter = rateLimit({
   windowMs: 15 * 60 * 1000, // 15 minutes
@@ -44,7 +44,6 @@ module.exports = function(crowi, app) {
   const page = require('./page')(crowi, app);
   const login = require('./login')(crowi, app);
   const loginPassport = require('./login-passport')(crowi, app);
-  const logout = require('./logout')(crowi, app);
   const me = require('./me')(crowi, app);
   const admin = require('./admin')(crowi, app);
   const user = require('./user')(crowi, app);
@@ -62,13 +61,16 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  const [apiV3Router, apiV3AdminRouter] = require('./apiv3')(crowi);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi);
 
   app.use('/api-docs', require('./apiv3/docs')(crowi));
 
   // API v3 for admin
   app.use('/_api/v3', apiV3AdminRouter);
 
+  // API v3 for auth
+  app.use('/_api/v3', apiV3AuthRouter);
+
   app.get('/'                         , applicationInstalled, unavailableWhenMaintenanceMode, loginRequired, autoReconnectToSearch, injectUserUISettings, page.showTopPage);
 
   app.get('/login/error/:reason'      , applicationInstalled, login.error);
@@ -76,10 +78,10 @@ module.exports = function(crowi, app) {
   app.get('/login/invited'            , applicationInstalled, login.invited);
   app.post('/login/activateInvited'   , apiLimiter , applicationInstalled, loginFormValidator.inviteRules(), loginFormValidator.inviteValidation, csrf, login.invited);
   app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+  app.post('/login'                   , apiLimiter , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
   app.post('/register'                , apiLimiter , applicationInstalled, registerFormValidator.registerRules(), registerFormValidator.registerValidation, csrf, login.register);
   app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
-  app.get('/logout'                   , applicationInstalled, logout.logout);
 
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , admin.index);
   app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);

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

@@ -1,9 +1,11 @@
 import { pagePathUtils } from '@growi/core';
-import urljoin from 'url-join';
 import { body } from 'express-validator';
 import mongoose from 'mongoose';
+import urljoin from 'url-join';
 
 import loggerFactory from '~/utils/logger';
+
+import { PathAlreadyExistsError } from '../models/errors';
 import UpdatePost from '../models/update-post';
 
 const { isCreatablePage, isTopPage, isUsersHomePage } = pagePathUtils;
@@ -1261,6 +1263,10 @@ module.exports = function(crowi, app) {
       page = await crowi.pageService.revertDeletedPage(page, req.user, {}, isRecursively);
     }
     catch (err) {
+      if (err instanceof PathAlreadyExistsError) {
+        logger.error('Path already exists', err);
+        return res.json(ApiResponse.error(err, 'already_exists', err.targetPath));
+      }
       logger.error('Error occured while get setting', err);
       return res.json(ApiResponse.error(err));
     }

+ 1 - 1
packages/app/src/server/routes/search.ts

@@ -1,5 +1,5 @@
 import loggerFactory from '~/utils/logger';
-import { isSearchError } from '../models/vo/error-search';
+import { isSearchError } from '../models/vo/search-error';
 
 const logger = loggerFactory('growi:routes:search');
 

+ 9 - 7
packages/app/src/server/service/comment.ts

@@ -1,10 +1,12 @@
-import { Types } from 'mongoose';
 import { getModelSafely } from '@growi/core';
+import { Types } from 'mongoose';
+
+import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_EVENT_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+
 import loggerFactory from '../../utils/logger';
-import ActivityDefine from '../util/activityDefine';
 import Crowi from '../crowi';
 
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 
 const logger = loggerFactory('growi:service:CommentService');
 
@@ -43,7 +45,7 @@ class CommentService {
           return;
         }
 
-        const activity = await this.createActivity(savedComment, ActivityDefine.ACTION_COMMENT_CREATE);
+        const activity = await this.createActivity(savedComment, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_CREATE);
         await this.createAndSendNotifications(activity, page);
       }
       catch (err) {
@@ -56,7 +58,7 @@ class CommentService {
     this.commentEvent.on('update', async(updatedComment) => {
       try {
         this.commentEvent.onUpdate();
-        await this.createActivity(updatedComment, ActivityDefine.ACTION_COMMENT_UPDATE);
+        await this.createActivity(updatedComment, SUPPORTED_ACTION_TYPE.ACTION_COMMENT_UPDATE);
       }
       catch (err) {
         logger.error('Error occurred while handling the comment update event:\n', err);
@@ -80,9 +82,9 @@ class CommentService {
   private createActivity = async function(comment, action) {
     const parameters = {
       user: comment.creator,
-      targetModel: ActivityDefine.MODEL_PAGE,
+      targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
       target: comment.page,
-      eventModel: ActivityDefine.MODEL_COMMENT,
+      eventModel: SUPPORTED_EVENT_MODEL_TYPE.MODEL_COMMENT,
       event: comment._id,
       action,
     };

+ 126 - 29
packages/app/src/server/service/page.ts

@@ -1,34 +1,39 @@
+import pathlib from 'path';
+import { Readable, Writable } from 'stream';
+
 import { pagePathUtils, pathUtils } from '@growi/core';
-import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import escapeStringRegexp from 'escape-string-regexp';
+import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
-import pathlib from 'path';
-import { Readable, Writable } from 'stream';
 
-import { createBatchStream } from '~/server/util/batch-stream';
-import loggerFactory from '~/utils/logger';
-import {
-  CreateMethod, PageCreateOptions, PageModel, PageDocument,
-} from '~/server/models/page';
-import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
+import { Ref } from '~/interfaces/common';
+import { HasObjectId } from '~/interfaces/has-object-id';
 import {
   IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
 } from '~/interfaces/page';
-import { serializePageSecurely } from '../models/serializers/page-serializer';
-import { PageRedirectModel } from '../models/page-redirect';
-import Subscription from '../models/subscription';
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
-import { IUserHasId } from '~/interfaces/user';
-import { Ref } from '~/interfaces/common';
-import { HasObjectId } from '~/interfaces/has-object-id';
-import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
-import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
-import ActivityDefine from '../util/activityDefine';
+import { IUserHasId } from '~/interfaces/user';
+import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import {
+  CreateMethod, PageCreateOptions, PageModel, PageDocument,
+} from '~/server/models/page';
+import { createBatchStream } from '~/server/util/batch-stream';
+import loggerFactory from '~/utils/logger';
 import { prepareDeleteConfigValuesForCalc } from '~/utils/page-delete-config';
 
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import { PathAlreadyExistsError } from '../models/errors';
+import PageOperation, { PageActionStage, PageActionType } from '../models/page-operation';
+import { PageRedirectModel } from '../models/page-redirect';
+import { serializePageSecurely } from '../models/serializers/page-serializer';
+import Subscription from '../models/subscription';
+import { V5ConversionError } from '../models/vo/v5-conversion-error';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
+
 const debug = require('debug')('growi:services:page');
 
 const logger = loggerFactory('growi:services:page');
@@ -154,7 +159,7 @@ class PageService {
       this.pageEvent.onUpdate();
 
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_UPDATE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_UPDATE);
       }
       catch (err) {
         logger.error(err);
@@ -164,7 +169,17 @@ class PageService {
     // rename
     this.pageEvent.on('rename', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_RENAME);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_RENAME);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // duplicate
+    this.pageEvent.on('duplicate', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DUPLICATE);
       }
       catch (err) {
         logger.error(err);
@@ -174,7 +189,7 @@ class PageService {
     // delete
     this.pageEvent.on('delete', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE);
       }
       catch (err) {
         logger.error(err);
@@ -184,7 +199,17 @@ class PageService {
     // delete completely
     this.pageEvent.on('deleteCompletely', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE_COMPLETELY);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE_COMPLETELY);
+      }
+      catch (err) {
+        logger.error(err);
+      }
+    });
+
+    // revert
+    this.pageEvent.on('revert', async(page, user) => {
+      try {
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_REVERT);
       }
       catch (err) {
         logger.error(err);
@@ -194,7 +219,7 @@ class PageService {
     // likes
     this.pageEvent.on('like', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_LIKE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_LIKE);
       }
       catch (err) {
         logger.error(err);
@@ -204,7 +229,7 @@ class PageService {
     // bookmark
     this.pageEvent.on('bookmark', async(page, user) => {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_BOOKMARK);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_BOOKMARK);
       }
       catch (err) {
         logger.error(err);
@@ -963,6 +988,7 @@ class PageService {
         newPagePath, page.revision.body, user, options,
       );
     }
+    this.pageEvent.emit('duplicate', page, user);
 
     // 4. Take over tags
     const originTags = await page.findRelatedTagsById();
@@ -1057,6 +1083,7 @@ class PageService {
     const createdPage = await Page.create(
       newPagePath, page.revision.body, user, options,
     );
+    this.pageEvent.emit('duplicate', page, user);
 
     if (isRecursively) {
       this.duplicateDescendantsWithStream(page, newPagePath, user);
@@ -1879,7 +1906,7 @@ class PageService {
 
     // throw if any page already exists
     if (originPage != null) {
-      throw Error(`This page cannot be reverted since a page with path "${originPage.path}" already exists. Rename the existing pages first.`);
+      throw new PathAlreadyExistsError('already_exists', originPage.path);
     }
 
     // 2. Revert target
@@ -1891,6 +1918,8 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
+    this.pageEvent.emit('revert', page, user);
+
     if (!isRecursively) {
       await this.updateDescendantCountOfAncestors(parent._id, 1, true);
     }
@@ -1973,7 +2002,7 @@ class PageService {
     const newPath = Page.getRevertDeletedPageName(page.path);
     const originPage = await Page.findByPath(newPath);
     if (originPage != null) {
-      throw Error(`This page cannot be reverted since a page with path "${originPage.path}" already exists.`);
+      throw new PathAlreadyExistsError('already_exists', originPage.path);
     }
 
     if (isRecursively) {
@@ -1990,6 +2019,8 @@ class PageService {
     }, { new: true });
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
+    this.pageEvent.emit('revert', page, user);
+
     return updatedPage;
   }
 
@@ -2208,7 +2239,7 @@ class PageService {
     // Create activity
     const parameters = {
       user: user._id,
-      targetModel: ActivityDefine.MODEL_PAGE,
+      targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
       target: page,
       action,
     };
@@ -2222,6 +2253,68 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
 
+  async normalizeParentByPath(path: string, user): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const pages = await Page.findByPathAndViewer(path, user, null, false);
+    if (pages == null || !Array.isArray(pages)) {
+      throw Error('Something went wrong while converting pages.');
+    }
+    if (pages.length === 0) {
+      throw new V5ConversionError(`Could not find the page "${path}" to convert.`, V5ConversionErrCode.PAGE_NOT_FOUND);
+    }
+    if (pages.length > 1) {
+      throw new V5ConversionError(
+        `There are more than two pages at the path "${path}". Please rename or delete the page first.`,
+        V5ConversionErrCode.DUPLICATE_PAGES_FOUND,
+      );
+    }
+
+    const page = pages[0];
+    const {
+      grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+    } = page;
+
+    /*
+     * UserGroup & Owner validation
+     */
+    let isGrantNormalized = false;
+    try {
+      const shouldCheckDescendants = true;
+
+      isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+    }
+    catch (err) {
+      logger.error(`Failed to validate grant of page at "${path}"`, err);
+      throw err;
+    }
+    if (!isGrantNormalized) {
+      throw new V5ConversionError(
+        'This page cannot be migrated since the selected grant or grantedGroup is not assignable to this page.',
+        V5ConversionErrCode.GRANT_INVALID,
+      );
+    }
+
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.NormalizeParent,
+        actionStage: PageActionStage.Main,
+        page,
+        user,
+        fromPath: page.path,
+        toPath: page.path,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
+    }
+
+    // no await
+    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+  }
+
   async normalizeParentByPageIds(pageIds: ObjectIdLike[], user, isRecursively: boolean): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
 
@@ -2440,7 +2533,11 @@ class PageService {
 
       const { prevDescendantCount } = options;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
-      const inc = (newDescendantCount - prevDescendantCount) + 1;
+      let inc = newDescendantCount - prevDescendantCount;
+      const isAlreadyConverted = page.parent != null;
+      if (!isAlreadyConverted) {
+        inc += 1;
+      }
       await this.updateDescendantCountOfAncestors(page._id, inc, false);
     }
     catch (err) {

+ 1 - 1
packages/app/src/server/service/search.ts

@@ -17,7 +17,7 @@ import { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 
 import { ObjectIdLike } from '../interfaces/mongoose-utils';
-import { SearchError } from '../models/vo/error-search';
+import { SearchError } from '../models/vo/search-error';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:search');

+ 0 - 52
packages/app/src/server/util/activityDefine.ts

@@ -1,52 +0,0 @@
-const MODEL_PAGE = 'Page';
-const MODEL_COMMENT = 'Comment';
-
-const ACTION_PAGE_LIKE = 'PAGE_LIKE';
-const ACTION_PAGE_BOOKMARK = 'PAGE_BOOKMARK';
-const ACTION_PAGE_UPDATE = 'PAGE_UPDATE';
-const ACTION_PAGE_RENAME = 'PAGE_RENAME';
-const ACTION_PAGE_DELETE = 'PAGE_DELETE';
-const ACTION_PAGE_DELETE_COMPLETELY = 'PAGE_DELETE_COMPLETELY';
-const ACTION_COMMENT_CREATE = 'COMMENT_CREATE';
-const ACTION_COMMENT_UPDATE = 'COMMENT_UPDATE';
-
-const getSupportTargetModelNames = () => {
-  return [MODEL_PAGE];
-};
-
-const getSupportEventModelNames = () => {
-  return [MODEL_COMMENT];
-};
-
-const getSupportActionNames = () => {
-  return [
-    ACTION_PAGE_LIKE,
-    ACTION_PAGE_BOOKMARK,
-    ACTION_PAGE_UPDATE,
-    ACTION_PAGE_RENAME,
-    ACTION_PAGE_DELETE,
-    ACTION_PAGE_DELETE_COMPLETELY,
-    ACTION_COMMENT_CREATE,
-    ACTION_COMMENT_UPDATE,
-  ];
-};
-
-const activityDefine = {
-  MODEL_PAGE,
-  MODEL_COMMENT,
-
-  ACTION_PAGE_LIKE,
-  ACTION_PAGE_BOOKMARK,
-  ACTION_PAGE_UPDATE,
-  ACTION_PAGE_RENAME,
-  ACTION_PAGE_DELETE,
-  ACTION_PAGE_DELETE_COMPLETELY,
-  ACTION_COMMENT_CREATE,
-  ACTION_COMMENT_UPDATE,
-
-  getSupportTargetModelNames,
-  getSupportEventModelNames,
-  getSupportActionNames,
-};
-
-export default activityDefine;

+ 3 - 19
packages/app/src/server/views/maintenance-mode.html

@@ -3,13 +3,12 @@
 {% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('maintenance_mode.maintenance_mode')) }}{% endblock %}
 
 
+
 {#
   # Remove default contents
   #}
  {% block html_head_loading_legacy %}
  {% endblock %}
- {% block html_head_loading_app %}
- {% endblock %}
  {% block layout_head_nav %}
  {% endblock %}
  {% block sidebar %}
@@ -19,6 +18,7 @@
  {% block fixed-controls %}
  {% endblock %}
 
+
 {% block layout_main %}
 <div id="main" class="main">
   <div id="content-main" class="content-main container-lg">
@@ -30,23 +30,7 @@
             <h1 class="text-center">{{ t('maintenance_mode.maintenance_mode') }}</h1>
             <h3>{{ t('maintenance_mode.growi_is_under_maintenance') }}</h3>
             <hr />
-            <div class="text-left">
-              <p>
-                <i class="icon-arrow-right"></i>
-                <a href="/admin">{{ t('maintenance_mode.admin_page') }}</a>
-              </p>
-              {% if not user %}
-                <p>
-                  <i class="icon-arrow-right"></i>
-                  <a href="/login">{{ t('maintenance_mode.login') }}</a>
-                </p>
-              {% else %}
-                <p>
-                  <i class="icon-arrow-right"></i>
-                  <a href="/logout">{{ t('maintenance_mode.logout') }}</a>
-                </p>
-              {% endif %}
-            </div>
+            <div id="maintenance-mode-content"></div>
           </div>
         </div>
       </div>

+ 12 - 12
packages/app/src/stores/modal.tsx

@@ -222,32 +222,32 @@ export const usePagePresentationModal = (
 
 
 /*
- * LegacyPrivatePagesMigrationModal
+ * PrivateLegacyPagesMigrationModal
  */
 
 export type ILegacyPrivatePage = { pageId: string, path: string };
 
-export type LegacyPrivatePagesMigrationModalSubmitedHandler = (pages: ILegacyPrivatePage[], isRecursively?: boolean) => void;
+export type PrivateLegacyPagesMigrationModalSubmitedHandler = (pages: ILegacyPrivatePage[], isRecursively?: boolean) => void;
 
-type LegacyPrivatePagesMigrationModalStatus = {
+type PrivateLegacyPagesMigrationModalStatus = {
   isOpened: boolean,
   pages?: ILegacyPrivatePage[],
-  onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler,
+  onSubmited?: PrivateLegacyPagesMigrationModalSubmitedHandler,
 }
 
-type LegacyPrivatePagesMigrationModalStatusUtils = {
-  open(pages: ILegacyPrivatePage[], onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
-  close(): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
+type PrivateLegacyPagesMigrationModalStatusUtils = {
+  open(pages: ILegacyPrivatePage[], onSubmited?: PrivateLegacyPagesMigrationModalSubmitedHandler): Promise<PrivateLegacyPagesMigrationModalStatus | undefined>,
+  close(): Promise<PrivateLegacyPagesMigrationModalStatus | undefined>,
 }
 
-export const useLegacyPrivatePagesMigrationModal = (
-    status?: LegacyPrivatePagesMigrationModalStatus,
-): SWRResponse<LegacyPrivatePagesMigrationModalStatus, Error> & LegacyPrivatePagesMigrationModalStatusUtils => {
-  const initialData: LegacyPrivatePagesMigrationModalStatus = {
+export const usePrivateLegacyPagesMigrationModal = (
+    status?: PrivateLegacyPagesMigrationModalStatus,
+): SWRResponse<PrivateLegacyPagesMigrationModalStatus, Error> & PrivateLegacyPagesMigrationModalStatusUtils => {
+  const initialData: PrivateLegacyPagesMigrationModalStatus = {
     isOpened: false,
     pages: [],
   };
-  const swrResponse = useStaticSWR<LegacyPrivatePagesMigrationModalStatus, Error>('legacyPrivatePagesMigrationModal', status, { fallbackData: initialData });
+  const swrResponse = useStaticSWR<PrivateLegacyPagesMigrationModalStatus, Error>('privateLegacyPagesMigrationModal', status, { fallbackData: initialData });
 
   return {
     ...swrResponse,

+ 17 - 4
packages/app/src/stores/page.tsx

@@ -1,13 +1,14 @@
 import useSWR, { SWRResponse } from 'swr';
+import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { HasObjectId } from '~/interfaces/has-object-id';
-
 import {
   IPageInfo, IPageHasId, IPageInfoForOperation, IPageInfoForListing, IDataWithMeta,
 } from '~/interfaces/page';
 import { IPagingResult } from '~/interfaces/paging-result';
+
 import { apiGet } from '../client/util/apiv1-client';
 import { IPageTagsInfo } from '../interfaces/pageTagsInfo';
 
@@ -15,9 +16,9 @@ import { useCurrentPagePath } from './context';
 import { ITermNumberManagerUtil, useTermNumberManager } from './use-static-swr';
 
 
-export const useSWRxPageByPath = (path: string, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
+export const useSWRxPageByPath = (path: string | null | undefined, initialData?: IPageHasId): SWRResponse<IPageHasId, Error> => {
   return useSWR(
-    ['/page', path],
+    path != null ? ['/page', path] : null,
     (endpoint, path) => apiv3Get(endpoint, { path }).then(result => result.data.page),
     {
       fallbackData: initialData,
@@ -33,7 +34,19 @@ export const useSWRxRecentlyUpdated = (): SWRResponse<(IPageHasId)[], Error> =>
     endpoint => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
   );
 };
-
+export const useSWRInifinitexRecentlyUpdated = () : SWRInfiniteResponse<(IPageHasId)[], Error> => {
+  const getKey = (page: number) => {
+    return `/pages/recent?offset=${page + 1}`;
+  };
+  return useSWRInfinite(
+    getKey,
+    (endpoint: string) => apiv3Get<{ pages:(IPageHasId)[] }>(endpoint).then(response => response.data?.pages),
+    {
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxPageList = (path: string | null, pageNumber?: number, termNumber?: number): SWRResponse<IPagingResult<IPageHasId>, Error> => {
 

+ 1 - 3
packages/app/src/stores/tag.tsx

@@ -1,11 +1,9 @@
 import { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
+import { apiGet } from '~/client/util/apiv1-client';
 import { ITagsListApiv1Result } from '~/interfaces/tag';
 
-import { apiGet } from '../client/util/apiv1-client';
-
-
 export const useSWRxTagsList = (limit?: number, offset?: number): SWRResponse<ITagsListApiv1Result, Error> => {
   return useSWRImmutable(
     ['/tags.list', limit, offset],

+ 2 - 2
packages/app/src/styles/_page-tree.scss

@@ -21,7 +21,7 @@ $grw-pagetree-item-padding-left: 10px;
         display: block;
       }
 
-      .grw-pagetree-count {
+      .grw-count-badge {
         display: none;
       }
     }
@@ -49,7 +49,7 @@ $grw-pagetree-item-padding-left: 10px;
         display: none;
       }
 
-      .grw-pagetree-count {
+      .grw-count-badge {
         min-width: 28px;
         padding: 0.1rem 0.5rem;
         font-size: 12px;

+ 12 - 15
packages/app/src/styles/_page_list.scss

@@ -49,15 +49,23 @@ body .page-list {
         margin-right: 2px;
       }
 
+      .footstamp-icon {
+        svg {
+          width: 14px;
+          height: 14px;
+        }
+      }
       .seen-users-count {
-        &.strength-1 {
+        &.strength-0,
+        &.strength-1,
+        &.strength-2 {
           font-weight: bold;
         }
-        &.strength-2 {
+        &.strength-3 {
           font-weight: normal;
         }
-        &.strength-3 {
-          opacity: 0.6;
+        &.strength-4 {
+          opacity: 0.5;
         }
       }
     }
@@ -78,17 +86,6 @@ body .page-list {
     .list-group-item {
       min-height: 136px;
 
-      .page-list-meta {
-        .meta-icon {
-          width: 14px;
-          height: 14px;
-          margin-right: 14px;
-        }
-        .footstamp-icon {
-          margin-right: 2px;
-        }
-      }
-
       .page-list-updated-at {
         font-size: 12px;
       }

+ 6 - 1
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -287,7 +287,7 @@ ul.pagination {
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($secondary, $gray-200);
     }
-    .grw-pagetree-count {
+    .grw-count-badge {
       color: $gray-400;
       background: lighten($bgcolor-sidebar-context, 15%);
     }
@@ -481,6 +481,11 @@ ul.pagination {
 * grw-side-contents
 */
 .grw-side-contents-sticky-container {
+  .grw-count-badge {
+    color: $gray-400;
+    background: $gray-700;
+  }
+
   .grw-border-vr {
     border-color: $border-color-toc;
   }

+ 6 - 1
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -192,7 +192,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($gray-400, $primary);
     }
-    .grw-pagetree-count {
+    .grw-count-badge {
       color: $gray-500;
       background: $gray-200;
     }
@@ -357,6 +357,11 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 * grw-side-contents
 */
 .grw-side-contents-sticky-container {
+  .grw-count-badge {
+    color: $primary;
+    background: $gray-200;
+  }
+
   .grw-border-vr {
     border-color: $border-color-toc;
   }

+ 3 - 2
packages/app/test/cypress/integration/6-home/home.spec.ts

@@ -59,12 +59,13 @@ context('Access User settings', () => {
     cy.getByTestid('grw-associate-modal').should('be.visible');
     cy.screenshot(`${ssPrefix}-external-account-2`);
     cy.getByTestid('grw-associate-modal').find('.modal-footer button').click(); // click add button in modal form
+    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.screenshot(`${ssPrefix}-external-account-3`);
+    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
+    cy.get('.toast').should('not.exist');
     cy.getByTestid('grw-associate-modal').find('.close').click();
-    cy.get('.toast').should('be.visible').invoke('attr', 'style', 'opacity: 1');
     cy.screenshot(`${ssPrefix}-external-account-4`);
 
-    cy.get('.toast-close-button').click({ multiple: true }); // close toast alert
     cy.get('.toast').should('not.exist');
 
     // Access Password setting

+ 134 - 4
packages/app/test/integration/models/v5.page.test.js

@@ -53,13 +53,22 @@ describe('Page', () => {
     const pModelUserId3 = new mongoose.Types.ObjectId();
     await User.insertMany([
       {
-        _id: pModelUserId1, name: 'pmodelUser1', username: 'pmodelUser1', email: 'pmodelUser1@example.com',
+        _id: pModelUserId1,
+        name: 'pmodelUser1',
+        username: 'pmodelUser1',
+        email: 'pmodelUser1@example.com',
       },
       {
-        _id: pModelUserId2, name: 'pmodelUser2', username: 'pmodelUser2', email: 'pmodelUser2@example.com',
+        _id: pModelUserId2,
+        name: 'pmodelUser2',
+        username: 'pmodelUser2',
+        email: 'pmodelUser2@example.com',
       },
       {
-        _id: pModelUserId3, name: 'pModelUser3', username: 'pModelUser3', email: 'pModelUser3@example.com',
+        _id: pModelUserId3,
+        name: 'pModelUser3',
+        username: 'pModelUser3',
+        email: 'pModelUser3@example.com',
       },
     ]);
     pModelUser1 = await User.findOne({ _id: pModelUserId1 });
@@ -473,6 +482,30 @@ describe('Page', () => {
         lastUpdateUser: dummyUser1._id,
         isEmpty: false,
       },
+      {
+        path: '/get_parent_A',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+      {
+        path: '/get_parent_A/get_parent_B',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
+      {
+        path: '/get_parent_C',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: rootPage._id,
+      },
+      {
+        path: '/get_parent_C/get_parent_D',
+        creator: dummyUser1,
+        lastUpdateUser: dummyUser1,
+        parent: null,
+      },
     ]);
 
   });
@@ -834,7 +867,7 @@ describe('Page', () => {
       expect(page1.parent).toStrictEqual(rootPage._id);
       expect(page2.parent).toStrictEqual(page1._id);
     });
-    test("should find parent while NOT updating private legacy page's parent", async() => {
+    test('should find parent while NOT updating private legacy page\'s parent', async() => {
       const path1 = '/emp_anc4';
       const path2 = '/emp_anc4/PAF4';
       const _page1 = await Page.findOne({ path: path1, isEmpty: true, grant: Page.GRANT_PUBLIC });
@@ -858,5 +891,102 @@ describe('Page', () => {
       expect(page2.parent).toStrictEqual(parent._id);
 
     });
+    test('should find parent while NOT creating unnecessary empty pages with all v4 public pages', async() => {
+      // All pages does not have parent (v4 schema)
+      const _pageA = await Page.findOne({
+        path: '/get_parent_A',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: null,
+      });
+      const _pageAB = await Page.findOne({
+        path: '/get_parent_A/get_parent_B',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: null,
+      });
+      const _emptyA = await Page.findOne({
+        path: '/get_parent_A',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+      const _emptyAB = await Page.findOne({
+        path: '/get_parent_A/get_parent_B',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+
+      expect(_pageA).not.toBeNull();
+      expect(_pageAB).not.toBeNull();
+      expect(_emptyA).toBeNull();
+      expect(_emptyAB).toBeNull();
+
+      const parent = await Page.getParentAndFillAncestors('/get_parent_A/get_parent_B/get_parent_C', dummyUser1);
+
+      const pageA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const pageAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const emptyA = await Page.findOne({ path: '/get_parent_A', grant: Page.GRANT_PUBLIC, isEmpty: true });
+      const emptyAB = await Page.findOne({ path: '/get_parent_A/get_parent_B', grant: Page.GRANT_PUBLIC, isEmpty: true });
+
+      // -- Check existance
+      expect(parent).not.toBeNull();
+      expect(pageA).not.toBeNull();
+      expect(pageAB).not.toBeNull();
+      expect(emptyA).toBeNull();
+      expect(emptyAB).toBeNull();
+
+      // -- Check parent
+      expect(pageA.parent).not.toBeNull();
+      expect(pageAB.parent).not.toBeNull();
+    });
+    test('should find parent while NOT creating unnecessary empty pages with some v5 public pages', async() => {
+      const _pageC = await Page.findOne({
+        path: '/get_parent_C',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+        parent: { $ne: null },
+      });
+      const _pageCD = await Page.findOne({
+        path: '/get_parent_C/get_parent_D',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: false,
+      });
+      const _emptyC = await Page.findOne({
+        path: '/get_parent_C',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+      const _emptyCD = await Page.findOne({
+        path: '/get_parent_C/get_parent_D',
+        grant: Page.GRANT_PUBLIC,
+        isEmpty: true,
+      });
+
+      expect(_pageC).not.toBeNull();
+      expect(_pageCD).not.toBeNull();
+      expect(_emptyC).toBeNull();
+      expect(_emptyCD).toBeNull();
+
+      const parent = await Page.getParentAndFillAncestors('/get_parent_C/get_parent_D/get_parent_E', dummyUser1);
+
+      const pageC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const pageCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: false });
+      const emptyC = await Page.findOne({ path: '/get_parent_C', grant: Page.GRANT_PUBLIC, isEmpty: true });
+      const emptyCD = await Page.findOne({ path: '/get_parent_C/get_parent_D', grant: Page.GRANT_PUBLIC, isEmpty: true });
+
+      // -- Check existance
+      expect(parent).not.toBeNull();
+      expect(pageC).not.toBeNull();
+      expect(pageCD).not.toBeNull();
+      expect(emptyC).toBeNull();
+      expect(emptyCD).toBeNull();
+
+      // -- Check parent attribute
+      expect(pageC.parent).toStrictEqual(rootPage._id);
+      expect(pageCD.parent).toStrictEqual(pageC._id);
+
+      // -- Check the found parent
+      expect(parent).toStrictEqual(pageCD);
+    });
   });
 });

+ 1 - 1
packages/codemirror-textlint/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/codemirror-textlint",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "scripts": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-attachment-refs",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-lsx",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -60,7 +60,7 @@ export class Lsx extends React.Component {
     try {
       const res = await this.props.appContainer.apiGet('/plugins/lsx', { pagePath, options: lsxContext.options });
 
-      lsxContext.activeUsersCount = res.activeUsersCount;
+      lsxContext.toppageViewersCount = res.toppageViewersCount;
 
       if (res.ok) {
         const nodeTree = this.generatePageNodeTree(pagePath, res.pages);

+ 2 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx

@@ -79,7 +79,7 @@ export class LsxPage extends React.Component {
 
   render() {
     const pageNode = this.props.pageNode;
-    const { activeUsersCount } = this.props.lsxContext;
+    const { toppageViewersCount } = this.props.lsxContext;
 
     // create PagePath element
     let pagePathNode = <PagePathWrapper pagePath={pageNode.pagePath} isExists={this.state.isExists} />;
@@ -88,7 +88,7 @@ export class LsxPage extends React.Component {
     }
 
     // create PageListMeta element
-    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} activeUsersCount={activeUsersCount} /> : '';
+    const pageListMeta = (this.state.isExists) ? <PageListMeta page={pageNode.page} basisViewersCount={toppageViewersCount} /> : '';
 
     return (
       <li className="page-list-li">

+ 11 - 4
packages/plugin-lsx/src/server/routes/lsx.js

@@ -208,10 +208,17 @@ module.exports = (crowi, app) => {
 
     const builder = await generateBaseQueryBuilder(pagePath, user);
 
-    // count active users
-    let activeUsersCount;
+    // count viewers of `/`
+    let toppageViewersCount;
     try {
-      activeUsersCount = await User.countListByStatus(User.STATUS_ACTIVE);
+      const aggRes = await Page.aggregate([
+        { $match: { path: '/' } },
+        { $project: { count: { $size: '$seenUsers' } } },
+      ]);
+
+      toppageViewersCount = aggRes.length > 0
+        ? aggRes[0].count
+        : 1;
     }
     catch (error) {
       return res.json(ApiResponse.error(error));
@@ -237,7 +244,7 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages, activeUsersCount }));
+      res.json(ApiResponse.success({ pages, toppageViewersCount }));
     }
     catch (error) {
       return res.json(ApiResponse.error(error));

+ 1 - 1
packages/plugin-pukiwiki-like-linker/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/plugin-pukiwiki-like-linker",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "description": "GROWI plugin to add PukiwikiLikeLinker",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "typings": "dist/index.d.ts",

+ 6 - 8
packages/slackbot-proxy/docker/Dockerfile

@@ -1,4 +1,4 @@
-# syntax = docker/dockerfile:1
+# syntax = docker/dockerfile:1.4
 
 ##
 ## packages-json-picker
@@ -103,18 +103,16 @@ ENV NODE_ENV production
 ENV optDir /opt
 ENV appDir ${optDir}
 
+USER node
+
+WORKDIR ${appDir}
 # copy artifacts
 COPY --from=deps-resolver-prod --chown=node:node \
-  ${optDir}/dependencies.tar ${appDir}/
+  ${optDir}/dependencies.tar ./
 COPY --from=builder --chown=node:node \
-  ${optDir}/packages.tar ${appDir}/
-
-RUN chown node:node ${appDir}
-
-USER node
+  ${optDir}/packages.tar ./
 
 # extract artifacts
-WORKDIR ${appDir}
 RUN tar -xf dependencies.tar
 RUN tar -xf packages.tar
 RUN rm dependencies.tar packages.tar

+ 2 - 2
packages/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "5.0.3",
+  "version": "5.0.4-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^5.0.3",
+    "@growi/slack": "^5.0.4-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "5.0.3",
+  "version": "5.0.4-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": [

+ 14 - 14
packages/ui/src/components/PagePath/PageListMeta.tsx

@@ -12,38 +12,38 @@ const { checkTemplatePath } = templateChecker;
 
 
 const SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT = 5;
-const MIN_STRENGTH_LEVEL = -3;
+const MAX_STRENGTH_LEVEL = 4;
 
 type SeenUsersCountProps = {
   count: number,
-  activeUsersCount?: number,
+  basisViewersCount?: number,
   shouldSpaceOutIcon?: boolean,
 }
 
 const SeenUsersCount = (props: SeenUsersCountProps): JSX.Element => {
 
-  const { count, shouldSpaceOutIcon, activeUsersCount } = props;
+  const { count, shouldSpaceOutIcon, basisViewersCount } = props;
 
   if (count === 0) {
     return <></>;
   }
 
-  if (activeUsersCount != null && activeUsersCount <= SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT) {
+  if (basisViewersCount != null && basisViewersCount <= SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT) {
     return <></>;
   }
 
-  const strengthLevel = Math.log(count / (activeUsersCount ?? count)); // Max: 0
+  const strengthLevel = Math.ceil(
+    Math.min(0, Math.log(count / (basisViewersCount ?? count))) // Max: 0
+    * 2 * -1,
+  );
 
-  if (strengthLevel <= MIN_STRENGTH_LEVEL) {
+  if (strengthLevel > MAX_STRENGTH_LEVEL) {
     return <></>;
   }
 
-  assert(strengthLevel > MIN_STRENGTH_LEVEL); // [0, MIN_STRENGTH_LEVEL)
+  assert(strengthLevel >= 0 && strengthLevel <= MAX_STRENGTH_LEVEL); // [0, MAX_STRENGTH_LEVEL)
 
-  let strengthClass = '';
-  if (strengthLevel < 0) {
-    strengthClass = `strength-${Math.ceil(strengthLevel * -1)}`; // strength-{0, 1, 2, 3}
-  }
+  const strengthClass = `strength-${strengthLevel}`; // strength-{0, 1, 2, 3, 4}
 
   return (
     <span className={`seen-users-count ${shouldSpaceOutIcon ? 'mr-3' : ''} ${strengthClass}`}>
@@ -60,12 +60,12 @@ type PageListMetaProps = {
   likerCount?: number,
   bookmarkCount?: number,
   shouldSpaceOutIcon?: boolean,
-  activeUsersCount?: number,
+  basisViewersCount?: number,
 }
 
 export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
 
-  const { page, shouldSpaceOutIcon, activeUsersCount } = props;
+  const { page, shouldSpaceOutIcon, basisViewersCount } = props;
 
   // top check
   let topLabel;
@@ -103,7 +103,7 @@ export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) =>
     <span className="page-list-meta">
       {topLabel}
       {templateLabel}
-      <SeenUsersCount count={page.seenUsers.length} activeUsersCount={activeUsersCount} />
+      <SeenUsersCount count={page.seenUsers.length} basisViewersCount={basisViewersCount} />
       {commentCount}
       {likerCount}
       {locked}