Prechádzať zdrojové kódy

Merge branch 'master' into imprv/104019-show-Table-Modal

jam411 3 rokov pred
rodič
commit
a862c6b347
98 zmenil súbory, kde vykonal 1643 pridanie a 1158 odobranie
  1. 4 2
      .github/dependabot.yml
  2. 8 0
      .github/workflows/ci-app-prod.yml
  3. 2 2
      .github/workflows/draft-release.yml
  4. 4 4
      .github/workflows/list-unhealthy-branches.yml
  5. 1 1
      .github/workflows/release-rc.yml
  6. 2 2
      .github/workflows/release-slackbot-proxy.yml
  7. 3 3
      .github/workflows/release.yml
  8. 8 4
      .github/workflows/reusable-app-prod.yml
  9. 14 1
      CHANGELOG.md
  10. 0 0
      packages/app/_obsolete/src/client/services/AppContainer.js
  11. 2 10
      packages/app/_obsolete/src/client/services/PageContainer.js
  12. 0 0
      packages/app/_obsolete/src/components/MyDraftList/Draft.tsx
  13. 0 0
      packages/app/_obsolete/src/components/MyDraftList/MyDraftList.jsx
  14. 2 2
      packages/app/docker/README.md
  15. 5 6
      packages/app/package.json
  16. 0 2
      packages/app/public/static/locales/en_US/admin.json
  17. 7 2
      packages/app/public/static/locales/en_US/translation.json
  18. 0 2
      packages/app/public/static/locales/ja_JP/admin.json
  19. 7 2
      packages/app/public/static/locales/ja_JP/translation.json
  20. 0 2
      packages/app/public/static/locales/zh_CN/admin.json
  21. 7 2
      packages/app/public/static/locales/zh_CN/translation.json
  22. 3 2
      packages/app/src/client/services/page-operation.ts
  23. 1 1
      packages/app/src/client/util/editor.ts
  24. 1 1
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  25. 2 4
      packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx
  26. 0 2
      packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  27. 2 3
      packages/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  28. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  29. 0 86
      packages/app/src/components/Admin/Users/RemoveAdminButton.jsx
  30. 0 85
      packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx
  31. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  32. 3 11
      packages/app/src/components/ArchiveCreateModal.jsx
  33. 12 39
      packages/app/src/components/EmptyTrashButton.tsx
  34. 1 1
      packages/app/src/components/EmptyTrashModal.tsx
  35. 1 1
      packages/app/src/components/IdenticalPathPage.tsx
  36. 4 1
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  37. 6 3
      packages/app/src/components/InstallerForm.tsx
  38. 2 8
      packages/app/src/components/Layout/AdminLayout.tsx
  39. 7 4
      packages/app/src/components/Navbar/GlobalSearch.tsx
  40. 29 25
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  41. 19 2
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  42. 8 9
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  43. 6 2
      packages/app/src/components/PageAttachment.tsx
  44. 9 0
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.module.scss
  45. 9 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  46. 11 15
      packages/app/src/components/PageAttachment/PageAttachmentList.tsx
  47. 18 4
      packages/app/src/components/PageEditor.tsx
  48. 27 17
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  49. 5 1
      packages/app/src/components/PageManagement/ApiErrorMessage.jsx
  50. 12 17
      packages/app/src/components/PageStatusAlert.jsx
  51. 9 3
      packages/app/src/components/PutbackPageModal.jsx
  52. 2 2
      packages/app/src/components/Sidebar/PageTree.tsx
  53. 5 1
      packages/app/src/components/Sidebar/Tag.tsx
  54. 7 2
      packages/app/src/components/TableOfContents.tsx
  55. 9 5
      packages/app/src/components/TagList.tsx
  56. 53 5
      packages/app/src/components/TrashPageList.tsx
  57. 0 38
      packages/app/src/components/User/LikerList.jsx
  58. 5 5
      packages/app/src/pages/[[...path]].page.tsx
  59. 2 1
      packages/app/src/pages/_app.page.tsx
  60. 0 347
      packages/app/src/pages/admin/[[...path]].page.tsx
  61. 46 0
      packages/app/src/pages/admin/app.page.tsx
  62. 56 0
      packages/app/src/pages/admin/audit-log.page.tsx
  63. 62 0
      packages/app/src/pages/admin/customize.page.tsx
  64. 45 0
      packages/app/src/pages/admin/export.page.tsx
  65. 46 0
      packages/app/src/pages/admin/global-notification/new.page.tsx
  66. 47 0
      packages/app/src/pages/admin/importer.page.tsx
  67. 74 0
      packages/app/src/pages/admin/index.page.tsx
  68. 47 0
      packages/app/src/pages/admin/markdown.page.tsx
  69. 48 0
      packages/app/src/pages/admin/notification.page.tsx
  70. 52 0
      packages/app/src/pages/admin/search.page.tsx
  71. 98 0
      packages/app/src/pages/admin/security.page.tsx
  72. 47 0
      packages/app/src/pages/admin/slack-integration-legacy.page.tsx
  73. 52 0
      packages/app/src/pages/admin/slack-integration.page.tsx
  74. 50 0
      packages/app/src/pages/admin/user-groups.page.tsx
  75. 50 0
      packages/app/src/pages/admin/users/external-accounts.page.tsx
  76. 69 0
      packages/app/src/pages/admin/users/index.page.tsx
  77. 1 1
      packages/app/src/pages/maintenance.page.tsx
  78. 5 3
      packages/app/src/pages/trash.page.tsx
  79. 3 2
      packages/app/src/pages/utils/commons.ts
  80. 36 33
      packages/app/src/server/models/page.ts
  81. 7 5
      packages/app/src/server/routes/apiv3/pages.js
  82. 4 0
      packages/app/src/server/routes/page.js
  83. 18 6
      packages/app/src/server/service/page.ts
  84. 17 13
      packages/app/src/stores/context.tsx
  85. 1 1
      packages/app/src/stores/modal.tsx
  86. 10 7
      packages/app/src/stores/ui.tsx
  87. 0 10
      packages/app/src/styles/_attachments.scss
  88. 5 0
      packages/app/src/styles/bootstrap/_override.scss
  89. 41 0
      packages/app/src/utils/admin-page-util.ts
  90. 2 1
      packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts
  91. 4 4
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  92. 9 9
      packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts
  93. 4 0
      packages/core/src/interfaces/attachment.ts
  94. 0 86
      packages/ui/src/components/Attachment/Attachment.jsx
  95. 58 0
      packages/ui/src/components/Attachment/Attachment.tsx
  96. 0 104
      packages/ui/src/components/User/UserPicture.jsx
  97. 120 0
      packages/ui/src/components/User/UserPicture.tsx
  98. 41 68
      yarn.lock

+ 4 - 2
.github/dependabot.yml

@@ -2,16 +2,18 @@ version: 2
 updates:
   - package-ecosystem: github-actions
     directory: '/'
+    open-pull-requests-limit: 0
     schedule:
-      interval: daily
+      interval: monthly
     commit-message:
       prefix: ci
       include: scope
 
   - package-ecosystem: npm
     directory: '/'
+    open-pull-requests-limit: 0
     schedule:
-      interval: daily
+      interval: weekly
     commit-message:
       prefix: ci
       include: scope

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

@@ -36,6 +36,13 @@ on:
       - packages/slack/**
       - packages/ui/**
       - packages/plugin-**
+  workflow_call:
+    inputs:
+      cypress-config-video:
+        description: 'Enable video when running Cypress test'
+        type: boolean
+        default: false
+
 
 jobs:
 
@@ -54,6 +61,7 @@ jobs:
       node-version: 16.x
       skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) && contains( github.event.pull_request.labels.*.name, 'github_actions' ) }}
       cypress-report-artifact-name: Cypress report
+      cypress-config-video: ${{ inputs.cypress-config-video || false }}
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 

+ 2 - 2
.github/workflows/draft-release.yml

@@ -19,7 +19,7 @@ jobs:
       - uses: actions/checkout@v3
 
       - name: Retrieve information from package.json
-        uses: myrotvorets/info-from-package-json-action@1.1.0
+        uses: myrotvorets/info-from-package-json-action@1.2.0
         id: package-json
 
       # Drafts your next Release notes as Pull Requests are merged into "master"
@@ -48,7 +48,7 @@ jobs:
         id: release-version
         run: |
           RELEASE_VERSION=`npx semver -i patch ${{ needs.update-release-draft.outputs.CURRENT_VERSION }}`
-          echo ::set-output name=RELEASE_VERSION::$RELEASE_VERSION
+          echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
 
       # See: https://github.com/bakunyo/git-pr-release-action/issues/15, https://github.com/samunohito/SimpleVolumeMixer/commit/2059044c71236509466cf9b1bb2d56d515274938
       - name: Create/Update Pull Request

+ 4 - 4
.github/workflows/list-unhealthy-branches.yml

@@ -23,10 +23,10 @@ jobs:
       run: |
         export SLACK_ATTACHMENTS_ILLEGAL=`node bin/github-actions/list-branches --illegal`
         export SLACK_ATTACHMENTS_INACTIVE=`node bin/github-actions/list-branches --inactive`
-        echo ::set-output name=SLACK_ATTACHMENTS_ILLEGAL::$SLACK_ATTACHMENTS_ILLEGAL
-        echo ::set-output name=SLACK_ATTACHMENTS_INACTIVE::$SLACK_ATTACHMENTS_INACTIVE
-        echo ::set-output name=SLACK_ATTACHMENTS_LENGTH_ILLEGAL::$(echo $SLACK_ATTACHMENTS_ILLEGAL | jq '. | length')
-        echo ::set-output name=SLACK_ATTACHMENTS_LENGTH_INACTIVE::$(echo $SLACK_ATTACHMENTS_INACTIVE | jq '. | length')
+        echo "SLACK_ATTACHMENTS_ILLEGAL=$SLACK_ATTACHMENTS_ILLEGAL" >> $GITHUB_OUTPUT
+        echo "SLACK_ATTACHMENTS_INACTIVE=$SLACK_ATTACHMENTS_INACTIVE" >> $GITHUB_OUTPUT
+        echo "SLACK_ATTACHMENTS_LENGTH_ILLEGAL=$(echo $SLACK_ATTACHMENTS_ILLEGAL | jq '. | length')" >> $GITHUB_OUTPUT
+        echo "SLACK_ATTACHMENTS_LENGTH_INACTIVE=$(echo $SLACK_ATTACHMENTS_INACTIVE | jq '. | length')" >> $GITHUB_OUTPUT
 
     - name: Slack Notification for illegal named branches
       if: steps.list-branches.outputs.SLACK_ATTACHMENTS_LENGTH_ILLEGAL > 0

+ 1 - 1
.github/workflows/release-rc.yml

@@ -17,7 +17,7 @@ jobs:
         lfs: true
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.1.0
+      uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
     - name: Docker meta

+ 2 - 2
.github/workflows/release-slackbot-proxy.yml

@@ -17,7 +17,7 @@ jobs:
         ref: ${{ github.event.pull_request.base.ref }}
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.1.0
+      uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
       with:
         workingDir: packages/slackbot-proxy
@@ -115,7 +115,7 @@ jobs:
         yarn bump-versions:slackbot-proxy
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.1.0
+      uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
       with:
         workingDir: packages/slackbot-proxy

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

@@ -38,7 +38,7 @@ jobs:
         sh ./packages/app/bin/github-actions/update-readme.sh
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.1.0
+      uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
     - name: Update Changelog
@@ -99,7 +99,7 @@ jobs:
         yarn bump-versions:slackbot-proxy
 
     - name: Retrieve information from package.json
-      uses: myrotvorets/info-from-package-json-action@1.1.0
+      uses: myrotvorets/info-from-package-json-action@1.2.0
       id: package-json
 
     - name: Commit
@@ -140,7 +140,7 @@ jobs:
       id: suffix
       run: |
         [[ ${{ matrix.flavor }} = "nocdn" ]] && suffix="-nocdn" || suffix=""
-        echo "::set-output name=SUFFIX::$suffix"
+        echo "SUFFIX=$suffix" >> $GITHUB_OUTPUT
 
     - name: Docker meta
       id: meta

+ 8 - 4
.github/workflows/reusable-app-prod.yml

@@ -10,6 +10,9 @@ on:
         type: boolean
       cypress-report-artifact-name:
         type: string
+      cypress-config-video:
+        type: boolean
+        default: false
     secrets:
       SLACK_WEBHOOK_URL:
         required: true
@@ -70,7 +73,7 @@ jobs:
           packages/app/.env.production* \
           packages/*/package.json \
           packages/*/dist
-        echo ::set-output name=file::production.tar.gz
+        echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
     - name: Upload production files as artifact
       uses: actions/upload-artifact@v3
@@ -126,8 +129,8 @@ jobs:
     - name: Get Date
       id: get-date
       run: |
-        echo "::set-output name=dateYmdHM::$(/bin/date -u "+%Y%m%d%H%M")"
-        echo "::set-output name=dateYm::$(/bin/date -u "+%Y%m")"
+        echo "dateYmdHM=$(/bin/date -u "+%Y%m%d%H%M")" >> $GITHUB_OUTPUT
+        echo "dateYm=$(/bin/date -u "+%Y%m")" >> $GITHUB_OUTPUT
 
     - name: Cache/Restore node_modules (not reused)
       id: cache-dependencies
@@ -247,7 +250,7 @@ jobs:
       id: determine-spec-exp
       run: |
         SPEC=`node bin/github-actions/generate-cypress-spec-arg.js --prefix="test/cypress/integration/" --suffix="-*/**" "${{ matrix.spec-group }}"`
-        echo "::set-output name=value::$SPEC"
+        echo "value=$SPEC" >> $GITHUB_OUTPUT
 
     - name: Copy dotenv file for ci
       working-directory: ./packages/app
@@ -274,6 +277,7 @@ jobs:
         spec: '${{ steps.determine-spec-exp.outputs.value }}'
         start: yarn server
         wait-on: 'http://localhost:3000'
+        config: video=${{ inputs.cypress-config-video }}
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-vrt
         ELASTICSEARCH_URI: http://localhost:${{ job.services.elasticsearch.ports['9200'] }}/growi

+ 14 - 1
CHANGELOG.md

@@ -1,9 +1,22 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.1.5...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.1.7...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v5.1.7](https://github.com/weseek/growi/compare/v5.1.6...v5.1.7) - 2022-10-26
+
+### 🐛 Bug Fixes
+
+- fix: Page move event notification message (#6823) @hakumizuki
+
+## [v5.1.6](https://github.com/weseek/growi/compare/v5.1.5...v5.1.6) - 2022-10-19
+
+### 🐛 Bug Fixes
+
+- fix: image not showing and exceed crop modal area (#6712) @mudana-grune
+- fix: Conflict Diff Modal Error getCurrentOptionsToSave is not a function (#6745) @kaoritokashiki
+
 ## [v5.1.5](https://github.com/weseek/growi/compare/v5.1.4...v5.1.5) - 2022-10-04
 
 ### 💎 Features

+ 0 - 0
packages/app/src/client/services/AppContainer.js → packages/app/_obsolete/src/client/services/AppContainer.js


+ 2 - 10
packages/app/src/client/services/PageContainer.js → packages/app/_obsolete/src/client/services/PageContainer.js

@@ -14,8 +14,6 @@ import {
 import {
   DrawioInterceptor,
 } from '../../services/renderer/interceptor/drawio-interceptor';
-import { toastError } from '../util/apiNotification';
-import { apiPost } from '../util/apiv1-client';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -338,23 +336,17 @@ export default class PageContainer extends Container {
   retrieveMyBookmarkList() {
   }
 
-  async resolveConflict(markdown, editorMode) {
+  async resolveConflict(markdown, editorMode, optionsToSave) {
 
     const { pageId, remoteRevisionId, path } = this.state;
     const editorContainer = this.appContainer.getContainer('EditorContainer');
-    const options = editorContainer.getCurrentOptionsToSave();
-    const optionsToSave = Object.assign({}, options);
 
     const res = await this.updatePage(pageId, remoteRevisionId, markdown, optionsToSave);
 
     editorContainer.clearDraft(path);
     this.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
 
-    // Update PageEditor component
-    if (editorMode !== EditorMode.Editor) {
-      // eslint-disable-next-line no-undef
-      globalEmitter.emit('updateEditorValue', markdown);
-    }
+    window.globalEmitter.emit('updateEditorValue', markdown);
 
     editorContainer.setState({ tags: res.tags });
 

+ 0 - 0
packages/app/src/components/MyDraftList/Draft.tsx → packages/app/_obsolete/src/components/MyDraftList/Draft.tsx


+ 0 - 0
packages/app/src/components/MyDraftList/MyDraftList.jsx → packages/app/_obsolete/src/components/MyDraftList/MyDraftList.jsx


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

@@ -11,8 +11,8 @@ Supported tags and respective Dockerfile links
 ------------------------------------------------
 
 * [`6.0.0`, `6.0`, `6`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v6.0.0/packages/app/docker/Dockerfile)
-* [`5.1.5`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.5/packages/app/docker/Dockerfile)
-* [`5.1.5-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.5/packages/app/docker/Dockerfile)
+* [`5.1.7`, `5.1`, `5`](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
+* [`5.1.7-nocdn`, `5.1-nocdn`, `5-nocdn`](https://github.com/weseek/growi/blob/v5.1.7/packages/app/docker/Dockerfile)
 * [`4.5.23`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 * [`4.5.23-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.23/packages/app/docker/Dockerfile)
 

+ 5 - 6
packages/app/package.json

@@ -116,9 +116,9 @@
     "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
-    "i18next-chained-backend": "^3.0.2",
-    "i18next-http-backend": "^1.4.1",
-    "i18next-localstorage-backend": "^3.1.3",
+    "i18next-chained-backend": "^4.0.0",
+    "i18next-http-backend": "^2.0.0",
+    "i18next-localstorage-backend": "^4.0.0",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "lucene-query-parser": "^1.2.0",
@@ -133,7 +133,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "next": "^12.2.5",
-    "next-i18next": "^11.3.0",
+    "next-i18next": "^12.1.0",
     "next-superjson": "^0.0.4",
     "next-themes": "^0.2.0",
     "nocache": "^3.0.1",
@@ -205,7 +205,6 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@alienfast/i18next-loader": "^1.1.4",
     "@growi/ui": "^6.0.0-RC.7",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
@@ -232,7 +231,7 @@
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
-    "i18next-hmr": "^1.7.7",
+    "i18next-hmr": "^1.11.0",
     "jquery-slimscroll": "^1.3.8",
     "jquery.cookie": "~1.4.1",
     "jshint": "^2.13.0",

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

@@ -290,8 +290,6 @@
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
   },
   "v5_page_migration": {
-    "page_tree_not_avaliable" : "Page tree feature is not available yet.",
-    "go_to_settings": "Go to settings to enable the feature",
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
     "migration_note": "Note: You will lose unique constraints from the page paths.",
     "upgrade_to_v5": "Convert to v5 compatibility",

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

@@ -115,7 +115,7 @@
   "Create under": "Create page under below:",
   "V5 Page Migration": "Convert To V5 Compatibility",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
-  "See_more_detail_on_new_schema": "See more detail on <a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
+  "See_more_detail_on_new_schema": "See more detail on <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <i class='icon-share-alt'></i> ",
   "Site URL settings": "Site URL settings",
   "external_account_management": "External Account Management",
   "UserGroup": "UserGroup",
@@ -389,7 +389,8 @@
     "notfound_or_forbidden": "Original page is not found or forbidden.",
     "already_exists": "Page with the path already exists.",
     "outdated": "Page is updated someone and now outdated.",
-    "user_not_admin": "Only admin user can delete"
+    "user_not_admin": "Only admin user can delete",
+    "single_deletion_empty_pages": "Empty pages cannot be single deleted"
   },
   "page_history": {
     "revision_list": "Revision list",
@@ -863,5 +864,9 @@
   "footer": {
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
+  },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page tree feature is not available yet.",
+    "go_to_settings": "Go to settings to enable the feature"
   }
 }

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

@@ -322,8 +322,6 @@
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
   },
   "v5_page_migration": {
-    "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
-    "go_to_settings": "設定する",
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
     "migration_note": "注意: ページパスからユニーク制約が失われます。",
     "upgrade_to_v5": "v5 互換形式 へ変換",

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

@@ -109,7 +109,7 @@
   "Create under": "ページを以下に作成",
   "V5 Page Migration": "V5 互換形式 への変換",
   "GROWI.5.0_new_schema": "GROWI.5.0における新スキーマについて",
-  "See_more_detail_on_new_schema": "詳しくは<a href='#'>{{url}}</a><i class='icon-share-alt'></i>を参照ください。",
+  "See_more_detail_on_new_schema": "詳しくは<a href='https://docs.growi.org/ja/admin-guide/upgrading/50x.html#新しい-v5-互換形式について' target='_blank'>{{title}}</a><i class='icon-share-alt'></i>を参照ください。",
   "Site URL settings": "サイトURL設定",
   "external_account_management": "外部アカウント管理",
   "UserGroup": "グループ",
@@ -380,7 +380,8 @@
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
     "already_exists": "そのパスを持つページは既に存在しています。",
     "outdated": "ページが他のユーザーによって更新されました。",
-    "user_not_admin": "権限のあるユーザーのみが削除できます"
+    "user_not_admin": "権限のあるユーザーのみが削除できます",
+    "single_deletion_empty_pages": "空ページの単体削除はできません"
   },
   "page_history": {
     "revision_list": "更新履歴",
@@ -854,5 +855,9 @@
   "footer": {
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
+  },
+  "v5_page_migration": {
+    "page_tree_not_avaliable" : "Page Tree 機能は現在使用できません。",
+    "go_to_settings": "設定する"
   }
 }

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

@@ -277,8 +277,6 @@
     "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
   },
   "v5_page_migration": {
-    "page_tree_not_avaliable": "Page Tree 功能不可用",
-    "go_to_settings": "进入设置,启用该功能",
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
     "migration_note": "注意:你将失去页面路径的唯一约束。",
     "upgrade_to_v5": "转换为v5兼容性",

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

@@ -117,7 +117,7 @@
 	"Create under": "Create page under below:",
   "V5 Page Migration": "转换为V5的兼容性",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
-  "See_more_detail_on_new_schema": "更多详情请见<a href='#'>{{url}}</a> <i class='icon-share-alt'></i> ",
+  "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
 	"Site URL settings": "主页URL设置",
 	"Markdown Settings": "Markdown设置",
 	"Notification Settings": "通知设置",
@@ -362,7 +362,8 @@
 		"notfound_or_forbidden": "未找到或禁止原始页。",
 		"already_exists": "具有该路径的页面已存在",
 		"outdated": "页面已被某人更新,现在已过时。",
-		"user_not_admin": "仅管理员用户可以删除"
+		"user_not_admin": "仅管理员用户可以删除",
+    "single_deletion_empty_pages": "空的页面不能被单一删除"
   },
   "page_history": {
     "revision_list": "修订清单",
@@ -909,5 +910,9 @@
   "footer": {
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
+  },
+  "v5_page_migration": {
+    "page_tree_not_avaliable": "Page Tree 功能不可用",
+    "go_to_settings": "进入设置,启用该功能"
   }
 }

+ 3 - 2
packages/app/src/client/services/page-operation.ts

@@ -4,7 +4,6 @@ import urljoin from 'url-join';
 import { OptionsToSave } from '~/interfaces/editor-settings';
 import loggerFactory from '~/utils/logger';
 
-
 import { toastError } from '../util/apiNotification';
 import { apiPost } from '../util/apiv1-client';
 import { apiv3Post, apiv3Put } from '../util/apiv3-client';
@@ -143,8 +142,10 @@ export const saveOrUpdate = async(optionsToSave: OptionsToSave, pageInfo: PageIn
   // markdown = pageEditor.getMarkdown();
   // }
 
+  const isNoRevisionPage = pageId != null && revisionId == null;
+
   let res;
-  if (pageId == null) {
+  if (pageId == null || isNoRevisionPage) {
     res = await createPage(path, markdown, options);
   }
   else {

+ 1 - 1
packages/app/src/client/util/editor.ts

@@ -1,4 +1,4 @@
-import { OptionsToSave } from '~/interfaces/editor-settings';
+import type { OptionsToSave } from '~/interfaces/editor-settings';
 
 export const getOptionsToSave = (
     isSlackEnabled: boolean,

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

@@ -47,7 +47,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                 <td>
                   { activity.user != null && (
                     <>
-                      <UserPicture user={activity.user} className="picture rounded-circle" />
+                      <UserPicture user={activity.user} />
                       <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
                     </>
                   )}

+ 2 - 4
packages/app/src/components/Admin/Notification/ManageGlobalNotification.jsx

@@ -2,7 +2,6 @@ import React, { useCallback, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
-import urljoin from 'url-join';
 
 import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
 import { toastError } from '~/client/util/apiNotification';
@@ -45,7 +44,7 @@ const ManageGlobalNotification = (props) => {
     }
   }, [triggerEvents]);
 
-  const submitHandler = useCallback(async() => {
+  const updateButtonClickedHandler = useCallback(async() => {
 
     const requestParams = {
       triggerPath,
@@ -62,7 +61,6 @@ const ManageGlobalNotification = (props) => {
       else {
         await apiv3Post('/notification-setting/global-notification', requestParams);
       }
-      window.location.href = urljoin(window.location.origin, '/admin/notification#global-notification');
     }
     catch (err) {
       toastError(err);
@@ -271,7 +269,7 @@ const ManageGlobalNotification = (props) => {
       </div>
 
       <AdminUpdateButtonRow
-        onClick={submitHandler}
+        onClick={updateButtonClickedHandler}
         disabled={adminNotificationContainer.state.retrieveError != null}
       />
     </>

+ 0 - 2
packages/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -5,8 +5,6 @@ import PropTypes from 'prop-types';
 
 import { useAppTitle } from '~/stores/context';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
 import CustomBotWithoutProxyConnectionStatus from './CustomBotWithoutProxyConnectionStatus';
 import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './CustomBotWithoutProxySettingsAccordion';
 

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

@@ -12,7 +12,6 @@ import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { useSiteUrl } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import Accordion from '../Common/Accordion';
 
 import ManageCommandsProcess from './ManageCommandsProcess';
@@ -143,7 +142,7 @@ const CustomCopyToClipBoard = (props) => {
   );
 };
 
-const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers((props) => {
+const GeneratingTokensAndRegisteringProxyServiceProcess = (props) => {
   const { t } = useTranslation();
   const { slackAppIntegrationId } = props;
 
@@ -231,7 +230,7 @@ const GeneratingTokensAndRegisteringProxyServiceProcess = withUnstatedContainers
     </div>
 
   );
-}, []);
+};
 
 const TestProcess = ({
   slackAppIntegrationId, onSubmitForm, onSubmitFormFailed, isLatestConnectionSuccess,

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

@@ -44,7 +44,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
           return (
             <tr key={relation._id}>
               <td>
-                <UserPicture user={relatedUser} className="picture rounded-circle" />
+                <UserPicture user={relatedUser} />
               </td>
               <td>
                 <strong>{relatedUser.username}</strong>

+ 0 - 86
packages/app/src/components/Admin/Users/RemoveAdminButton.jsx

@@ -1,86 +0,0 @@
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class RemoveAdminButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickRemoveAdminBtn = this.onClickRemoveAdminBtn.bind(this);
-  }
-
-  async onClickRemoveAdminBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.removeUserAdmin(this.props.user._id);
-      toastSuccess(t('toaster.remove_user_admin', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-
-  renderRemoveAdminBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickRemoveAdminBtn() }}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_management.user_table.remove_admin_access')}
-      </button>
-    );
-  }
-
-  renderRemoveAdminAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.remove_admin_access')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_remove')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderRemoveAdminBtn()
-          : this.renderRemoveAdminAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-const RemoveAdminButtonWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <RemoveAdminButton t={t} {...props} />;
-};
-
-/**
-* Wrapper component for using unstated
-*/
-const RemoveAdminButtonWrapper = withUnstatedContainers(RemoveAdminButtonWrapperFC, [AppContainer, AdminUsersContainer]);
-
-RemoveAdminButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default RemoveAdminButtonWrapper;

+ 0 - 85
packages/app/src/components/Admin/Users/StatusSuspendedButton.jsx

@@ -1,85 +0,0 @@
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class StatusSuspendedButton extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.onClickDeactiveBtn = this.onClickDeactiveBtn.bind(this);
-  }
-
-  async onClickDeactiveBtn() {
-    const { t } = this.props;
-
-    try {
-      const username = await this.props.adminUsersContainer.deactivateUser(this.props.user._id);
-      toastSuccess(t('toaster.deactivate_user_success', { username }));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  renderSuspendedBtn() {
-    const { t } = this.props;
-
-    return (
-      <button className="dropdown-item" type="button" onClick={() => { this.onClickDeactiveBtn() }}>
-        <i className="icon-fw icon-ban"></i> {t('admin:user_management.user_table.deactivate_account')}
-      </button>
-    );
-  }
-
-  renderSuspendedAlert() {
-    const { t } = this.props;
-
-    return (
-      <div className="px-4">
-        <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
-        <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
-      </div>
-    );
-  }
-
-  render() {
-    const { user } = this.props;
-    const { currentUsername } = this.props.appContainer;
-
-    return (
-      <Fragment>
-        {user.username !== currentUsername ? this.renderSuspendedBtn()
-          : this.renderSuspendedAlert()}
-      </Fragment>
-    );
-  }
-
-}
-
-const StatusSuspendedFormWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <StatusSuspendedButton t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const StatusSuspendedFormWrapper = withUnstatedContainers(StatusSuspendedFormWrapperFC, [AppContainer, AdminUsersContainer]);
-
-StatusSuspendedButton.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
-
-  user: PropTypes.object.isRequired,
-};
-
-export default StatusSuspendedFormWrapper;

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -188,7 +188,7 @@ class UserTable extends React.Component {
                 return (
                   <tr data-testid="user-table-tr" key={user._id}>
                     <td>
-                      <UserPicture user={user} className="picture rounded-circle" />
+                      <UserPicture user={user} />
                     </td>
                     <td>{this.getUserStatusLabel(user.status)} {this.getUserAdminLabel(user.admin)}</td>
                     <td>

+ 3 - 11
packages/app/src/components/ArchiveCreateModal.jsx

@@ -1,17 +1,14 @@
 import React, { useState, useCallback } from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 
-import { withUnstatedContainers } from './UnstatedUtils';
-
 
 const ArchiveCreateModal = (props) => {
   const { t } = useTranslation();
@@ -235,7 +232,7 @@ const ArchiveCreateModal = (props) => {
 };
 
 ArchiveCreateModal.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func,
   path: PropTypes.string.isRequired,
@@ -243,9 +240,4 @@ ArchiveCreateModal.propTypes = {
   errorMessage: PropTypes.string,
 };
 
-/**
- * Wrapper component for using unstated
- */
-const ArchiveCreateModalWrapper = withUnstatedContainers(ArchiveCreateModal, [AppContainer]);
-
-export default ArchiveCreateModalWrapper;
+export default ArchiveCreateModal;

+ 12 - 39
packages/app/src/components/EmptyTrashButton.tsx

@@ -1,55 +1,28 @@
-import React, { FC, useCallback } from 'react';
+import React, { useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { toastSuccess } from '~/client/util/apiNotification';
-import {
-  IDataWithMeta,
-  IPageHasId,
-  IPageInfo,
-} from '~/interfaces/page';
-import { useEmptyTrashModal } from '~/stores/modal';
-import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
+type EmptyTrashButtonProps = {
+  onEmptyTrashButtonClick: () => void,
+  disableEmptyButton: boolean
+};
 
 
-const EmptyTrashButton: FC = () => {
+const EmptyTrashButton = (props: EmptyTrashButtonProps): JSX.Element => {
+  const { onEmptyTrashButtonClick, disableEmptyButton } = props;
   const { t } = useTranslation();
-  const { open: openEmptyTrashModal } = useEmptyTrashModal();
-  const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
-
-  const pageIds = pagingResult?.items?.map(page => page._id);
-  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
-
-  let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfo>[] = [];
-
-  const convertToIDataWithMeta = (page) => {
-    return { data: page };
-  };
-
-  if (pagingResult != null) {
-    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
-    pageWithMetas = injectTo(dataWithMetas);
-  }
-
-  const deletablePages = pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
-
-  const onEmptiedTrashHandler = useCallback(() => {
-    toastSuccess(t('empty_trash'));
-
-    mutate();
-  }, [t, mutate]);
 
-  const emptyTrashClickHandler = () => {
-    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
-  };
+  const emptyTrashButtonHandler = useCallback(() => {
+    onEmptyTrashButtonClick();
+  }, [onEmptyTrashButtonClick]);
 
   return (
     <div className="d-flex align-items-center">
       <button
         type="button"
         className="btn btn-outline-secondary rounded-pill text-danger d-flex align-items-center"
-        disabled={deletablePages.length === 0}
-        onClick={() => emptyTrashClickHandler()}
+        disabled={disableEmptyButton}
+        onClick={emptyTrashButtonHandler}
       >
         <i className="icon-fw icon-trash"></i>
         <div>{t('modal_empty.empty_the_trash')}</div>

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

@@ -19,7 +19,7 @@ const EmptyTrashModal: FC = () => {
 
   const isOpened = emptyTrashModalData?.isOpened ?? false;
 
-  const canDeleteAllpages = emptyTrashModalData?.opts?.canDelepeAllPages ?? false;
+  const canDeleteAllpages = emptyTrashModalData?.opts?.canDeleteAllPages ?? false;
 
   const [errs, setErrs] = useState<Error[] | null>(null);
 

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

@@ -40,7 +40,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
           { path: _path, pageName: _pageName })}<br />
         <span
           // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { url: t('GROWI.5.0_new_schema') }) }}
+          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { title: t('GROWI.5.0_new_schema') }) }}
         />
       </p>
       <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>

+ 4 - 1
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -4,6 +4,7 @@ import React, {
 
 import { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui';
+import { useRouter } from 'next/router';
 
 import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
 import { IInAppNotification } from '~/interfaces/in-app-notification';
@@ -24,6 +25,8 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
     notification, actionMsg, actionIcon, actionUsers,
   } = props;
 
+  const router = useRouter();
+
   const snapshot = parseSnapshot(notification.snapshot);
 
   // publish open()
@@ -33,7 +36,7 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
         // jump to target page
         const targetPagePath = notification.target.path;
         if (targetPagePath != null) {
-          window.location.href = targetPagePath;
+          router.push(targetPagePath);
         }
       }
     },

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

@@ -3,6 +3,7 @@ import {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
@@ -12,6 +13,8 @@ import { apiv3Post } from '~/client/util/apiv3-client';
 const InstallerForm = memo((): JSX.Element => {
   const { t, i18n } = useTranslation();
 
+  const router = useRouter();
+
   const [isValidUserName, setValidUserName] = useState(true);
   const [isSubmittingDisabled, setSubmittingDisabled] = useState(false);
   const [currentLocale, setCurrentLocale] = useState('en_US');
@@ -70,7 +73,7 @@ const InstallerForm = memo((): JSX.Element => {
 
     try {
       await apiv3Post('/installer', data);
-      window.location.href = '/';
+      router.push('/');
     }
     catch (errs) {
       const err = errs[0];
@@ -78,12 +81,12 @@ const InstallerForm = memo((): JSX.Element => {
 
       if (code === 'failed_to_login_after_install') {
         toastError(t('installer.failed_to_login_after_install'));
-        setTimeout(() => { window.location.href = '/login' }, 700); // Wait 700 ms to show toastr
+        setTimeout(() => { router.push('/login') }, 700); // Wait 700 ms to show toastr
       }
 
       toastError(t('installer.failed_to_install'));
     }
-  }, [isSubmittingDisabled, t, currentLocale]);
+  }, [isSubmittingDisabled, currentLocale, router, t]);
 
   const hasErrorClass = isValidUserName ? '' : ' has-error';
   const unavailableUserId = isValidUserName

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

@@ -17,18 +17,12 @@ const AdminNotFoundPage = dynamic(() => import('../Admin/NotFoundPage').then(mod
 type Props = {
   title: string
   componentTitle: string
-  /**
-   * Set the current option of AdminNavigation
-   * Expected it is in ["home", "app", "security", "markdown", "customize", "importer", "export",
-   * "notification", 'global-notification', "users", "user-groups", "search"]
-   */
-  selectedNavOpt: string
   children?: ReactNode
 }
 
 
 const AdminLayout = ({
-  children, title, selectedNavOpt, componentTitle,
+  children, title, componentTitle,
 }: Props): JSX.Element => {
 
   const AdminNavigation = dynamic(() => import('~/components/Admin/Common/AdminNavigation'), { ssr: false });
@@ -46,7 +40,7 @@ const AdminLayout = ({
           <div className="container-fluid">
             <div className="row">
               <div className="col-lg-3">
-                <AdminNavigation selected={selectedNavOpt} />
+                <AdminNavigation />
               </div>
               <div className="col-lg-9">
                 {children || <AdminNotFoundPage />}

+ 7 - 4
packages/app/src/components/Navbar/GlobalSearch.tsx

@@ -3,6 +3,7 @@ import React, { useState, useCallback, useRef } from 'react';
 import assert from 'assert';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IPageWithSearchMeta } from '~/interfaces/search';
@@ -25,6 +26,8 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
   const { dropup } = props;
 
+  const router = useRouter();
+
   const globalSearchFormRef = useRef<IFocusable>(null);
 
   useGlobalSearchFormRef(globalSearchFormRef);
@@ -45,9 +48,9 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
 
     // navigate to page
     if (page != null) {
-      window.location.href = `/${page._id}`;
+      router.push(`/${page._id}`);
     }
-  }, []);
+  }, [router]);
 
   const search = useCallback(() => {
     const url = new URL(window.location.href);
@@ -60,8 +63,8 @@ export const GlobalSearch = (props: GlobalSearchProps): JSX.Element => {
     }
     url.searchParams.append('q', q);
 
-    window.location.href = url.href;
-  }, [currentPagePath, isScopeChildren, text]);
+    router.push(url.href);
+  }, [currentPagePath, isScopeChildren, router, text]);
 
   const scopeLabel = isScopeChildren
     ? t('header_search_box.label.This tree')

+ 29 - 25
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -3,6 +3,7 @@ import React, { useState, useEffect, useCallback } from 'react';
 import { isPopulated, IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
 import { exportAsMarkdown } from '~/client/services/page-operation';
@@ -184,6 +185,8 @@ type GrowiContextualSubNavigationProps = {
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
+  const router = useRouter();
+
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
 
   const revision = currentPage?.revision;
@@ -270,43 +273,44 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     return;
   }, [mutatePageTagsForEditors]);
 
+  const reload = useCallback(() => {
+    if (currentPathname != null) {
+      router.push(currentPathname);
+    }
+  }, [currentPathname, router]);
+
   const duplicateItemClickedHandler = useCallback(async(page: IPageForPageDuplicateModal) => {
     const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
-      window.location.href = toPath;
+      router.push(toPath);
     };
     openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal]);
+  }, [openDuplicateModal, router]);
 
   const renameItemClickedHandler = useCallback(async(page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
     const renamedHandler: OnRenamedFunction = () => {
-      if (page.data._id !== null) {
-        window.location.href = `/${page.data._id}`;
-        return;
-      }
-      window.location.reload();
+      reload();
     };
     openRenameModal(page, { onRenamed: renamedHandler });
-  }, [openRenameModal]);
+  }, [openRenameModal, reload]);
 
-  const onDeletedHandler: OnDeletedFunction = useCallback((pathOrPathsToDelete, isRecursively, isCompletely) => {
-    if (typeof pathOrPathsToDelete !== 'string') {
-      return;
-    }
-
-    const path = pathOrPathsToDelete;
+  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
+    const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
 
-    if (isCompletely) {
-      // redirect to NotFound Page
-      window.location.href = path;
-    }
-    else {
-      window.location.reload();
-    }
-  }, []);
+      const path = pathOrPathsToDelete;
 
-  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
-    openDeleteModal([pageWithMeta], { onDeleted: onDeletedHandler });
-  }, [onDeletedHandler, openDeleteModal]);
+      if (isCompletely) {
+        // redirect to NotFound Page
+        router.push(path);
+      }
+      else {
+        reload();
+      }
+    };
+    openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
+  }, [openDeleteModal, reload, router]);
 
   const templateMenuItemClickHandler = useCallback(() => {
     setIsPageTempleteModalShown(true);

+ 19 - 2
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -5,12 +5,13 @@ import React, {
 import { isServer } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Image from 'next/image';
 import Link from 'next/link';
 import { useRipple } from 'react-use-ripple';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import {
-  useIsSearchPage, useCurrentPagePath, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential,
+  useIsSearchPage, useCurrentPagePath, useIsGuestUser, useIsSearchServiceConfigured, useAppTitle, useConfidential, useCustomizedLogoSrc,
 } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useIsDeviceSmallerThanMd } from '~/stores/ui';
@@ -119,6 +120,21 @@ const Confidential: FC<ConfidentialProps> = memo((props: ConfidentialProps): JSX
 });
 Confidential.displayName = 'Confidential';
 
+interface NavbarLogoProps {
+  logoSrc?: string,
+}
+
+const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
+  const { logoSrc } = props;
+
+  return logoSrc != null
+    // eslint-disable-next-line @next/next/no-img-element
+    ? (<img src={logoSrc} alt="custom logo" className="picture picture-lg p-2 mx-2" id="settingBrandLogo" width="32" />)
+    : <GrowiLogo />;
+});
+
+GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
+
 export const GrowiNavbar = (): JSX.Element => {
 
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
@@ -128,6 +144,7 @@ export const GrowiNavbar = (): JSX.Element => {
   const { data: isSearchServiceConfigured } = useIsSearchServiceConfigured();
   const { data: isDeviceSmallerThanMd } = useIsDeviceSmallerThanMd();
   const { data: isSearchPage } = useIsSearchPage();
+  const { data: customizedLogoSrc } = useCustomizedLogoSrc();
 
   return (
     <nav id="grw-navbar" className={`navbar grw-navbar ${styles['grw-navbar']} navbar-expand navbar-dark sticky-top mb-0 px-0`}>
@@ -135,7 +152,7 @@ export const GrowiNavbar = (): JSX.Element => {
       <div className="navbar-brand mr-0">
         <Link href="/" prefetch={false}>
           <a className="grw-logo d-block">
-            <GrowiLogo />
+            <GrowiNavbarLogo logoSrc={customizedLogoSrc}/>
           </a>
         </Link>
       </div>

+ 8 - 9
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -2,11 +2,10 @@ import React from 'react';
 
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
+import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
-import {
-  useIsTrashPage, useShareLinkId,
-} from '~/stores/context';
+import { useIsTrashPage } from '~/stores/context';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
@@ -21,14 +20,14 @@ const onDeletedHandler = (pathOrPathsToDelete) => {
 
 export const TrashPageAlert = (): JSX.Element => {
   const { t } = useTranslation();
+  const router = useRouter();
 
   const { data: isAbleToShowTrashPageManagementButtons } = useIsAbleToShowTrashPageManagementButtons();
-  const { data: shareLinkId } = useShareLinkId();
   const { data: pageData } = useSWRxCurrentPage();
   const { data: isTrashPage } = useIsTrashPage();
   const pageId = pageData?._id;
   const pagePath = pageData?.path;
-  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
 
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -39,7 +38,7 @@ export const TrashPageAlert = (): JSX.Element => {
   }
 
 
-  const lastUpdateUserName = pageData?.lastUpdateUser?.name;
+  const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
 
@@ -49,7 +48,7 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
     }
     const putBackedHandler = () => {
-      window.location.reload();
+      router.push(`/${pageId}`);
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
   }
@@ -98,9 +97,9 @@ export const TrashPageAlert = (): JSX.Element => {
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           <br />
-          <UserPicture user={{ username: lastUpdateUserName }} />
+          <UserPicture user={deleteUser} />
           <span className="ml-2">
-            Deleted by { lastUpdateUserName } at {deletedAt || pageData?.updatedAt}
+            Deleted by { deleteUser?.name } at {deletedAt || pageData?.updatedAt}
           </span>
         </div>
         <div className="pt-1 d-flex align-items-end align-items-lg-center">

+ 6 - 2
packages/app/src/components/PageAttachment.tsx

@@ -5,7 +5,8 @@ import React, {
 import { HasObjectId, IAttachment } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
-import { useEditingMarkdown, useCurrentPageId, useIsGuestUser } from '~/stores/context';
+import { useCurrentPageId, useIsGuestUser } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
 
 import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
@@ -17,10 +18,13 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 };
 
 const PageAttachment = (): JSX.Element => {
+
+  const { data: currentPage } = useSWRxCurrentPage();
+  const markdown = currentPage?.revision.body;
+
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
-  const { data: markdown } = useEditingMarkdown();
 
   // States
   const [pageNumber, setPageNumber] = useState(1);

+ 9 - 0
packages/app/src/components/PageAttachment/DeleteAttachmentModal.module.scss

@@ -0,0 +1,9 @@
+.attachment-delete-modal :global {
+  .attachment-delete-image {
+    text-align: center;
+
+    img {
+      max-width: 100%;
+    }
+  }
+}

+ 9 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -10,6 +10,8 @@ import {
 
 import Username from '../User/Username';
 
+import styles from './DeleteAttachmentModal.module.scss';
+
 
 function iconNameByFormat(format: string): string {
   if (format.match(/image\/.+/i)) {
@@ -74,7 +76,13 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
 
 
   return (
-    <Modal isOpen={isOpen} className="attachment-delete-modal" size="lg" aria-labelledby="contained-modal-title-lg" fade={false}>
+    <Modal
+      isOpen={isOpen}
+      className={`${styles['attachment-delete-modal']} attachment-delete-modal`}
+      size="lg"
+      aria-labelledby="contained-modal-title-lg"
+      fade={false}
+    >
       <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
         <span id="contained-modal-title-lg">Delete attachment?</span>
       </ModalHeader>

+ 11 - 15
packages/app/src/components/PageAttachment/PageAttachmentList.tsx

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

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

@@ -15,8 +15,8 @@ import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import {
-  useCurrentPagePath, useCurrentPathname, useCurrentPageId, useEditingMarkdown,
-  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage,
+  useCurrentPagePath, useCurrentPathname, useCurrentPageId,
+  useIsEditable, useIsIndentSizeForced, useIsUploadableFile, useIsUploadableImage, useEditingMarkdown,
 } from '~/stores/context';
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
@@ -55,10 +55,9 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
-  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageTags } = usePageTagsForEditors(pageId);
-
+  const { data: editingMarkdown } = useEditingMarkdown();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
   const { data: isMobile } = useIsMobile();
@@ -85,6 +84,20 @@ const PageEditor = React.memo((): JSX.Element => {
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
+
+  // const optionsToSave = useMemo(() => {
+  //   if (grantData == null) {
+  //     return;
+  //   }
+  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
+  //   const optionsToSave = getOptionsToSave(
+  //     isSlackEnabled ?? false, slackChannels,
+  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
+  //     pageTags || [],
+  //   );
+  //   return optionsToSave;
+  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     setMarkdownToPreview(value);
@@ -428,6 +441,7 @@ const PageEditor = React.memo((): JSX.Element => {
         onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
         pageContainer={pageContainer}
         markdownOnEdit={markdown}
+        optionsToSave={optionsToSave}
       /> */}
     </div>
   );

+ 27 - 17
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useState, useEffect, useRef, useMemo, useCallback,
 } from 'react';
 
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import CodeMirror from 'codemirror/lib/codemirror';
 import { format } from 'date-fns';
@@ -10,11 +11,10 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { IUser } from '~/interfaces/user';
+import type { OptionsToSave } from '~/interfaces/editor-settings';
 import { useCurrentUser } from '~/stores/context';
 import { useEditorMode } from '~/stores/ui';
 
-import PageContainer from '../../client/services/PageContainer';
 import { IRevisionOnConflict } from '../../interfaces/revision';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
@@ -29,8 +29,9 @@ Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
 type ConflictDiffModalProps = {
   isOpen?: boolean;
   onClose?: (() => void);
-  pageContainer: PageContainer;
+  // pageContainer: PageContainer;
   markdownOnEdit: string;
+  optionsToSave: OptionsToSave | undefined;
 };
 
 type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
@@ -38,7 +39,7 @@ type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'>
 }
 
 const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IUser }): JSX.Element => {
-  const { currentUser, pageContainer, onClose } = props;
+  const { currentUser, onClose } = props;
 
   const { data: editorMode } = useEditorMode();
 
@@ -59,16 +60,25 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
     user: currentUser,
   };
   const origin: IRevisionOnConflictWithStringDate = {
-    revisionId: pageContainer.state.revisionId || '',
-    revisionBody: pageContainer.state.markdown || '',
-    createdAt: pageContainer.state.updatedAt || '',
-    user: pageContainer.state.revisionAuthor,
+    // revisionId: pageContainer.state.revisionId || '',
+    // revisionBody: pageContainer.state.markdown || '',
+    // createdAt: pageContainer.state.updatedAt || '',
+    // user: pageContainer.state.revisionAuthor,
+    revisionId:  '',
+    revisionBody: '',
+    createdAt: '',
+    user: {} as IUser,
   };
   const latest: IRevisionOnConflictWithStringDate = {
-    revisionId: pageContainer.state.remoteRevisionId || '',
-    revisionBody: pageContainer.state.remoteRevisionBody || '',
-    createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
-    user: pageContainer.state.lastUpdateUser,
+    // revisionId: pageContainer.state.remoteRevisionId || '',
+    // revisionBody: pageContainer.state.remoteRevisionBody || '',
+    // createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    // user: pageContainer.state.lastUpdateUser,
+    revisionId: '',
+    revisionBody: '',
+    createdAt: format(new Date(''), 'yyyy/MM/dd HH:mm:ss'),
+    user: {} as IUser,
+
   };
 
   useEffect(() => {
@@ -101,15 +111,15 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
     const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
 
     try {
-      await pageContainer.resolveConflict(codeMirrorVal, editorMode);
-      close();
-      pageContainer.showSuccessToastr();
+      // await pageContainer.resolveConflict(codeMirrorVal, editorMode, props.optionsToSave);
+      // close();
+      // pageContainer.showSuccessToastr();
     }
     catch (error) {
-      pageContainer.showErrorToastr(error);
+      // pageContainer.showErrorToastr(error);
     }
 
-  }, [editorMode, close, pageContainer]);
+  }, []);
 
   const resizeAndCloseButtons = useMemo(() => (
     <div className="d-flex flex-nowrap">

+ 5 - 1
packages/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 
 const ApiErrorMessage = (props) => {
   const { t } = useTranslation();
@@ -43,6 +43,10 @@ const ApiErrorMessage = (props) => {
         return (
           <strong><i className="icon-fw icon-ban"></i> Invalid path</strong>
         );
+      case 'single_deletion_empty_pages':
+        return (
+          <strong><i className="icon-fw icon-ban"></i>{ t('page_api_error.single_deletion_empty_pages') }</strong>
+        );
       default:
         return (
           <strong><i className="icon-fw icon-ban"></i> Unknown error occured</strong>

+ 12 - 17
packages/app/src/components/PageStatusAlert.jsx

@@ -3,8 +3,8 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
+// import AppContainer from '~/client/services/AppContainer';
+// import PageContainer from '~/client/services/PageContainer';
 import Username from '~/components/User/Username';
 
 import { withUnstatedContainers } from './UnstatedUtils';
@@ -73,15 +73,15 @@ class PageStatusAlert extends React.Component {
   }
 
   getContentsForUpdatedAlert() {
-    const { t, appContainer, pageContainer } = this.props;
-    const pageEditor = appContainer.getComponentInstance('PageEditor');
+    const { t } = this.props;
+    // const pageEditor = appContainer.getComponentInstance('PageEditor');
 
-    let isConflictOnEdit = false;
+    const isConflictOnEdit = false;
 
-    if (pageEditor != null) {
-      const markdownOnEdit = pageEditor.getMarkdown();
-      isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
-    }
+    // if (pageEditor != null) {
+    //   const markdownOnEdit = pageEditor.getMarkdown();
+    //   isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
+    // }
 
     // TODO: re-impl with Next.js way
     // const usernameComponentToString = ReactDOMServer.renderToString(<Username user={pageContainer.state.lastUpdateUser} />);
@@ -165,8 +165,8 @@ class PageStatusAlert extends React.Component {
 PageStatusAlert.propTypes = {
   t: PropTypes.func.isRequired, // i18next
 
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 };
 
 const PageStatusAlertWrapperFC = (props) => {
@@ -174,9 +174,4 @@ const PageStatusAlertWrapperFC = (props) => {
   return <PageStatusAlert t={t} {...props} />;
 };
 
-/**
- * Wrapper component for using unstated
- */
-const PageStatusAlertWrapper = withUnstatedContainers(PageStatusAlertWrapperFC, [AppContainer, PageContainer]);
-
-export default PageStatusAlertWrapper;
+export default PageStatusAlertWrapperFC;

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

@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import React, { useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
@@ -63,6 +63,7 @@ const PutBackPageModal = () => {
       </>
     );
   };
+
   const BodyContent = () => {
     if (!isOpened) {
       return <></>;
@@ -106,9 +107,14 @@ const PutBackPageModal = () => {
     );
   };
 
+  const closeModalHandler = useCallback(() => {
+    closePutBackPageModal();
+    setErrs(null);
+  }, [closePutBackPageModal]);
+
   return (
-    <Modal isOpen={isOpened} toggle={closePutBackPageModal} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={closePutBackPageModal} className="bg-info text-light">
+    <Modal isOpen={isOpened} toggle={closeModalHandler} className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-info text-light">
         <HeaderContent/>
       </ModalHeader>
       <ModalBody>

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

@@ -43,8 +43,8 @@ const PageTree: FC = memo(() => {
           <h3 className="mb-0">{t('Page Tree')}</h3>
         </div>
         <div className="mt-5 mx-2 text-center">
-          <h3 className="text-gray">{t('admin:v5_page_migration.page_tree_not_avaliable')}</h3>
-          <a href="/admin">{t('admin:v5_page_migration.go_to_settings')}</a>
+          <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
+          <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
         </div>
       </>
     );

+ 5 - 1
packages/app/src/components/Sidebar/Tag.tsx

@@ -1,6 +1,7 @@
 import React, { FC, useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import { useRouter } from 'next/router';
 
 import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
@@ -13,6 +14,9 @@ const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
 
 const Tag: FC = () => {
+
+  const router = useRouter();
+
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
 
@@ -74,7 +78,7 @@ const Tag: FC = () => {
         <button
           className="btn btn-primary rounded px-4"
           type="button"
-          onClick={() => { window.location.href = '/tags' }}
+          onClick={() => router.push('/tags')}
         >
           {t('Check All tags')}
         </button>

+ 7 - 2
packages/app/src/components/TableOfContents.tsx

@@ -1,8 +1,9 @@
 import React, { useCallback } from 'react';
 
+import { pagePathUtils } from '@growi/core';
 import ReactMarkdown from 'react-markdown';
 
-import { useIsUserPage } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/context';
 import { useTocOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -10,12 +11,16 @@ import { StickyStretchableScroller } from './StickyStretchableScroller';
 
 import styles from './TableOfContents.module.scss';
 
+const { isUserPage: _isUserPage } = pagePathUtils;
+
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 
 const TableOfContents = (): JSX.Element => {
 
-  const { data: isUserPage } = useIsUserPage();
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const isUserPage = currentPagePath != null && _isUserPage(currentPagePath);
 
   // const [tocHtml, setTocHtml] = useState('');
 

+ 9 - 5
packages/app/src/components/TagList.tsx

@@ -3,6 +3,7 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
 
 import { IDataTagCount } from '~/interfaces/tag';
 
@@ -33,14 +34,17 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
       const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
 
       return (
-        <a
+        <Link
           key={tag._id}
           href={`/_search?q=tag:${encodeURIComponent(tag.name)}`}
-          className={tagListClasses}
         >
-          <div className="text-truncate list-tag-name">{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>
+          <a
+            className={tagListClasses}
+          >
+            <div className="text-truncate list-tag-name">{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>
+        </Link>
       );
     });
   }, []);

+ 53 - 5
packages/app/src/components/TrashPageList.tsx

@@ -1,15 +1,67 @@
-import React, { FC, useMemo } from 'react';
+import React, { FC, useMemo, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { toastSuccess } from '~/client/util/apiNotification';
+import {
+  IPageHasId,
+} from '~/interfaces/page';
+import { IPagingResult } from '~/interfaces/paging-result';
+import { useShowPageLimitationXL } from '~/stores/context';
+import { useEmptyTrashModal } from '~/stores/modal';
+import { useSWRxDescendantsPageListForCurrrentPath, useSWRxPageInfoForList } from '~/stores/page-listing';
+
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import EmptyTrashButton from './EmptyTrashButton';
 import PageListIcon from './Icons/PageListIcon';
 
+const convertToIDataWithMeta = (page) => {
+  return { data: page };
+};
+
+const useEmptyTrashButton = () => {
+
+  const { data: limit } = useShowPageLimitationXL();
+  const { data: pagingResult, mutate: mutatePageLists } = useSWRxDescendantsPageListForCurrrentPath(1, limit);
+  const { t } = useTranslation();
+  const { open: openEmptyTrashModal } = useEmptyTrashModal();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
+
+  const calculateDeletablePages = useCallback((pagingResult?: IPagingResult<IPageHasId>) => {
+    if (pagingResult == null) { return undefined }
+
+    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    const pageWithMetas = injectTo(dataWithMetas);
+
+    return pageWithMetas.filter(page => page.meta?.isAbleToDeleteCompletely);
+  }, [injectTo]);
+
+  const deletablePages = calculateDeletablePages(pagingResult);
+
+  const onEmptiedTrashHandler = useCallback(() => {
+    toastSuccess(t('empty_trash'));
+
+    mutatePageLists();
+  }, [t, mutatePageLists]);
+
+  const emptyTrashClickHandler = useCallback(() => {
+    if (deletablePages == null) { return }
+    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDeleteAllPages: pagingResult?.totalCount === deletablePages.length });
+  }, [deletablePages, onEmptiedTrashHandler, openEmptyTrashModal, pagingResult?.totalCount]);
+
+  const emptyTrashButton = useMemo(() => {
+    return <EmptyTrashButton onEmptyTrashButtonClick={emptyTrashClickHandler} disableEmptyButton={deletablePages?.length === 0} />;
+  }, [emptyTrashClickHandler, deletablePages?.length]);
+
+  return emptyTrashButton;
+};
 
 export const TrashPageList: FC = () => {
   const { t } = useTranslation();
+  const emptyTrashButton = useEmptyTrashButton();
 
   const navTabMapping = useMemo(() => {
     return {
@@ -22,10 +74,6 @@ export const TrashPageList: FC = () => {
     };
   }, [t]);
 
-  const emptyTrashButton = useMemo(() => {
-    return <EmptyTrashButton />;
-  }, []);
-
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
       <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />

+ 0 - 38
packages/app/src/components/User/LikerList.jsx

@@ -1,38 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import UserPictureList from './UserPictureList';
-
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-import PageContainer from '~/client/services/PageContainer';
-
-class LikerList extends React.Component {
-
-  render() {
-    const { pageContainer } = this.props;
-    return (
-      <div className="user-list-content text-truncate text-muted text-right">
-        <span className="text-info">
-          <span className="liker-user-count">{pageContainer.state.sumOfLikers}</span>
-          <i className="fa fa-fw fa-heart-o"></i>
-        </span>
-        <span className="mr-1">
-          <UserPictureList users={pageContainer.state.likerUsers} />
-        </span>
-      </div>
-    );
-  }
-
-}
-
-LikerList.propTypes = {
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const LikerListWrapper = withUnstatedContainers(LikerList, [PageContainer]);
-
-export default (LikerListWrapper);

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

@@ -58,14 +58,14 @@ import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import {
   useCurrentUser, useCurrentPagePath,
   useIsLatestRevision,
-  useIsForbidden, useIsNotFound, useIsTrashPage, useIsSharedUser,
+  useIsForbidden, useIsNotFound, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useDrawioUri, useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
-  useIsAclEnabled, useIsUserPage, useIsSearchPage,
+  useIsAclEnabled, useIsSearchPage,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useEditingMarkdown,
-  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useLayoutSetting, useCustomizedLogoSrc,
 } from '../stores/context';
 
 import {
@@ -187,6 +187,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // commons
   useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
+  useCustomizedLogoSrc(props.customizedLogoSrc);
 
   // UserUISettings
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
@@ -238,14 +239,13 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
   useCurrentPageId(pageId ?? null);
-  useIsUserPage(pagePath != null && isUserPage(pagePath));
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPagePath(pagePath);
   useCurrentPathname(props.currentPathname);
-  useIsTrashPage(pagePath != null && _isTrashPage(pagePath));
 
   useSWRxCurrentPage(undefined, pageWithMeta?.data ?? null); // store initial data
   useEditingMarkdown(pageWithMeta?.data.revision?.body ?? '');
+
   const { data: dataPageInfo } = useSWRxPageInfo(pageId);
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
   const { mutate: mutateSelectedGrant } = useSelectedGrant();

+ 2 - 1
packages/app/src/pages/_app.page.tsx

@@ -9,7 +9,7 @@ import * as nextI18nConfig from '^/config/next-i18next.config';
 
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
-  useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl,
+  useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
 } from '~/stores/context';
 import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
 
@@ -53,6 +53,7 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useConfidential(commonPageProps.confidential);
   useGrowiTheme(commonPageProps.theme);
   useGrowiVersion(commonPageProps.growiVersion);
+  useCustomizedLogoSrc(commonPageProps.customizedLogoSrc);
 
   return (
     <SWRConfig value={swrConfig}>

+ 0 - 347
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -1,347 +0,0 @@
-import React from 'react';
-
-import { isClient, objectIdUtils } from '@growi/core';
-import {
-  NextPage, GetServerSideProps, GetServerSidePropsContext,
-} from 'next';
-import { useTranslation } from 'next-i18next';
-import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
-import dynamic from 'next/dynamic';
-import { useRouter } from 'next/router';
-import { Container, Provider } from 'unstated';
-
-import AdminAppContainer from '~/client/services/AdminAppContainer';
-import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
-import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
-import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
-import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
-import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
-import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
-import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
-import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
-import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
-import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
-import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import { SupportedActionType } from '~/interfaces/activity';
-import { CrowiRequest } from '~/interfaces/crowi-request';
-import PluginUtils from '~/server/plugins/plugin-utils';
-import ConfigLoader from '~/server/service/config-loader';
-import {
-  useCurrentUser, /* useSearchServiceConfigured, */ useIsAclEnabled, useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
-  useAuditLogEnabled, useAuditLogAvailableActions, useIsSearchPage, useCustomizeTitle,
-} from '~/stores/context';
-import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
-
-import {
-  CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
-} from '../utils/commons';
-
-
-// import { useEnvVars } from '~/stores/admin-context';
-
-const AdminHome = dynamic(() => import('../../components/Admin/AdminHome/AdminHome'), { ssr: false });
-const AppSettingsPageContents = dynamic(() => import('../../components/Admin/App/AppSettingsPageContents'), { ssr: false });
-const SecurityManagement = dynamic(() => import('../../components/Admin/Security/SecurityManagement'), { ssr: false });
-const MarkDownSettingContents = dynamic(() => import('../../components/Admin/MarkdownSetting/MarkDownSettingContents'), { ssr: false });
-const CustomizeSettingContents = dynamic(() => import('../../components/Admin/Customize/Customize'), { ssr: false });
-const DataImportPageContents = dynamic(() => import('../../components/Admin/ImportData/ImportDataPageContents'), { ssr: false });
-const ExportArchiveDataPage = dynamic(() => import('../../components/Admin/ExportArchiveDataPage'), { ssr: false });
-const NotificationSetting = dynamic(() => import('../../components/Admin/Notification/NotificationSetting'), { ssr: false });
-const ManageGlobalNotification = dynamic(() => import('../../components/Admin/Notification/ManageGlobalNotification'), { ssr: false });
-const SlackIntegration = dynamic(() => import('../../components/Admin/SlackIntegration/SlackIntegration'), { ssr: false });
-const LegacySlackIntegration = dynamic(() => import('../../components/Admin/LegacySlackIntegration/LegacySlackIntegration'), { ssr: false });
-const UserManagement = dynamic(() => import('../../components/Admin/UserManagement'), { ssr: false });
-const ManageExternalAccount = dynamic(() => import('../../components/Admin/ManageExternalAccount'), { ssr: false });
-const FullTextSearchManagement = dynamic(
-  () => import('../../components/Admin/FullTextSearchManagement').then(mod => mod.FullTextSearchManagement), { ssr: false },
-);
-const UserGroupDetailPage = dynamic(() => import('../../components/Admin/UserGroupDetail/UserGroupDetailPage'), { ssr: false });
-const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
-// named export
-const UserGroupPage = dynamic(() => import('../../components/Admin/UserGroup/UserGroupPage').then(mod => mod.UserGroupPage), { ssr: false });
-const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(mod => mod.AuditLogManagement), { ssr: false });
-
-const pluginUtils = new PluginUtils();
-
-type Props = CommonProps & {
-  currentUser: any,
-
-  nodeVersion: string,
-  npmVersion: string,
-  yarnVersion: string,
-  installedPlugins: any,
-  envVars: any,
-  isAclEnabled: boolean,
-  isSearchServiceConfigured: boolean,
-  isSearchServiceReachable: boolean,
-  isMailerSetup: boolean,
-  auditLogEnabled: boolean,
-  auditLogAvailableActions: SupportedActionType[],
-
-  customizeTitle: string,
-  siteUrl: string,
-};
-
-const AdminPage: NextPage<Props> = (props: Props) => {
-
-  const { t } = useTranslation('admin');
-  const router = useRouter();
-  const { path } = router.query;
-  const pagePathKeys: string[] = Array.isArray(path) ? path : ['home'];
-
-  /*
-  * Set userGroupId as a adminPagesMap key
-  * eg) In case that url is `/user-group-detail/62e8388a9a649bea5e703ef7`, userGroupId will be 62e8388a9a649bea5e703ef7
-  */
-  let userGroupId;
-  const [firstPath, secondPath] = pagePathKeys;
-  if (firstPath === 'user-group-detail') {
-    userGroupId = objectIdUtils.isValidObjectId(secondPath) ? secondPath : undefined;
-  }
-
-  // TODO: refactoring adminPagesMap => https://redmine.weseek.co.jp/issues/102694
-  const adminPagesMap = {
-    home: {
-      title:  t('wiki_management_home_page'),
-      component: <AdminHome
-        nodeVersion={props.nodeVersion}
-        npmVersion={props.npmVersion}
-        yarnVersion={props.yarnVersion}
-        installedPlugins={props.installedPlugins}
-      />,
-    },
-    app: {
-      title: t('commons:headers.app_settings'),
-      component: <AppSettingsPageContents />,
-    },
-    security: {
-      title: t('security_settings.security_settings'),
-      component: <SecurityManagement />,
-    },
-    markdown: {
-      title: t('markdown_settings.markdown_settings'),
-      component: <MarkDownSettingContents />,
-    },
-    customize: {
-      title: t('customize_settings.customize_settings'),
-      component: <CustomizeSettingContents />,
-    },
-    importer: {
-      title: t('importer_management.import_data'),
-      component: <DataImportPageContents />,
-    },
-    export: {
-      title: t('export_archive_data'),
-      component: <ExportArchiveDataPage />,
-    },
-    notification: {
-      title: t('external_notification.external_notification'),
-      component: <NotificationSetting />,
-    },
-    'global-notification': {
-      new: {
-        title: t('external_notification.external_notification'),
-        component: <ManageGlobalNotification />,
-      },
-    },
-    'slack-integration': {
-      title: t('slack_integration.slack_integration'),
-      component: <SlackIntegration />,
-    },
-    'slack-integration-legacy': {
-      title: t('slack_integration_legacy.slack_integration_legacy'),
-      component: <LegacySlackIntegration />,
-    },
-    users: {
-      title: t('user_management.user_management'),
-      component: <UserManagement />,
-      'external-accounts': {
-        title: t('user_management.external_account'),
-        component: <ManageExternalAccount />,
-      },
-    },
-    'user-groups': {
-      title:  t('user_group_management.user_group_management'),
-      component: <UserGroupPage />,
-    },
-    'user-group-detail': {
-      [userGroupId]: {
-        title: t('user_group_management.user_group_management'),
-        component: <UserGroupDetailPage userGroupId={userGroupId} />,
-      },
-    },
-    search: {
-      title: t('full_text_search_management.full_text_search_management'),
-      component: <FullTextSearchManagement />,
-    },
-    'audit-log': {
-      title: t('audit_log_management.audit_log'),
-      component: <AuditLogManagement />,
-    },
-  };
-
-  const getTargetPageToRender = (pagesMap, keys): {title: string, component: JSX.Element} => {
-    return keys.reduce((pagesMap, key) => {
-      return pagesMap[key];
-    }, pagesMap);
-  };
-
-  const targetPage = getTargetPageToRender(adminPagesMap, pagePathKeys);
-
-  useIsSearchPage(false);
-  useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
-  useIsMailerSetup(props.isMailerSetup);
-  useIsMaintenanceMode(props.isMaintenanceMode);
-
-  // useSearchServiceConfigured(props.isSearchServiceConfigured);
-  useIsSearchServiceReachable(props.isSearchServiceReachable);
-
-  useIsAclEnabled(props.isAclEnabled);
-  useSiteUrl(props.siteUrl);
-
-  // useEnvVars(props.envVars);
-
-  useAuditLogEnabled(props.auditLogEnabled);
-  useAuditLogAvailableActions(props.auditLogAvailableActions);
-
-  useCustomizeTitle(props.customizeTitle);
-
-  const injectableContainers: Container<any>[] = [];
-
-  if (isClient()) {
-    // Create unstated container instances (except Security)
-    const adminAppContainer = new AdminAppContainer();
-    const adminImportContainer = new AdminImportContainer();
-    const adminHomeContainer = new AdminHomeContainer();
-    const adminCustomizeContainer = new AdminCustomizeContainer();
-    const adminUsersContainer = new AdminUsersContainer();
-    const adminExternalAccountsContainer = new AdminExternalAccountsContainer();
-    const adminNotificationContainer = new AdminNotificationContainer();
-    const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
-    const adminMarkDownContainer = new AdminMarkDownContainer();
-
-    injectableContainers.push(
-      adminAppContainer,
-      adminImportContainer,
-      adminHomeContainer,
-      adminCustomizeContainer,
-      adminUsersContainer,
-      adminExternalAccountsContainer,
-      adminNotificationContainer,
-      adminSlackIntegrationLegacyContainer,
-      adminMarkDownContainer,
-    );
-  }
-
-
-  const adminSecurityContainers: Container<any>[] = [];
-
-  if (isClient()) {
-    const adminSecuritySettingElem = document.getElementById('admin-security-setting');
-
-    if (adminSecuritySettingElem != null) {
-      // Create unstated container instances (Security)
-      const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer();
-      const adminLocalSecurityContainer = new AdminLocalSecurityContainer();
-      const adminLdapSecurityContainer = new AdminLdapSecurityContainer();
-      const adminSamlSecurityContainer = new AdminSamlSecurityContainer();
-      const adminOidcSecurityContainer = new AdminOidcSecurityContainer();
-      const adminBasicSecurityContainer = new AdminBasicSecurityContainer();
-      const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer();
-      const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer();
-      const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer();
-
-      adminSecurityContainers.push(
-        adminGeneralSecurityContainer,
-        adminLocalSecurityContainer,
-        adminLdapSecurityContainer,
-        adminSamlSecurityContainer,
-        adminOidcSecurityContainer,
-        adminBasicSecurityContainer,
-        adminGoogleSecurityContainer,
-        adminGitHubSecurityContainer,
-        adminTwitterSecurityContainer,
-      );
-    }
-  }
-
-
-  return (
-    <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
-      <AdminLayout title={useCustomTitle(props, targetPage.title)} selectedNavOpt={firstPath} componentTitle={targetPage.title}>
-        {targetPage.component}
-      </AdminLayout>
-    </Provider>
-  );
-};
-
-
-async function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): Promise<void> {
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi } = req;
-  const {
-    appService, mailService, aclService, searchService, activityService,
-  } = crowi;
-
-  props.siteUrl = appService.getSiteUrl();
-  props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
-  props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
-  props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
-  props.installedPlugins = pluginUtils.listPlugins();
-  props.envVars = await ConfigLoader.getEnvVarsForDisplay(true);
-  props.isAclEnabled = aclService.isAclEnabled();
-
-  props.isSearchServiceConfigured = searchService.isConfigured;
-  props.isSearchServiceReachable = searchService.isReachable;
-
-  props.isMailerSetup = mailService.isMailerSetup;
-
-  props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
-  props.auditLogAvailableActions = activityService.getAvailableActions(false);
-  props.customizeTitle = crowi.configManager.getConfig('crowi', 'customize:title');
-}
-
-/**
- * for Server Side Translations
- * @param context
- * @param props
- * @param namespacesRequired
- */
-async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
-  // preload all languages because of language lists in user setting
-  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired, true);
-  props._nextI18Next = nextI18NextConfig._nextI18Next;
-}
-
-export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req: CrowiRequest = context.req as CrowiRequest;
-
-  const { user } = req;
-  const result = await getServerSideCommonProps(context);
-
-  // check for presence
-  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
-  if (!('props' in result)) {
-    throw new Error('invalid getSSP result');
-  }
-  const props: Props = result.props as Props;
-  if (user != null) {
-    // props.currentUser = JSON.stringify(user.toObject());
-    props.currentUser = JSON.stringify(user);
-  }
-
-  injectServerConfigurations(context, props);
-  await injectNextI18NextConfigurations(context, props, ['admin', 'commons']);
-
-  return {
-    props,
-  };
-};
-
-export default AdminPage;

+ 46 - 0
packages/app/src/pages/admin/app.page.tsx

@@ -0,0 +1,46 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const AppSettingsPageContents = dynamic(() => import('~/components/Admin/App/AppSettingsPageContents'), { ssr: false });
+
+
+const AdminAppPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+  useIsMaintenanceMode(props.isMaintenanceMode);
+
+  const title = t('commons:headers.app_settings');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminAppContainer = new AdminAppContainer();
+    injectableContainers.push(adminAppContainer);
+  }
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <AppSettingsPageContents />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminAppPage;

+ 56 - 0
packages/app/src/pages/admin/audit-log.page.tsx

@@ -0,0 +1,56 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+
+import { SupportedActionType } from '~/interfaces/activity';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+
+const AuditLogManagement = dynamic(() => import('~/components/Admin/AuditLogManagement').then(mod => mod.AuditLogManagement), { ssr: false });
+
+
+type Props = CommonProps & {
+  auditLogEnabled: boolean,
+  auditLogAvailableActions: SupportedActionType[],
+};
+
+
+const AdminAuditLogPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useAuditLogEnabled(props.auditLogEnabled);
+  useAuditLogAvailableActions(props.auditLogAvailableActions);
+
+  const title = t('audit_log_management.audit_log');
+
+  return (
+    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <AuditLogManagement />
+    </AdminLayout>
+  );
+
+};
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { activityService } = crowi;
+
+  props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
+  props.auditLogAvailableActions = activityService.getAvailableActions(false);
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminAuditLogPage;

+ 62 - 0
packages/app/src/pages/admin/customize.page.tsx

@@ -0,0 +1,62 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCustomizeTitle } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const CustomizeSettingContents = dynamic(() => import('~/components/Admin//Customize/Customize'), { ssr: false });
+
+
+type Props = CommonProps & {
+  customizeTitle: string,
+};
+
+
+const AdminCustomizeSettingsPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useCustomizeTitle(props.customizeTitle);
+
+  const title = t('customize_settings.customize_settings');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminCustomizeContainer = new AdminCustomizeContainer();
+
+    injectableContainers.push(adminCustomizeContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <CustomizeSettingContents />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+
+  props.customizeTitle = crowi.configManager.getConfig('crowi', 'customize:title');
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminCustomizeSettingsPage;

+ 45 - 0
packages/app/src/pages/admin/export.page.tsx

@@ -0,0 +1,45 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const ExportArchiveDataPage = dynamic(() => import('~/components/Admin/ExportArchiveDataPage'), { ssr: false });
+
+
+const AdminExportDataArchivePage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('export_management.export_archive_data');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminAppContainer = new AdminAppContainer();
+    injectableContainers.push(adminAppContainer);
+  }
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <ExportArchiveDataPage />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminExportDataArchivePage;

+ 46 - 0
packages/app/src/pages/admin/global-notification/new.page.tsx

@@ -0,0 +1,46 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { retrieveServerSideProps } from '../../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const ManageGlobalNotification = dynamic(() => import('~/components/Admin/Notification/ManageGlobalNotification'), { ssr: false });
+
+
+const AdminGlobalNotificationNewPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation();
+
+  const title = t('external_notification.external_notification');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminNotificationContainer = new AdminNotificationContainer();
+    injectableContainers.push(adminNotificationContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <ManageGlobalNotification />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminGlobalNotificationNewPage;

+ 47 - 0
packages/app/src/pages/admin/importer.page.tsx

@@ -0,0 +1,47 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminImportContainer from '~/client/services/AdminImportContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const DataImportPageContents = dynamic(() => import('~/components/Admin/ImportData/ImportDataPageContents'), { ssr: false });
+
+
+const AdminDataImportPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('importer_management.import_data');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminImportContainer = new AdminImportContainer();
+    injectableContainers.push(adminImportContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <DataImportPageContents />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminDataImportPage;

+ 74 - 0
packages/app/src/pages/admin/index.page.tsx

@@ -0,0 +1,74 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import PluginUtils from '~/server/plugins/plugin-utils';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const AdminHome = dynamic(() => import('~/components/Admin/AdminHome/AdminHome'), { ssr: false });
+
+
+type Props = CommonProps & {
+  nodeVersion: string,
+  npmVersion: string,
+  yarnVersion: string,
+  installedPlugins: any,
+};
+
+
+const AdminHomePage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('wiki_management_home_page');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminHomeContainer = new AdminHomeContainer();
+
+    injectableContainers.push(adminHomeContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <AdminHome
+          nodeVersion={props.nodeVersion}
+          npmVersion={props.npmVersion}
+          yarnVersion={props.yarnVersion}
+          installedPlugins={props.installedPlugins}
+        />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const pluginUtils = new PluginUtils();
+
+  props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
+  props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
+  props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
+  props.installedPlugins = pluginUtils.listPlugins();
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminHomePage;

+ 47 - 0
packages/app/src/pages/admin/markdown.page.tsx

@@ -0,0 +1,47 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const MarkDownSettingContents = dynamic(() => import('~/components/Admin/MarkdownSetting/MarkDownSettingContents'), { ssr: false });
+
+
+const AdminMarkdownPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('markdown_settings.markdown_settings');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminMarkDownContainer = new AdminMarkDownContainer();
+    injectableContainers.push(adminMarkDownContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <MarkDownSettingContents />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminMarkdownPage;

+ 48 - 0
packages/app/src/pages/admin/notification.page.tsx

@@ -0,0 +1,48 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const NotificationSetting = dynamic(() => import('~/components/Admin/Notification/NotificationSetting'), { ssr: false });
+
+
+const AdminExternalNotificationPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('external_notification.external_notification');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminNotificationContainer = new AdminNotificationContainer();
+
+    injectableContainers.push(adminNotificationContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <NotificationSetting />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminExternalNotificationPage;

+ 52 - 0
packages/app/src/pages/admin/search.page.tsx

@@ -0,0 +1,52 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useIsSearchServiceReachable } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const FullTextSearchManagement = dynamic(
+  () => import('~/components/Admin//FullTextSearchManagement').then(mod => mod.FullTextSearchManagement), { ssr: false },
+);
+
+
+type Props = CommonProps & {
+  isSearchServiceReachable: boolean,
+};
+
+
+const AdminFullTextSearchManagementPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+
+  const title = t('full_text_search_management.full_text_search_management');
+
+  return (
+    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <FullTextSearchManagement />
+    </AdminLayout>
+  );
+};
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { searchService } = crowi;
+
+  props.isSearchServiceReachable = searchService.isReachable;
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminFullTextSearchManagementPage;

+ 98 - 0
packages/app/src/pages/admin/security.page.tsx

@@ -0,0 +1,98 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useIsMailerSetup, useSiteUrl } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const SecurityManagement = dynamic(() => import('~/components/Admin/Security/SecurityManagement'), { ssr: false });
+
+
+type Props = CommonProps & {
+  isMailerSetup: boolean,
+  siteUrl: string,
+};
+
+
+const AdminSecuritySettingsPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useSiteUrl(props.siteUrl);
+  useIsMailerSetup(props.isMailerSetup);
+
+
+  const title = t('security_settings.security_settings');
+  const adminSecurityContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminSecuritySettingElem = document.getElementById('admin-security-setting');
+
+    if (adminSecuritySettingElem != null) {
+      const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer();
+      const adminLocalSecurityContainer = new AdminLocalSecurityContainer();
+      const adminLdapSecurityContainer = new AdminLdapSecurityContainer();
+      const adminSamlSecurityContainer = new AdminSamlSecurityContainer();
+      const adminOidcSecurityContainer = new AdminOidcSecurityContainer();
+      const adminBasicSecurityContainer = new AdminBasicSecurityContainer();
+      const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer();
+      const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer();
+      const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer();
+
+      adminSecurityContainers.push(
+        adminGeneralSecurityContainer,
+        adminLocalSecurityContainer,
+        adminLdapSecurityContainer,
+        adminSamlSecurityContainer,
+        adminOidcSecurityContainer,
+        adminBasicSecurityContainer,
+        adminGoogleSecurityContainer,
+        adminGitHubSecurityContainer,
+        adminTwitterSecurityContainer,
+      );
+    }
+  }
+
+
+  return (
+    <Provider inject={[...adminSecurityContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <SecurityManagement />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { appService, mailService } = crowi;
+
+  props.siteUrl = appService.getSiteUrl();
+  props.isMailerSetup = mailService.isMailerSetup;
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminSecuritySettingsPage;

+ 47 - 0
packages/app/src/pages/admin/slack-integration-legacy.page.tsx

@@ -0,0 +1,47 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const LegacySlackIntegration = dynamic(() => import('~/components/Admin/LegacySlackIntegration/LegacySlackIntegration'), { ssr: false });
+
+
+const AdminLegacySlackIntegrationPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('slack_integration_legacy.slack_integration_legacy');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
+    injectableContainers.push(adminSlackIntegrationLegacyContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <LegacySlackIntegration />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminLegacySlackIntegrationPage;

+ 52 - 0
packages/app/src/pages/admin/slack-integration.page.tsx

@@ -0,0 +1,52 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useSiteUrl } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const SlackIntegration = dynamic(() => import('~/components/Admin/SlackIntegration/SlackIntegration'), { ssr: false });
+
+
+type Props = CommonProps & {
+  siteUrl: string
+};
+
+
+const AdminSlackIntegrationPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useSiteUrl(props.siteUrl);
+
+  const title = t('slack_integration.slack_integration');
+
+  return (
+    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <SlackIntegration />
+    </AdminLayout>
+  );
+};
+
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { appService } = crowi;
+
+  props.siteUrl = appService.getSiteUrl();
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminSlackIntegrationPage;

+ 50 - 0
packages/app/src/pages/admin/user-groups.page.tsx

@@ -0,0 +1,50 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useIsAclEnabled } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const UserGroupPage = dynamic(() => import('~/components/Admin/UserGroup/UserGroupPage').then(mod => mod.UserGroupPage), { ssr: false });
+
+
+type Props = CommonProps & {
+  isAclEnabled: boolean
+};
+
+
+const AdminUserGroupPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useIsAclEnabled(props.isAclEnabled);
+
+  const title = t('user_group_management.user_group_management');
+
+  return (
+    <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+      <UserGroupPage />
+    </AdminLayout>
+  );
+};
+
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { aclService } = crowi;
+
+  props.isAclEnabled = aclService.isAclEnabled();
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminUserGroupPage;

+ 50 - 0
packages/app/src/pages/admin/users/external-accounts.page.tsx

@@ -0,0 +1,50 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+
+import { retrieveServerSideProps } from '../../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const ManageExternalAccount = dynamic(() => import('~/components/Admin/ManageExternalAccount'), { ssr: false });
+
+
+const AdminUserManagementPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('admin');
+
+  const title = t('user_management.external_account');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminExternalAccountsContainer = new AdminExternalAccountsContainer();
+
+    injectableContainers.push(
+      adminExternalAccountsContainer,
+    );
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <ManageExternalAccount />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminUserManagementPage;

+ 69 - 0
packages/app/src/pages/admin/users/index.page.tsx

@@ -0,0 +1,69 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser, useIsMailerSetup } from '~/stores/context';
+
+import { retrieveServerSideProps } from '../../../utils/admin-page-util';
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+
+const UserManagement = dynamic(() => import('~/components/Admin/UserManagement'), { ssr: false });
+
+
+type Props = CommonProps & {
+  currentUser: any,
+  isMailerSetup: boolean,
+};
+
+
+const AdminUserManagementPage: NextPage<Props> = (props) => {
+  const { t } = useTranslation('admin');
+  useCurrentUser(props.currentUser != null ? JSON.parse(props.currentUser) : null);
+  useIsMailerSetup(props.isMailerSetup);
+
+  const title = t('user_management.user_management');
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminUsersContainer = new AdminUsersContainer();
+
+    injectableContainers.push(adminUsersContainer);
+  }
+
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <UserManagement />
+      </AdminLayout>
+    </Provider>
+  );
+
+};
+
+const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi, user } = req;
+  const { mailService } = crowi;
+
+  if (user != null) {
+    props.currentUser = JSON.stringify(user);
+  }
+  props.isMailerSetup = mailService.isMailerSetup;
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context, injectServerConfigurations);
+  return props;
+};
+
+
+export default AdminUserManagementPage;

+ 1 - 1
packages/app/src/pages/maintenance.page.tsx

@@ -47,7 +47,7 @@ const MaintenancePage: NextPage<CommonProps> = (props: Props) => {
               && (
                 <p>
                   <i className="icon-arrow-right"></i>
-                  <a className="btn btn-link" href="/admin/home">{ t('maintenance_mode.admin_page') }</a>
+                  <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
                 </p>
               )}
                 {props.currentUser != null

+ 5 - 3
packages/app/src/pages/trash.page.tsx

@@ -5,9 +5,14 @@ import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 
+import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
+import {
+  useCurrentProductNavWidth, useCurrentSidebarContents, useDrawerMode, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
+} from '~/stores/ui';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
@@ -19,9 +24,6 @@ import {
 import {
   CommonProps, getServerSideCommonProps, getNextI18NextConfig, useCustomTitle,
 } from './utils/commons';
-import { useCurrentProductNavWidth, useCurrentSidebarContents, useDrawerMode, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed } from '~/stores/ui';
-import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
-import { ISidebarConfig } from '~/interfaces/sidebar-config';
 
 const TrashPageList = dynamic(() => import('~/components/TrashPageList').then(mod => mod.TrashPageList), { ssr: false });
 const EmptyTrashModal = dynamic(() => import('~/components/EmptyTrashModal'), { ssr: false });

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

@@ -21,6 +21,7 @@ export type CommonProps = {
   growiVersion: string,
   isMaintenanceMode: boolean,
   redirectDestination: string | null,
+  customizedLogoSrc?: string,
 } & Partial<SSRConfig>;
 
 // eslint-disable-next-line max-len
@@ -53,6 +54,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     growiVersion: crowi.version,
     isMaintenanceMode,
     redirectDestination,
+    customizedLogoSrc: configManager.getConfig('crowi', 'customize:customizedLogoSrc'),
   };
 
   return { props };
@@ -76,12 +78,11 @@ export const getNextI18NextConfig = async(
     ?? configManager.getConfig('crowi', 'app:globalLang') as Lang
     ?? Lang.en_US;
 
-  // TODO: Consider to not include translation as default or other architecture idea
-  // see: https://redmine.weseek.co.jp/issues/107092
   const namespaces = ['commons'];
   if (namespacesRequired != null) {
     namespaces.push(...namespacesRequired);
   }
+  // TODO: deprecate 'translation.json' in the future
   else {
     namespaces.push('translation');
   }

+ 36 - 33
packages/app/src/server/models/page.ts

@@ -2,7 +2,7 @@
 
 import nodePath from 'path';
 
-import { pagePathUtils, pathUtils } from '@growi/core';
+import { HasObjectId, pagePathUtils, pathUtils } from '@growi/core';
 import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, {
   Schema, Model, Document, AnyObject,
@@ -58,13 +58,13 @@ export type CreateMethod = (path: string, body: string, user, options: PageCreat
 export interface PageModel extends Model<PageDocument> {
   [x: string]: any; // for obsolete static methods
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: true, includeEmpty?: boolean): Promise<PageDocument & HasObjectId | null>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: false, includeEmpty?: boolean): Promise<(PageDocument & HasObjectId)[]>
   countByPathAndViewer(path: string | null, user, userGroups?, includeEmpty?:boolean): Promise<number>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
-    user, userGroups, showAnyoneKnowsLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+    user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
 
   PageQueryBuilder: typeof PageQueryBuilder
@@ -140,7 +140,7 @@ export class PageQueryBuilder {
    * @param pathsToFilter The paths to have additional filters as to be applicable
    * @returns PageQueryBuilder
    */
-  addConditionToFilterByApplicableAncestors(pathsToFilter: string[]) {
+  addConditionToFilterByApplicableAncestors(pathsToFilter: string[]): PageQueryBuilder {
     this.query = this.query
       .and(
         {
@@ -156,7 +156,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionToExcludeTrashed() {
+  addConditionToExcludeTrashed(): PageQueryBuilder {
     this.query = this.query
       .and({
         $or: [
@@ -172,7 +172,7 @@ export class PageQueryBuilder {
    * generate the query to find the pages '{path}/*' and '{path}' self.
    * If top page, return without doing anything.
    */
-  addConditionToListWithDescendants(path: string, option?) {
+  addConditionToListWithDescendants(path: string, option?): PageQueryBuilder {
     // No request is set for the top page
     if (isTopPage(path)) {
       return this;
@@ -198,7 +198,7 @@ export class PageQueryBuilder {
    * generate the query to find the pages '{path}/*' (exclude '{path}' self).
    * If top page, return without doing anything.
    */
-  addConditionToListOnlyDescendants(path, option) {
+  addConditionToListOnlyDescendants(path, option): PageQueryBuilder {
     // No request is set for the top page
     if (isTopPage(path)) {
       return this;
@@ -215,7 +215,7 @@ export class PageQueryBuilder {
 
   }
 
-  addConditionToListOnlyAncestors(path) {
+  addConditionToListOnlyAncestors(path): PageQueryBuilder {
     const pathNormalized = pathUtils.normalizePath(path);
     const ancestorsPaths = extractToAncestorsPaths(pathNormalized);
 
@@ -299,7 +299,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  async addConditionForParentNormalization(user) {
+  async addConditionForParentNormalization(user): Promise<PageQueryBuilder> {
     // determine UserGroup condition
     let userGroups;
     if (user != null) {
@@ -332,7 +332,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  async addConditionAsMigratablePages(user) {
+  async addConditionAsMigratablePages(user): Promise<PageQueryBuilder> {
     this.query = this.query
       .and({
         $or: [
@@ -349,19 +349,21 @@ export class PageQueryBuilder {
   }
 
   // add viewer condition to PageQueryBuilder instance
-  async addViewerCondition(user, userGroups = null): Promise<PageQueryBuilder> {
+  async addViewerCondition(user, userGroups = null, includeAnyoneWithTheLink = false): Promise<PageQueryBuilder> {
     let relatedUserGroups = userGroups;
     if (user != null && relatedUserGroups == null) {
       const UserGroupRelation: any = mongoose.model('UserGroupRelation');
       relatedUserGroups = await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user);
     }
 
-    this.addConditionToFilteringByViewer(user, relatedUserGroups, false);
+    this.addConditionToFilteringByViewer(user, relatedUserGroups, includeAnyoneWithTheLink);
     return this;
   }
 
-  addConditionToFilteringByViewer(user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false) {
-    const condition = generateGrantCondition(user, userGroups, showAnyoneKnowsLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+  addConditionToFilteringByViewer(
+      user, userGroups, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+  ): PageQueryBuilder {
+    const condition = generateGrantCondition(user, userGroups, includeAnyoneWithTheLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
     this.query = this.query
       .and(condition);
@@ -369,27 +371,27 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionToPagenate(offset, limit, sortOpt?) {
+  addConditionToPagenate(offset, limit, sortOpt?): PageQueryBuilder {
     this.query = this.query
       .sort(sortOpt).skip(offset).limit(limit); // eslint-disable-line newline-per-chained-call
 
     return this;
   }
 
-  addConditionAsNonRootPage() {
+  addConditionAsNonRootPage(): PageQueryBuilder {
     this.query = this.query.and({ path: { $ne: '/' } });
 
     return this;
   }
 
-  addConditionAsNotMigrated() {
+  addConditionAsNotMigrated(): PageQueryBuilder {
     this.query = this.query
       .and({ parent: null });
 
     return this;
   }
 
-  addConditionAsOnTree() {
+  addConditionAsOnTree(): PageQueryBuilder {
     this.query = this.query
       .and(
         {
@@ -406,25 +408,25 @@ export class PageQueryBuilder {
   /*
    * Add this condition when get any ancestor pages including the target's parent
    */
-  addConditionToSortPagesByDescPath() {
+  addConditionToSortPagesByDescPath(): PageQueryBuilder {
     this.query = this.query.sort('-path');
 
     return this;
   }
 
-  addConditionToSortPagesByAscPath() {
+  addConditionToSortPagesByAscPath(): PageQueryBuilder {
     this.query = this.query.sort('path');
 
     return this;
   }
 
-  addConditionToMinimizeDataForRendering() {
+  addConditionToMinimizeDataForRendering(): PageQueryBuilder {
     this.query = this.query.select('_id path isEmpty grant revision descendantCount');
 
     return this;
   }
 
-  addConditionToListByPathsArray(paths) {
+  addConditionToListByPathsArray(paths): PageQueryBuilder {
     this.query = this.query
       .and({
         path: {
@@ -435,7 +437,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionToListByPageIdsArray(pageIds) {
+  addConditionToListByPageIdsArray(pageIds): PageQueryBuilder {
     this.query = this.query
       .and({
         _id: {
@@ -446,7 +448,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  addConditionToExcludeByPageIdsArray(pageIds) {
+  addConditionToExcludeByPageIdsArray(pageIds): PageQueryBuilder {
     this.query = this.query
       .and({
         _id: {
@@ -457,7 +459,7 @@ export class PageQueryBuilder {
     return this;
   }
 
-  populateDataToList(userPublicFields) {
+  populateDataToList(userPublicFields): PageQueryBuilder {
     this.query = this.query
       .populate({
         path: 'lastUpdateUser',
@@ -466,12 +468,12 @@ export class PageQueryBuilder {
     return this;
   }
 
-  populateDataToShowRevision(userPublicFields) {
+  populateDataToShowRevision(userPublicFields): PageQueryBuilder {
     this.query = populateDataToShowRevision(this.query, userPublicFields);
     return this;
   }
 
-  addConditionToFilteringByParentId(parentId) {
+  addConditionToFilteringByParentId(parentId): PageQueryBuilder {
     this.query = this.query.and({ parent: parentId });
     return this;
   }
@@ -561,16 +563,17 @@ schema.statics.findByIdsAndViewer = async function(pageIds: string[], user, user
  * Find a page by path and viewer. Pass false to useFindOne to use findOne method.
  */
 schema.statics.findByPathAndViewer = async function(
-    path: string | null, user, userGroups = null, useFindOne = true, includeEmpty = false,
-): Promise<PageDocument | PageDocument[] | null> {
+    path: string | null, user, userGroups = null, useFindOne = false, includeEmpty = false,
+): Promise<(PageDocument | PageDocument[]) & HasObjectId | null> {
   if (path == null) {
     throw new Error('path is required.');
   }
 
   const baseQuery = useFindOne ? this.findOne({ path }) : this.find({ path });
+  const includeAnyoneWithTheLink = useFindOne;
   const queryBuilder = new PageQueryBuilder(baseQuery, includeEmpty);
 
-  await queryBuilder.addViewerCondition(user, userGroups);
+  await queryBuilder.addViewerCondition(user, userGroups, includeAnyoneWithTheLink);
 
   return queryBuilder.query.exec();
 };
@@ -889,14 +892,14 @@ schema.statics.findParent = async function(pageId): Promise<PageDocument | null>
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 export function generateGrantCondition(
-    user, userGroups, showAnyoneKnowsLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+    user, userGroups, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
 ): { $or: any[] } {
   const grantConditions: AnyObject[] = [
     { grant: null },
     { grant: GRANT_PUBLIC },
   ];
 
-  if (showAnyoneKnowsLink) {
+  if (includeAnyoneWithTheLink) {
     grantConditions.push({ grant: GRANT_RESTRICTED });
   }
 

+ 7 - 5
packages/app/src/server/routes/apiv3/pages.js

@@ -538,23 +538,25 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3('Someone could update this page, so couldn\'t delete.', 'notfound_or_forbidden'), 409);
       }
       renamedPage = await crowi.pageService.renamePage(page, newPagePath, req.user, options, activityParameters);
+
+      // Respond before sending notification
+      const result = { page: serializePageSecurely(renamedPage ?? page) };
+      res.apiv3(result);
     }
     catch (err) {
       logger.error(err);
       return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
     }
-    const result = { page: serializePageSecurely(renamedPage ?? page) };
+
     try {
       // global notification
-      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, page, req.user, {
-        oldPath: req.body.path,
+      await globalNotificationService.fire(GlobalNotificationSetting.EVENT.PAGE_MOVE, renamedPage, req.user, {
+        oldPath: page.path,
       });
     }
     catch (err) {
       logger.error('Move notification failed', err);
     }
-
-    return res.apiv3(result);
   });
 
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator, async(req, res) => {

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

@@ -1281,6 +1281,10 @@ module.exports = function(crowi, app) {
       return res.json(ApiResponse.error(`Page '${pageId}' is not found or forbidden`, 'notfound_or_forbidden'));
     }
 
+    if (page.isEmpty && !isRecursively) {
+      return res.json(ApiResponse.error('Empty pages cannot be single deleted', 'single_deletion_empty_pages'));
+    }
+
     let creator;
     if (page.isEmpty) {
       // If empty, the creator is inherited from the closest non-empty ancestor page.

+ 18 - 6
packages/app/src/server/service/page.ts

@@ -224,9 +224,9 @@ class PageService {
       pageId: string, path: string, user: IUserHasId, includeEmpty = false, isSharedPage = false,
   ): Promise<IPageWithMeta<IPageInfoAll>|null> {
 
-    const Page = this.crowi.model('Page');
+    const Page = this.crowi.model('Page') as PageModel;
 
-    let page: PageModel & PageDocument & HasObjectId;
+    let page: PageDocument & HasObjectId | null;
     if (pageId != null) { // prioritized
       page = await Page.findByIdAndViewer(pageId, user, null, includeEmpty);
     }
@@ -2010,18 +2010,30 @@ class PageService {
     const includeEmpty = true;
     const originPage = await Page.findByPath(newPath, includeEmpty);
 
-    // throw if any page already exists
-    if (originPage != null) {
+    // throw if any page already exists when recursively operation
+    if (originPage != null && (!originPage.isEmpty || isRecursively)) {
       throw new PathAlreadyExistsError('already_exists', originPage.path);
     }
 
     // 2. Revert target
     const parent = await this.getParentAndFillAncestorsByUser(user, newPath);
-    const updatedPage = await Page.findByIdAndUpdate(page._id, {
+    const shouldReplace = originPage != null && originPage.isEmpty;
+    let updatedPage = await Page.findByIdAndUpdate(page._id, {
       $set: {
-        path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null, parent: parent._id, descendantCount: 0,
+        path: newPath,
+        status: Page.STATUS_PUBLISHED,
+        lastUpdateUser: user._id,
+        deleteUser: null,
+        deletedAt: null,
+        parent: parent._id,
+        descendantCount: shouldReplace ? originPage.descendantCount : 0,
       },
     }, { new: true });
+
+    if (shouldReplace) {
+      updatedPage = await Page.replaceTargetWithPage(originPage, updatedPage, true);
+    }
+
     await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
 
     this.pageEvent.emit('revert', page, user);

+ 17 - 13
packages/app/src/stores/context.tsx

@@ -1,4 +1,4 @@
-import { IUser } from '@growi/core';
+import { IUser, pagePathUtils } from '@growi/core';
 import { HtmlElementNode } from 'rehype-toc';
 import { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -80,14 +80,6 @@ export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean,
   return useStaticSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
 };
 
-export const useIsUserPage = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isUserPage', initialData, { fallbackData: false });
-};
-
-export const useIsTrashPage = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useStaticSWR<boolean, Error>('isTrashPage', initialData, { fallbackData: false });
-};
-
 // export const useIsNotCreatable = (initialData?: boolean): SWRResponse<boolean, Error> => {
 //   return useStaticSWR<boolean, Error>('isNotCreatable', initialData, { fallbackData: false });
 // };
@@ -271,6 +263,10 @@ export const useCustomizeTitle = (initialData?: string): SWRResponse<string, Err
   return useStaticSWR('CustomizeTitle', initialData);
 };
 
+export const useCustomizedLogoSrc = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR('customizedLogoSrc', initialData);
+};
+
 /** **********************************************************
  *                     Computed contexts
  *********************************************************** */
@@ -289,12 +285,11 @@ export const useIsEditable = (): SWRResponse<boolean, Error> => {
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isForbidden } = useIsForbidden();
   const { data: isIdenticalPath } = useIsIdenticalPath();
-  const { data: isTrashPage } = useIsTrashPage();
 
   return useSWRImmutable(
-    ['isEditable', isGuestUser, isForbidden, isIdenticalPath, isTrashPage],
-    (key: Key, isGuestUser: boolean, isForbidden: boolean, isIdenticalPath: boolean, isTrashPage: boolean) => {
-      return (!isTrashPage && !isForbidden && !isIdenticalPath && !isGuestUser);
+    ['isEditable', isGuestUser, isForbidden, isIdenticalPath],
+    (key: Key, isGuestUser: boolean, isForbidden: boolean, isIdenticalPath: boolean) => {
+      return (!isForbidden && !isIdenticalPath && !isGuestUser);
     },
   );
 };
@@ -304,3 +299,12 @@ export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
 
   return useStaticSWR(['currentPageTocNode', currentPagePath]);
 };
+
+export const useIsTrashPage = (): SWRResponse<boolean, Error> => {
+  const { data: pagePath } = useCurrentPagePath();
+
+  return useSWRImmutable(
+    pagePath == null ? null : ['isTrashPage', pagePath],
+    (key: Key, pagePath: string) => pagePathUtils.isTrashPage(pagePath),
+  );
+};

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

@@ -78,7 +78,7 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
 */
 type IEmptyTrashModalOption = {
   onEmptiedTrash?: () => void,
-  canDelepeAllPages: boolean,
+  canDeleteAllPages: boolean,
 }
 
 type EmptyTrashModalStatus = {

+ 10 - 7
packages/app/src/stores/ui.tsx

@@ -405,18 +405,21 @@ export const useIsAbleToShowTrashPageManagementButtons = (): SWRResponse<boolean
 export const useIsAbleToShowPageManagement = (): SWRResponse<boolean, Error> => {
   const key = 'isAbleToShowPageManagement';
   const { data: currentPageId } = useCurrentPageId();
-  const { data: isTrashPage } = useIsTrashPage();
-  const { data: isSharedUser } = useIsSharedUser();
+  const { data: _isTrashPage } = useIsTrashPage();
+  const { data: _isSharedUser } = useIsSharedUser();
   const { data: isNotFound } = useIsNotFound();
 
   const pageId = currentPageId;
-  const includesUndefined = [pageId, isTrashPage, isSharedUser, isNotFound].some(v => v === undefined);
-  const isPageExist = (pageId != null) && !isNotFound;
-  const isEmptyPage = (pageId != null) && isNotFound;
+  const includesUndefined = [pageId, _isTrashPage, _isSharedUser, isNotFound].some(v => v === undefined);
+  const isPageExist = (pageId != null) && isNotFound === false;
+  const isEmptyPage = (pageId != null) && isNotFound === true;
+  const isTrashPage = isPageExist && _isTrashPage === true;
+  const isSharedUser = isPageExist && _isSharedUser === true;
 
   return useSWRImmutable(
-    includesUndefined ? null : [key, pageId],
-    () => (isPageExist && !isTrashPage && !isSharedUser) || (isEmptyPage != null && isEmptyPage),
+    includesUndefined ? null : [key, pageId, isPageExist, isEmptyPage, isTrashPage, isSharedUser],
+    // eslint-disable-next-line max-len
+    (key: string, pageId: string, isPageExist: boolean, isTrashPage: boolean, isSharedUser: boolean) => (isPageExist && !isTrashPage && !isSharedUser) || isEmptyPage,
   );
 };
 

+ 0 - 10
packages/app/src/styles/_attachments.scss

@@ -1,13 +1,3 @@
-.attachment-delete-modal {
-  .attachment-delete-image {
-    text-align: center;
-
-    img {
-      max-width: 100%;
-    }
-  }
-}
-
 .attachment-userpicture .picture {
   vertical-align: text-bottom;
 }

+ 5 - 0
packages/app/src/styles/bootstrap/_override.scss

@@ -167,3 +167,8 @@ fieldset[disabled] .btn {
   word-break: break-word;
   overflow-wrap: break-word;
 }
+
+// prevent tooltip flickering (flashing) on hover
+.tooltip {
+  pointer-events: none;
+}

+ 41 - 0
packages/app/src/utils/admin-page-util.ts

@@ -0,0 +1,41 @@
+import { GetServerSidePropsContext } from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+
+import {
+  getServerSideCommonProps, getNextI18NextConfig,
+} from '~/pages/utils/commons';
+
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props, namespacesRequired?: string[] | undefined): Promise<void> {
+  // preload all languages because of language lists in user setting
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired, true);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+
+export const retrieveServerSideProps: any = async(
+    context: GetServerSidePropsContext, injectServerConfigurations?:(context: GetServerSidePropsContext, props) => Promise<void>,
+) => {
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props = result.props;
+  if (injectServerConfigurations != null) {
+    await injectServerConfigurations(context, props);
+  }
+  await injectNextI18NextConfigurations(context, props, ['admin', 'commons']);
+
+  return {
+    props,
+  };
+};

+ 2 - 1
packages/app/test/cypress/integration/20-basic-features/access-to-page.spec.ts

@@ -98,10 +98,11 @@ context('Access to special pages', () => {
   });
 
   it('/tags is successfully loaded', () => {
-    cy.visit('/tags');
 
     // open sidebar
     cy.collapseSidebar(false);
+
+    cy.visit('/tags');
     // select tags
     cy.getByTestid('grw-sidebar-nav-primary-tags').click();
     cy.getByTestid('grw-sidebar-content-tags').should('be.visible');

+ 4 - 4
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -72,7 +72,7 @@ context('Modal for page operation', () => {
     });
     cy.getByTestid('page-editor').should('be.visible');
     cy.getByTestid('save-page-btn').click();
-    cy.get('layout-root').should('not.have.class', 'editing');
+    cy.get('.layout-root').should('not.have.class', 'editing');
 
     cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
@@ -248,7 +248,7 @@ context('Tag Oprations', () =>{
     });
 
     cy.get('.toast').should('be.visible').trigger('mouseover');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).should('exist');
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).should('exist');
     /* eslint-disable cypress/no-unnecessary-waiting */
     cy.wait(150); // wait for toastr to change its color occured by mouseover
     cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
@@ -260,7 +260,7 @@ context('Tag Oprations', () =>{
     const tag = 'we';
     const newPageName = 'our';
     cy.visit('/');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).click();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');
@@ -295,7 +295,7 @@ context('Tag Oprations', () =>{
     const newPageName = '/ourus';
 
     cy.visit('/');
-    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.get('.grw-taglabels-container > .grw-tag-labels > a', { timeout: 10000 }).contains(tag).click();
     cy.getByTestid('search-result-base').should('be.visible');
     cy.getByTestid('search-result-list').should('be.visible');
     cy.getByTestid('search-result-content').should('be.visible');

+ 9 - 9
packages/app/test/cypress/integration/50-sidebar/access-to-side-bar.spec.ts

@@ -60,7 +60,7 @@ context('Access to sidebar', () => {
     cy.get('.CodeMirror textarea').type(content, {force: true});
     cy.screenshot(`${ssPrefix}custom-sidebar-2-custom-sidebar-editor`);
     cy.getByTestid('save-page-btn').click();
-    cy.get('layout-root').should('not.have.class', 'editing');
+    cy.get('.layout-root').should('not.have.class', 'editing');
 
     // What to do when UserUISettings is not saved in time
     cy.getByTestid('grw-sidebar-nav-primary-custom-sidebar').then(($el) => {
@@ -156,14 +156,14 @@ context('Access to sidebar', () => {
     cy.screenshot(`${ssPrefix}tags-2-check-all-tags`);
   });
 
-  it('Successfully access to My Drafts page', () => {
-    cy.visit('/');
-    cy.collapseSidebar(true);
-    cy.get('.grw-sidebar-nav-secondary-container').within(() => {
-      cy.get('a[href*="/me/drafts"]').click();
-    });
-    cy.screenshot(`${ssPrefix}access-to-drafts-page`);
-  });
+  // it('Successfully access to My Drafts page', () => {
+  //   cy.visit('/');
+  //   cy.collapseSidebar(true);
+  //   cy.get('.grw-sidebar-nav-secondary-container').within(() => {
+  //     cy.get('a[href*="/me/drafts"]').click();
+  //   });
+  //   cy.screenshot(`${ssPrefix}access-to-drafts-page`);
+  // });
   it('Successfully access to Growi Docs page', () => {
     cy.visit('/');
     cy.get('.grw-sidebar-nav-secondary-container').within(() => {

+ 4 - 0
packages/core/src/interfaces/attachment.ts

@@ -8,4 +8,8 @@ export type IAttachment = {
 
   // virtual property
   filePathProxied: string,
+
+  fileFormat: string,
+  downloadPathProxied: string,
+  originalName: string,
 };

+ 0 - 86
packages/ui/src/components/Attachment/Attachment.jsx

@@ -1,86 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-
-import { UserPicture } from '../User/UserPicture';
-
-export class Attachment extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this._onAttachmentDeleteClicked = this._onAttachmentDeleteClicked.bind(this);
-  }
-
-  iconNameByFormat(format) {
-    if (format.match(/image\/.+/i)) {
-      return 'icon-picture';
-    }
-
-    return 'icon-doc';
-  }
-
-  _onAttachmentDeleteClicked(event) {
-    if (this.props.onAttachmentDeleteClicked != null) {
-      this.props.onAttachmentDeleteClicked(this.props.attachment);
-    }
-  }
-
-  render() {
-    const attachment = this.props.attachment;
-    const formatIcon = this.iconNameByFormat(attachment.fileFormat);
-
-    let fileInUse = '';
-    if (this.props.inUse) {
-      fileInUse = <span className="attachment-in-use badge badge-pill badge-info">In Use</span>;
-    }
-
-    const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
-
-    const btnDownload = (this.props.isUserLoggedIn)
-      ? (
-        <a className="attachment-download" href={attachment.downloadPathProxied}>
-          <i className="icon-cloud-download" />
-        </a>
-      )
-      : '';
-
-    const btnTrash = (this.props.isUserLoggedIn)
-      ? (
-        /* eslint-disable-next-line */
-        <a className="text-danger attachment-delete" onClick={this._onAttachmentDeleteClicked}>
-          <i className="icon-trash" />
-        </a>
-      )
-      : '';
-
-    return (
-      <div className="attachment mb-2">
-        <span className="mr-1 attachment-userpicture">
-          <UserPicture user={attachment.creator} size="sm"></UserPicture>
-        </span>
-        <a className="mr-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
-          <i className={formatIcon}></i> {attachment.originalName}
-        </a>
-        <span className="mr-2">{fileType}</span>
-        <span className="mr-2">{fileInUse}</span>
-        <span className="mr-2">{btnDownload}</span>
-        <span className="mr-2">{btnTrash}</span>
-      </div>
-    );
-  }
-
-}
-
-Attachment.propTypes = {
-  attachment: PropTypes.object.isRequired,
-  inUse: PropTypes.bool,
-  onAttachmentDeleteClicked: PropTypes.func,
-  isUserLoggedIn: PropTypes.bool,
-};
-
-Attachment.defaultProps = {
-  inUse: false,
-  isUserLoggedIn: false,
-};

+ 58 - 0
packages/ui/src/components/Attachment/Attachment.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import { HasObjectId, IAttachment } from '@growi/core';
+
+import { UserPicture } from '../User/UserPicture';
+
+type AttachmentProps = {
+  attachment: IAttachment & HasObjectId,
+  inUse: boolean,
+  onAttachmentDeleteClicked?: (attachment: IAttachment & HasObjectId) => void,
+  isUserLoggedIn?: boolean,
+};
+
+export const Attachment = (props: AttachmentProps): JSX.Element => {
+
+  const {
+    attachment, inUse, isUserLoggedIn, onAttachmentDeleteClicked,
+  } = props;
+
+  const _onAttachmentDeleteClicked = () => {
+    if (onAttachmentDeleteClicked != null) {
+      onAttachmentDeleteClicked(attachment);
+    }
+  };
+
+  const formatIcon = (attachment.fileFormat.match(/image\/.+/i)) ? 'icon-picture' : 'icon-doc';
+  const btnDownload = (isUserLoggedIn)
+    ? (
+      <a className="attachment-download" href={attachment.downloadPathProxied}>
+        <i className="icon-cloud-download" />
+      </a>
+    )
+    : '';
+  const btnTrash = (isUserLoggedIn)
+    ? (
+      <a className="text-danger attachment-delete" onClick={_onAttachmentDeleteClicked}>
+        <i className="icon-trash" />
+      </a>
+    )
+    : '';
+  const fileType = <span className="attachment-filetype badge badge-pill badge-secondary">{attachment.fileFormat}</span>;
+  const fileInUse = (inUse) ? <span className="attachment-in-use badge badge-pill badge-info">In Use</span> : '';
+
+  return (
+    <div className="attachment mb-2">
+      <span className="mr-1 attachment-userpicture">
+        <UserPicture user={attachment.creator} size="sm"></UserPicture>
+      </span>
+      <a className="mr-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
+        <i className={formatIcon}></i> {attachment.originalName}
+      </a>
+      <span className="mr-2">{fileType}</span>
+      <span className="mr-2">{fileInUse}</span>
+      <span className="mr-2">{btnDownload}</span>
+      <span className="mr-2">{btnTrash}</span>
+    </div>
+  );
+};

+ 0 - 104
packages/ui/src/components/User/UserPicture.jsx

@@ -1,104 +0,0 @@
-import React from 'react';
-
-import { pagePathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-
-const { userPageRoot } = pagePathUtils;
-
-
-const DEFAULT_IMAGE = '/images/icons/user.svg';
-
-export class UserPicture extends React.Component {
-
-  getClassName() {
-    const className = ['rounded-circle', 'picture'];
-    // size
-    if (this.props.size) {
-      className.push(`picture-${this.props.size}`);
-    }
-
-    return className.join(' ');
-  }
-
-  renderForNull() {
-    return (
-      <img
-        src={DEFAULT_IMAGE}
-        alt="someone"
-        className={this.getClassName()}
-      />
-    );
-  }
-
-  RootElmWithoutLink = (props) => {
-    return <span {...props}>{props.children}</span>;
-  };
-
-  RootElmWithLink = (props) => {
-    const { user } = this.props;
-    const href = userPageRoot(user);
-    // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
-    // Nested anchor tags causes a warning.
-    // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
-    return <span onClick={() => { window.location.href = href }} {...props}>{props.children}</span>;
-  };
-
-  withTooltip = (RootElm) => {
-    const { user } = this.props;
-    const id = `user-picture-${Math.random().toString(32).substring(2)}`;
-
-    return props => (
-      <>
-        <RootElm id={id}>{props.children}</RootElm>
-        <UncontrolledTooltip placement="bottom" target={id} delay={0} fade={false}>
-          @{user.username}<br />
-          {user.name}
-        </UncontrolledTooltip>
-      </>
-    );
-  };
-
-  render() {
-    const user = this.props.user;
-
-    if (user == null) {
-      return this.renderForNull();
-    }
-
-    const { noLink, noTooltip } = this.props;
-
-    // determine RootElm
-    let RootElm = noLink ? this.RootElmWithoutLink : this.RootElmWithLink;
-    if (!noTooltip) {
-      RootElm = this.withTooltip(RootElm);
-    }
-
-    const userPictureSrc = user.imageUrlCached || DEFAULT_IMAGE;
-
-    return (
-      <RootElm>
-        <img
-          src={userPictureSrc}
-          alt={user.username}
-          className={this.getClassName()}
-        />
-      </RootElm>
-    );
-  }
-
-}
-
-UserPicture.propTypes = {
-  user: PropTypes.object,
-  size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
-  noLink: PropTypes.bool,
-  noTooltip: PropTypes.bool,
-};
-
-UserPicture.defaultProps = {
-  size: null,
-  noLink: false,
-  noTooltip: false,
-};

+ 120 - 0
packages/ui/src/components/User/UserPicture.tsx

@@ -0,0 +1,120 @@
+import React, {
+  forwardRef, useCallback, useRef,
+} from 'react';
+
+import type { Ref, IUser } from '@growi/core';
+import { pagePathUtils } from '@growi/core';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import type { UncontrolledTooltipProps } from 'reactstrap';
+
+const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
+
+const { userPageRoot } = pagePathUtils;
+
+const DEFAULT_IMAGE = '/images/icons/user.svg';
+
+
+type UserPictureRootProps = {
+  user: Partial<IUser>,
+  className?: string,
+  children?: React.ReactNode,
+}
+
+const UserPictureRootWithoutLink = forwardRef<HTMLSpanElement, UserPictureRootProps>((props, ref) => {
+  return <span ref={ref} className={props.className}>{props.children}</span>;
+});
+
+const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootProps>((props, ref) => {
+  const router = useRouter();
+
+  const { user } = props;
+  const href = userPageRoot(user);
+
+  const clickHandler = useCallback(() => {
+    router.push(href);
+  }, [href, router]);
+
+  // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
+  // Nested anchor tags causes a warning.
+  // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
+  return <span ref={ref} className={props.className} onClick={clickHandler} style={{ cursor: 'pointer' }}>{props.children}</span>;
+});
+
+
+// wrapper with Tooltip
+const withTooltip = (UserPictureSpanElm: React.ForwardRefExoticComponent<UserPictureRootProps & React.RefAttributes<HTMLSpanElement>>) => {
+  return (props: UserPictureRootProps) => {
+    const { user } = props;
+
+    const userPictureRef = useRef<HTMLSpanElement>(null);
+
+    return (
+      <>
+        <UserPictureSpanElm ref={userPictureRef} user={user}>{props.children}</UserPictureSpanElm>
+        <UncontrolledTooltip placement="bottom" target={userPictureRef} delay={0} fade={false}>
+          @{user.username}<br />
+          {user.name}
+        </UncontrolledTooltip>
+      </>
+    );
+  };
+};
+
+
+/**
+ * type guard to determine whether the specified object is IUser
+ */
+const isUserObj = (obj: Partial<IUser> | Ref<IUser>): obj is Partial<IUser> => {
+  return typeof obj !== 'string' && 'username' in obj;
+};
+
+
+type Props = {
+  user?: Partial<IUser> | Ref<IUser> | null,
+  size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
+  noLink?: boolean,
+  noTooltip?: boolean,
+};
+
+export const UserPicture = React.memo((props: Props): JSX.Element => {
+
+  const {
+    user, size, noLink, noTooltip,
+  } = props;
+
+  const classNames = ['rounded-circle', 'picture'];
+  if (size != null) {
+    classNames.push(`picture-${size}`);
+  }
+  const className = classNames.join(' ');
+
+  if (user == null || !isUserObj(user)) {
+    return (
+      <img
+        src={DEFAULT_IMAGE}
+        alt="someone"
+        className={className}
+      />
+    );
+  }
+
+  // determine RootElm
+  const UserPictureSpanElm = noLink ? UserPictureRootWithoutLink : UserPictureRootWithLink;
+  const UserPictureRootElm = noTooltip
+    ? UserPictureSpanElm
+    : withTooltip(UserPictureSpanElm);
+
+  const userPictureSrc = user.imageUrlCached ?? DEFAULT_IMAGE;
+
+  return (
+    <UserPictureRootElm user={user}>
+      <img
+        src={userPictureSrc}
+        alt={user.username}
+        className={className}
+      />
+    </UserPictureRootElm>
+  );
+});
+UserPicture.displayName = 'UserPicture';

+ 41 - 68
yarn.lock

@@ -10,16 +10,6 @@
     plantuml-encoder "^1.4.0"
     unist-util-visit "^2.0.2"
 
-"@alienfast/i18next-loader@^1.1.4":
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/@alienfast/i18next-loader/-/i18next-loader-1.1.4.tgz#213a6cd77222900a61b1635a212051193bcd5d1f"
-  integrity sha512-8H+pIHIPwsjr1ip4bpCHnZtmR1z/K4KPpmD/fUL+kLug/2usATVmRi3IcZogy70Olqo3eH+qoKvWf+ROJbwoUA==
-  dependencies:
-    glob-all "^3.1.0"
-    js-yaml "^3.13.1"
-    loader-utils "^1.2.3"
-    lodash "^4.17.15"
-
 "@ampproject/remapping@^2.1.0":
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d"
@@ -1448,7 +1438,7 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2":
+"@babel/runtime@^7.14.5", "@babel/runtime@^7.17.2":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.6.tgz#6a1ef59f838debd670421f8c7f2cbb8da9751580"
   integrity sha512-t9wi7/AW6XtKahAe20Yw0/mMljKq0B1r2fPdvaAdV/KPDZewFXdaaa6K7lxmZBZ8FBNpCiAT6iHPmd6QO9bKfQ==
@@ -1462,10 +1452,10 @@
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.18.6":
-  version "7.19.0"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
-  integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
+"@babel/runtime@^7.18.9", "@babel/runtime@^7.19.4":
+  version "7.19.4"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78"
+  integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==
   dependencies:
     regenerator-runtime "^0.13.4"
 
@@ -11014,14 +11004,6 @@ glam@^5.0.1:
     fbjs "^0.8.16"
     inline-style-prefixer "^3.0.8"
 
-glob-all@^3.1.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/glob-all/-/glob-all-3.3.0.tgz#2019896fbaeb37bc451809cf0cb1e5d2b3e345b2"
-  integrity sha512-30gCh9beSb+YSAh0vsoIlBRm4bSlyMa+5nayax1EJhjwYrCohX0aDxcxvWVe3heOrJikbHgRs75Af6kPLcumew==
-  dependencies:
-    glob "^7.1.2"
-    yargs "^15.3.1"
-
 glob-base@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
@@ -11058,7 +11040,7 @@ glob2base@^0.0.12:
   dependencies:
     find-index "^0.1.1"
 
-glob@7.1.6, glob@^7.0.0, glob@^7.1.2, glob@^7.1.3, glob@^7.1.6:
+glob@7.1.6, glob@^7.0.0, glob@^7.1.3, glob@^7.1.6:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -11935,41 +11917,41 @@ hyphenate-style-name@^1.0.2:
   resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz#691879af8e220aea5750e8827db4ef62a54e361d"
   integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==
 
-i18next-chained-backend@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/i18next-chained-backend/-/i18next-chained-backend-3.0.2.tgz#8968c9e12412d24fd23eec109f0340386154384a"
-  integrity sha512-0dd/7oVtPHJnCDMuDvjzlXmWxwfbLOGBFXd1+cgcZ54QlMwv6/ofQ9xhrBIhCFjNh97WQ5pytEeTdcAGwLQ/QA==
+i18next-chained-backend@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/i18next-chained-backend/-/i18next-chained-backend-4.0.0.tgz#97679ee4b6e04e1ad96e49b3c4ab755ff62238eb"
+  integrity sha512-gOfkl2tvRDSMKQ2vaYbP+n5fsHeYM/836/Co8/NVP8LplRE8Ck7IrKWswp4vKw4D5Ji7cEdzA4drrG4ssgsXIg==
   dependencies:
-    "@babel/runtime" "^7.14.0"
+    "@babel/runtime" "^7.19.4"
 
-i18next-fs-backend@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.1.4.tgz#d0e9b9ed2fa7a0f11002d82b9fa69c3c3d6482da"
-  integrity sha512-/MfAGMP0jHonV966uFf9PkWWuDjPYLIcsipnSO3NxpNtAgRUKLTwvm85fEmsF6hGeu0zbZiCQ3W74jwO6K9uXA==
+i18next-fs-backend@^1.1.5:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/i18next-fs-backend/-/i18next-fs-backend-1.2.0.tgz#c498c68c8e6a8ae5ed59bea5e5392a11991de696"
+  integrity sha512-pUx3AcgXCbur0jpFA7U67Z2RJflAcIi698Y8VL+phdOqUchahxriV3Cs+M6UkPNQSS/zPEzWLfdJ8EgjB7HVxg==
 
-i18next-hmr@^1.7.7:
-  version "1.7.7"
-  resolved "https://registry.yarnpkg.com/i18next-hmr/-/i18next-hmr-1.7.7.tgz#8288697ff5595d1201990d6d0de65c4a58e0ffd5"
-  integrity sha512-jZuRSyJ9IfZUGENlTnYlqsSk+Cv/rGo//udrz3lxu/yGCxPW9A8dHS1HSs6fJVXgdHtiV4CuNN5+uRqCFb+y3g==
+i18next-hmr@^1.11.0:
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/i18next-hmr/-/i18next-hmr-1.11.0.tgz#2c474f68910f2f45d10ce7c76402a99bb0dc589f"
+  integrity sha512-OUKJ9oCwLjlBQ4rbB8PAaYVzsOcl6FjeRM1yA6kqyzfpS7uSNgk0aGhSIZ6vexu1Wu6Ymi3dTKM9rseUG+5Mog==
 
-i18next-http-backend@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-1.4.1.tgz#d8d308e7d8c5b89988446d0b83f469361e051bc0"
-  integrity sha512-s4Q9hK2jS29iyhniMP82z+yYY8riGTrWbnyvsSzi5TaF7Le4E7b5deTmtuaRuab9fdDcYXtcwdBgawZG+JCEjA==
+i18next-http-backend@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-2.0.0.tgz#7be736eb4c592e110b9ee54a985b737248d1c43f"
+  integrity sha512-6aFT5LcDOSxFyaoezruIxZDzpp6nu92j1iZc444nrz/OOaF7rsxQFNi1es19la53MQQFzG7uD2Koxi7Jav8khg==
   dependencies:
     cross-fetch "3.1.5"
 
-i18next-localstorage-backend@^3.1.3:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/i18next-localstorage-backend/-/i18next-localstorage-backend-3.1.3.tgz#5eaad25a515bdadebeb13e1486acfa6fa1686cbe"
-  integrity sha512-tx8dxQTEsTnRC654IrXPFr94c3NH7bIVHGKHnGvbgefpLz13/uFT5ITsmhqhg/gOza0TIj8e5jTsGnQytIhh+A==
+i18next-localstorage-backend@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/i18next-localstorage-backend/-/i18next-localstorage-backend-4.0.0.tgz#bd1b4318fe0f97baa1121dbb31c0c57e61e45a5d"
+  integrity sha512-XErjf0Zvciw3fo9/vzU1hWQfwHViq8l31ahKEvf6lgtqysPCtCBxNlIdrSjVZWEe76LD/thox1ixmO9PmlsL/w==
   dependencies:
-    "@babel/runtime" "^7.14.6"
+    "@babel/runtime" "^7.19.4"
 
-i18next@^21.8.13:
-  version "21.9.2"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.9.2.tgz#3f7c5594393eb27117c1db4c38f5ec766e68de0e"
-  integrity sha512-00fVrLQOwy45nm3OtC9l1WiLK3nJlIYSljgCt0qzTaAy65aciMdRy9GsuW+a2AtKtdg9/njUGfRH30LRupV7ZQ==
+i18next@^21.9.1:
+  version "21.10.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-21.10.0.tgz#85429af55fdca4858345d0e16b584ec29520197d"
+  integrity sha512-YeuIBmFsGjUfO3qBmMOc0rQaun4mIpGKET5WDwvu8lU7gvwpcariZLNtL0Fzj+zazcHUrlXHiptcFhBMFaxzfg==
   dependencies:
     "@babel/runtime" "^7.17.2"
 
@@ -14168,15 +14150,6 @@ load-plugin@^4.0.0:
     import-meta-resolve "^1.0.0"
     libnpmconfig "^1.0.0"
 
-loader-utils@^1.2.3:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
-  integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
-  dependencies:
-    big.js "^5.2.2"
-    emojis-list "^3.0.0"
-    json5 "^1.0.1"
-
 loader-utils@^2.0.0:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129"
@@ -16277,18 +16250,18 @@ nested-error-stacks@^2.0.0:
   resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61"
   integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==
 
-next-i18next@^11.3.0:
-  version "11.3.0"
-  resolved "https://registry.yarnpkg.com/next-i18next/-/next-i18next-11.3.0.tgz#bfce51d8df07fb5cd61097423eeb7d744e09ae25"
-  integrity sha512-xl0oIRtiVrk9ZaWBRUbNk/prva4Htdu59o9rFWzd9ax/KemaDVuTTuBZTQMkmXohUQk/MJ7w1rV/mICL6TzyGw==
+next-i18next@^12.1.0:
+  version "12.1.0"
+  resolved "https://registry.yarnpkg.com/next-i18next/-/next-i18next-12.1.0.tgz#70926fbe966bc4750d2f68573307bfe36eadba46"
+  integrity sha512-rhos/PVULmZPdC0jpec2MDBQMXdGZ3+Mbh/tZfrDtjgnVN3ucdq7k8BlwsJNww6FnqC8AC31n6dSYuqVzYsGsw==
   dependencies:
-    "@babel/runtime" "^7.18.6"
+    "@babel/runtime" "^7.18.9"
     "@types/hoist-non-react-statics" "^3.3.1"
     core-js "^3"
     hoist-non-react-statics "^3.3.2"
-    i18next "^21.8.13"
-    i18next-fs-backend "^1.1.4"
-    react-i18next "^11.18.0"
+    i18next "^21.9.1"
+    i18next-fs-backend "^1.1.5"
+    react-i18next "^11.18.4"
 
 next-superjson@^0.0.4:
   version "0.0.4"
@@ -18629,7 +18602,7 @@ react-hotkeys@^2.0.0:
   dependencies:
     prop-types "^15.6.1"
 
-react-i18next@^11.18.0:
+react-i18next@^11.18.4:
   version "11.18.6"
   resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.18.6.tgz#e159c2960c718c1314f1e8fcaa282d1c8b167887"
   integrity sha512-yHb2F9BiT0lqoQDt8loZ5gWP331GwctHz9tYQ8A2EIEUu+CcEdjBLQWli1USG3RdWQt3W+jqQLg/d4rrQR96LA==