Explorar el Código

Merge branch 'master' into support/omit-appcontainer

Yuki Takei hace 3 años
padre
commit
4c48431ccd
Se han modificado 100 ficheros con 1820 adiciones y 997 borrados
  1. 0 5
      .github/workflows/release-rc.yml
  2. 0 5
      .github/workflows/release.yml
  3. 83 26
      CHANGELOG.md
  4. 1 1
      lerna.json
  5. 1 1
      package.json
  6. 0 34
      packages/app/bin/shrink-emojione-strategy.js
  7. 1 0
      packages/app/docker/Dockerfile
  8. 2 2
      packages/app/docker/README.md
  9. 9 7
      packages/app/package.json
  10. 2 9
      packages/app/resource/cdn-manifests.js
  11. 11 13
      packages/app/resource/locales/en_US/sandbox.md
  12. 50 3
      packages/app/resource/locales/en_US/translation.json
  13. 11 13
      packages/app/resource/locales/ja_JP/sandbox.md
  14. 50 2
      packages/app/resource/locales/ja_JP/translation.json
  15. 11 13
      packages/app/resource/locales/zh_CN/sandbox.md
  16. 51 3
      packages/app/resource/locales/zh_CN/translation.json
  17. 14 11
      packages/app/src/client/app.jsx
  18. 9 8
      packages/app/src/client/base.jsx
  19. 10 6
      packages/app/src/client/nologin.jsx
  20. 0 4
      packages/app/src/client/services/AppContainer.js
  21. 9 7
      packages/app/src/client/services/ContextExtractor.tsx
  22. 23 2
      packages/app/src/client/services/PageContainer.js
  23. 8 7
      packages/app/src/client/util/GrowiRenderer.js
  24. 0 0
      packages/app/src/client/util/emojione/emoji_strategy_shrinked.json
  25. 66 0
      packages/app/src/client/util/markdown-it/emoji-mart-data.ts
  26. 5 19
      packages/app/src/client/util/markdown-it/emoji.js
  27. 50 0
      packages/app/src/client/util/markdown-it/link-by-relative-path.ts
  28. 1 1
      packages/app/src/components/Admin/Users/UserInviteModal.jsx
  29. 5 2
      packages/app/src/components/CustomNavigation/CustomNav.jsx
  30. 4 1
      packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx
  31. 61 0
      packages/app/src/components/EmptyTrashButton.tsx
  32. 0 72
      packages/app/src/components/EmptyTrashModal.jsx
  33. 92 0
      packages/app/src/components/EmptyTrashModal.tsx
  34. 55 0
      packages/app/src/components/MaintenanceModeContent.tsx
  35. 7 5
      packages/app/src/components/Me/ProfileImageSettings.jsx
  36. 17 27
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  37. 2 2
      packages/app/src/components/Page/TagsInput.tsx
  38. 0 2
      packages/app/src/components/PageComment/CommentEditor.jsx
  39. 8 22
      packages/app/src/components/PageCreateModal.jsx
  40. 12 10
      packages/app/src/components/PageDeleteModal.tsx
  41. 5 7
      packages/app/src/components/PageDuplicateModal.tsx
  42. 26 33
      packages/app/src/components/PageEditor.jsx
  43. 85 32
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  44. 0 1
      packages/app/src/components/PageEditor/Editor.jsx
  45. 14 0
      packages/app/src/components/PageEditor/EditorIcon.jsx
  46. 0 149
      packages/app/src/components/PageEditor/EmojiAutoCompleteHelper.js
  47. 64 0
      packages/app/src/components/PageEditor/EmojiPicker.tsx
  48. 124 0
      packages/app/src/components/PageEditor/EmojiPickerHelper.ts
  49. 48 21
      packages/app/src/components/PageRenameModal.tsx
  50. 118 18
      packages/app/src/components/PrivateLegacyPages.tsx
  51. 3 3
      packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx
  52. 1 8
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  53. 2 2
      packages/app/src/components/Sidebar/Tag.tsx
  54. 5 3
      packages/app/src/components/TagCloudBox.tsx
  55. 3 3
      packages/app/src/components/TagList.tsx
  56. 2 2
      packages/app/src/components/TagPage.tsx
  57. 9 2
      packages/app/src/components/TrashPageList.jsx
  58. 48 0
      packages/app/src/interfaces/activity.ts
  59. 7 0
      packages/app/src/interfaces/errors/v5-conversion-error.ts
  60. 3 3
      packages/app/src/interfaces/page.ts
  61. 6 9
      packages/app/src/interfaces/tag.ts
  62. 6 0
      packages/app/src/interfaces/websocket.ts
  63. 13 12
      packages/app/src/server/crowi/index.js
  64. 6 8
      packages/app/src/server/models/activity.ts
  65. 6 5
      packages/app/src/server/models/in-app-notification.ts
  66. 3 5
      packages/app/src/server/models/page-tag-relation.js
  67. 2 1
      packages/app/src/server/models/page.ts
  68. 3 4
      packages/app/src/server/models/subscription.ts
  69. 0 69
      packages/app/src/server/models/tag.js
  70. 63 0
      packages/app/src/server/models/tag.ts
  71. 0 0
      packages/app/src/server/models/vo/search-error.ts
  72. 28 0
      packages/app/src/server/models/vo/v5-conversion-error.ts
  73. 7 2
      packages/app/src/server/routes/apiv3/index.js
  74. 16 0
      packages/app/src/server/routes/apiv3/logout.js
  75. 5 1
      packages/app/src/server/routes/apiv3/page.js
  76. 80 9
      packages/app/src/server/routes/apiv3/pages.js
  77. 10 8
      packages/app/src/server/routes/index.js
  78. 1 1
      packages/app/src/server/routes/search.ts
  79. 2 1
      packages/app/src/server/routes/tag.js
  80. 9 7
      packages/app/src/server/service/comment.ts
  81. 119 30
      packages/app/src/server/service/page.ts
  82. 1 1
      packages/app/src/server/service/search.ts
  83. 0 60
      packages/app/src/server/util/activityDefine.ts
  84. 1 0
      packages/app/src/server/views/layout/layout.html
  85. 3 19
      packages/app/src/server/views/maintenance-mode.html
  86. 60 14
      packages/app/src/stores/modal.tsx
  87. 3 3
      packages/app/src/stores/tag.tsx
  88. 10 0
      packages/app/src/styles/_mixins.scss
  89. 0 1
      packages/app/src/styles/_override-bootstrap.scss
  90. 0 6
      packages/app/src/styles/_page-tree.scss
  91. 3 0
      packages/app/src/styles/_vendor.scss
  92. 5 9
      packages/app/src/styles/theme/_apply-colors-dark.scss
  93. 8 12
      packages/app/src/styles/theme/_apply-colors-light.scss
  94. 8 0
      packages/app/src/styles/theme/_apply-colors.scss
  95. 0 17
      packages/app/src/styles/theme/christmas.scss
  96. 1 1
      packages/app/src/styles/theme/default.scss
  97. 0 17
      packages/app/src/styles/theme/future.scss
  98. 0 8
      packages/app/src/styles/theme/island.scss
  99. 0 17
      packages/app/src/styles/theme/kibela.scss
  100. 24 8
      packages/app/src/styles/theme/mixins/_list-group.scss

+ 0 - 5
.github/workflows/release-rc.yml

@@ -54,8 +54,3 @@ jobs:
         cache-from: type=gha
         cache-from: type=gha
         cache-to: type=gha,mode=max
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
         tags: ${{ steps.meta.outputs.tags }}
-
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache

+ 0 - 5
.github/workflows/release.yml

@@ -183,11 +183,6 @@ jobs:
         cache-to: type=gha,mode=max
         cache-to: type=gha,mode=max
         tags: ${{ steps.meta.outputs.tags }}
         tags: ${{ steps.meta.outputs.tags }}
 
 
-    - name: Move cache
-      run: |
-        rm -rf /tmp/.buildx-cache
-        mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
     - name: Update Docker Hub Description
     - name: Update Docker Hub Description
       uses: peter-evans/dockerhub-description@v3
       uses: peter-evans/dockerhub-description@v3
       with:
       with:

+ 83 - 26
CHANGELOG.md

@@ -1,9 +1,66 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/weseek/growi/compare/v5.0.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v5.0.5...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v5.0.5](https://github.com/weseek/growi/compare/v5.0.4...v5.0.5) - 2022-05-16
+
+### 💎 Features
+
+- feat: Empty trash button in trash page (#5816) @yukendev
+
+### 🚀 Improvement
+
+- imprv: Count badge colors (#5835) @shukmos
+- imprv: List group background colors on PageTree (#5812) @shukmos
+- imprv: Page path auto complete function for page rename modal (#5805) @kaoritokashiki
+- imprv: Show toastr when converting is completed on Private Legacy Page (#5810) @yukendev
+- imprv: Create parent pages as needed by path that includes slash (#5809) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Change the execution user of the official docker image to root (#5846) @yuki-takei
+- fix: Display admin link only with logged in (#5799) @hirokei-camel
+- fix: Error when renaming (#5793) @miya
+
+### 🧰 Maintenance
+
+- support: Typescriptize tag model (#5778) @kaoritokashiki
+
+## [v5.0.4](https://github.com/weseek/growi/compare/v5.0.3...v5.0.4) - 2022-04-28
+
+### 💎 Features
+
+- feat: Private legacy pages convert by path (#5787) @hakumizuki
+- feat: Generate activity when page is created (#5765) @miya
+- feat: Private legacy pages convert by path API (#5760) @hakumizuki
+- feat:  Create notification when page is reverted (#5756) @miya
+- feat: Create notification when page is duplicated (#5749) @miya
+- feat: Add count badge to Page List button and Comment button (#5740) @yukendev
+- feat: Infinite scroll for Recent Changes in Sidebar (#5647) @mudana-grune
+
+### 🚀 Improvement
+
+- imprv: Change GET method to POST for logout operation (#5751) @kaoritokashiki
+- imprv: Redesign tags (#5730) @miya
+- imprv: i18n for already_exists error in PutBackPageModal (#5747) @kaoritokashiki
+
+### 🐛 Bug Fixes
+
+- fix: Default markdown linker with relative path does not respect the current page path (#5788) @yuki-takei
+- fix: Include any public pages as applicable ancestors (#5786) @hakumizuki
+- fix: Not create unnecessary empty pages when ancestors are public (#5774) @hakumizuki
+- fix: Too many footstamps icons are shown by lsx output 2 (#5763) @yuki-takei
+- fix:  footstamp-icon size (#5759) @kaoritokashiki
+
+## [v4.5.19](https://github.com/weseek/growi/compare/v4.5.18...v4.5.19) - 2022-04-28
+
+### 🐛 Bug Fixes
+
+- fix: Swiping to previous/next page for Mac users (4.5.x) (#5758) @hirokei-camel
+- fix: Get attachment list api without "page" parameter returns 500 response (#5726) @miya
+
 ## [v5.0.3](https://github.com/weseek/growi/compare/v5.0.2...v5.0.3) - 2022-04-21
 ## [v5.0.3](https://github.com/weseek/growi/compare/v5.0.2...v5.0.3) - 2022-04-21
 
 
 ### 💎 Features
 ### 💎 Features
@@ -69,6 +126,31 @@
 - support: Migration for setting sparce option to slack member id (#5694) @kaoritokashiki
 - support: Migration for setting sparce option to slack member id (#5694) @kaoritokashiki
 - support: Update eslint-config-weseek (#5673) @yuki-takei
 - support: Update eslint-config-weseek (#5673) @yuki-takei
 
 
+## [v4.5.18](https://github.com/weseek/growi/compare/v4.5.17...v4.5.18) - 2022-04-15
+
+### 🐛 Bug Fixes
+
+- fix: One Time Token is not available for v4.5.x (#5713) @miya
+- fix: Prevent auto completing email with username stored by browser in /me page for v4.5.x (#5703) @Yohei-Shiina
+- fix: Page view count stops at 15 (#5705) @miya
+
+## [v4.5.17](https://github.com/weseek/growi/compare/v4.5.16...v4.5.17) - 2022-04-07
+
+### 🐛 Bug Fixes
+
+- fix: Elasticsearch doesn't work properly on production (#5676) @Yohei-Shiina
+
+## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
+
+### 💎 Features
+
+- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
+
+### 🐛 Bug Fixes
+
+- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
+- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
+
 ## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
 ## [v5.0.0](https://github.com/weseek/growi/compare/v4.5.15...v5.0.0) - 2022-04-01
 
 
 ### 💎 Features
 ### 💎 Features
@@ -119,31 +201,6 @@
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update nanoid yarn.lock v3.1.30 to v3.2.0 (#5216) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 - support: update validator version (#5562) @LuqmanHakim-Grune
 
 
-## [v4.5.18](https://github.com/weseek/growi/compare/v4.5.17...v4.5.18) - 2022-04-15
-
-### 🐛 Bug Fixes
-
-- fix: One Time Token is not available for v4.5.x (#5713) @miya
-- fix: Prevent auto completing email with username stored by browser in /me page for v4.5.x (#5703) @Yohei-Shiina
-- fix: Page view count stops at 15 (#5705) @miya
-
-## [v4.5.17](https://github.com/weseek/growi/compare/v4.5.16...v4.5.17) - 2022-04-07
-
-### 🐛 Bug Fixes
-
-- fix: Elasticsearch doesn't work properly on production (#5676) @Yohei-Shiina
-
-## [v4.5.16](https://github.com/weseek/growi/compare/v4.5.15...v4.5.16) - 2022-04-06
-
-### 💎 Features
-
-- feat: Support Elasticsearch 7 (#5613) @Yohei-Shiina
-
-### 🐛 Bug Fixes
-
-- fix: Domain whitelist is not respected (fix #5408) (#5488) @yuto-oweseek
-- fix: Add tags to pages restricted by specified groups on View mode (for v4.5.x) (#5487) @yuto-oweseek
-
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 ## [v4.5.15](https://github.com/weseek/growi/compare/v4.5.14...v4.5.15) - 2022-02-17
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 1
lerna.json

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

+ 1 - 1
package.json

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

+ 0 - 34
packages/app/bin/shrink-emojione-strategy.js

@@ -1,34 +0,0 @@
-/**
- * the tool to shrink emojione/emoji_strategy.json and output
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- */
-/*
-require('module-alias/register');
-
-const fs = require('graceful-fs');
-
-const helpers = require('@commons/util/helpers');
-
-const emojiStrategy = require('emojione/emoji_strategy.json');
-const markdownItEmojiFull = require('markdown-it-emoji/lib/data/full.json');
-
-const OUT = helpers.root('tmp/emoji_strategy_shrinked.json');
-
-const shrinkedMap = {};
-Object.keys(emojiStrategy).forEach((unicode) => {
-  const data = emojiStrategy[unicode];
-  const shortname = data.shortname.replace(/:/g, '');
-
-  // ignore if it isn't included in markdownItEmojiFull
-  if (markdownItEmojiFull[shortname] == null) {
-    return;
-  }
-
-  // add
-  shrinkedMap[unicode] = data;
-});
-
-// write
-fs.writeFileSync(OUT, JSON.stringify(shrinkedMap));
-*/

+ 1 - 0
packages/app/docker/Dockerfile

@@ -159,6 +159,7 @@ RUN rm node_modules.tar packages.tar
 
 
 COPY --chown=node:node --chmod=700 packages/app/docker/docker-entrypoint.sh /
 COPY --chown=node:node --chmod=700 packages/app/docker/docker-entrypoint.sh /
 
 
+USER root
 WORKDIR ${appDir}/packages/app
 WORKDIR ${appDir}/packages/app
 
 
 VOLUME /data
 VOLUME /data

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

@@ -10,8 +10,8 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 ------------------------------------------------
 
 
-* [`5.0.3`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.3/docker/Dockerfile)
-* [`5.0.3-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.3/docker/Dockerfile)
+* [`5.0.5`, `5.0`, `5`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.5/docker/Dockerfile)
+* [`5.0.5-nocdn`, `5.0-nocdn`, `5-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v5.0.5/docker/Dockerfile)
 * [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.5.15`, `4.5`, `4`, `latest` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.5.15-nocdn`, `4.5-nocdn`, `4-nocdn`, `latest-nocdn` (Dockerfile)](https://github.com/weseek/growi/blob/v4.5.15/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)
 * [`4.4.13`, `4.4` (Dockerfile)](https://github.com/weseek/growi/blob/v4.4.13/docker/Dockerfile)

+ 9 - 7
packages/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "5.0.4-RC.0",
+  "version": "5.0.6-RC.0",
   "license": "MIT",
   "license": "MIT",
   "scripts": {
   "scripts": {
     "//// for production": "",
     "//// for production": "",
@@ -62,11 +62,11 @@
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/codemirror-textlint": "^5.0.4-RC.0",
-    "@growi/plugin-attachment-refs": "^5.0.4-RC.0",
-    "@growi/plugin-lsx": "^5.0.4-RC.0",
-    "@growi/plugin-pukiwiki-like-linker": "^5.0.4-RC.0",
-    "@growi/slack": "^5.0.4-RC.0",
+    "@growi/codemirror-textlint": "^5.0.6-RC.0",
+    "@growi/plugin-attachment-refs": "^5.0.6-RC.0",
+    "@growi/plugin-lsx": "^5.0.6-RC.0",
+    "@growi/plugin-pukiwiki-like-linker": "^5.0.6-RC.0",
+    "@growi/slack": "^5.0.6-RC.0",
     "@promster/express": "^7.0.2",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",
     "@promster/server": "^7.0.4",
     "@slack/events-api": "^3.0.0",
     "@slack/events-api": "^3.0.0",
@@ -167,7 +167,7 @@
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
     "@alienfast/i18next-loader": "^1.1.4",
-    "@growi/ui": "^5.0.4-RC.0",
+    "@growi/ui": "^5.0.6-RC.0",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/express": "^4.17.11",
@@ -187,6 +187,8 @@
     "csv-to-markdown-table": "^1.0.1",
     "csv-to-markdown-table": "^1.0.1",
     "diff2html": "^3.1.2",
     "diff2html": "^3.1.2",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
+    "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
+    "markdown-it-emoji-mart": "^0.1.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-cypress": "^2.12.1",
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",
     "file-loader": "^5.0.2",
     "file-loader": "^5.0.2",

+ 2 - 9
packages/app/resource/cdn-manifests.js

@@ -3,7 +3,7 @@ module.exports = {
     {
     {
       name: 'basis',
       name: 'basis',
       // eslint-disable-next-line max-len
       // eslint-disable-next-line max-len
-      url: 'https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.4.0,npm/popper.js@1.15.0,npm/bootstrap@4.5.0/dist/js/bootstrap.min.js,npm/scrollpos-styler@0.7.1,npm/jquery-slimscroll@1.3.8/jquery.slimscroll.min.js',
+      url: 'https://cdn.jsdelivr.net/combine/npm/jquery@3.4.0,npm/popper.js@1.15.0,npm/bootstrap@4.5.0/dist/js/bootstrap.min.js,npm/scrollpos-styler@0.7.1,npm/jquery-slimscroll@1.3.8/jquery.slimscroll.min.js',
       groups: ['basis'],
       groups: ['basis'],
       args: {
       args: {
         integrity: '',
         integrity: '',
@@ -138,14 +138,7 @@ module.exports = {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
-    {
-      name: 'emojione',
-      url: 'https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css',
-      groups: ['basis'],
-      args: {
-        integrity: '',
-      },
-    },
+
     {
     {
       name: 'animate.css',
       name: 'animate.css',
       url: 'https://cdn.jsdelivr.net/npm/animate.css@3.7.2/animate.min.css',
       url: 'https://cdn.jsdelivr.net/npm/animate.css@3.7.2/animate.min.css',

+ 11 - 13
packages/app/resource/locales/en_US/sandbox.md

@@ -12,7 +12,7 @@
   </div>
   </div>
 </div>
 </div>
 
 
-# :pencil: Block Elements
+# :memo: Block Elements
 
 
 ## Headers
 ## Headers
 
 
@@ -160,7 +160,7 @@ ___
 
 
 
 
 
 
-# :pencil: Typography
+# :memo: Typography
 
 
 ## Strong Text
 ## Strong Text
 
 
@@ -200,7 +200,7 @@ This is ___Italic & Bold___.
 This is ***Italic & Bold***.
 This is ***Italic & Bold***.
 This is ___Italic & Bold___.
 This is ___Italic & Bold___.
 
 
-# :pencil: Images
+# :memo: Images
 
 
 You can insert `<img>` tag using `![description](URL)`.
 You can insert `<img>` tag using `![description](URL)`.
 
 
@@ -221,7 +221,7 @@ The size of the image can be set by using an HTML image tag
 <img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
 <img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
 
 
 
 
-# :pencil: Link
+# :memo: Link
 
 
 ## Markdown standard
 ## Markdown standard
 
 
@@ -259,7 +259,7 @@ Example of Bootstrap4 is [[here>./Bootstrap4]]
 [[./Bootstrap4]]  
 [[./Bootstrap4]]  
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 Example of Bootstrap4 is[[here>./Bootstrap4]]
 
 
-# :pencil: Lists
+# :memo: Lists
 
 
 ## Ul Bulleted list
 ## Ul Bulleted list
 
 
@@ -319,7 +319,7 @@ The numbers don’t have to be in numerical order, but the list should start wit
 - [x] Task2
 - [x] Task2
 
 
 
 
-# :pencil: Table
+# :memo: Table
 
 
 ## Markdown Standard
 ## Markdown Standard
 
 
@@ -415,7 +415,7 @@ Content Cell,Content Cell
 :::
 :::
 
 
 
 
-# :pencil: Footnote
+# :memo: Footnote
 
 
 You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
 You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
 
 
@@ -428,15 +428,13 @@ Long footnotes can be written as [^longnote].
     Subsequent paragraphs are indented and belong to the previous footnote.
     Subsequent paragraphs are indented and belong to the previous footnote.
 
 
 
 
-# :pencil: Emoji
-
-See [emojione](https://www.emojione.com/)
+# :memo: Emoji
 
 
 :smiley: :smile: :laughing: :innocent: :drooling_face:
 :smiley: :smile: :laughing: :innocent: :drooling_face:
 
 
-:family: :family_man_boy: :family_man_girl: :family_man_girl_girl: :family_woman_girl_girl:
+:family: :man-boy: :man-girl: :man-girl-girl: :woman-girl-girl:
 
 
-:thumbsup: :thumbsdown: :open_hands: :raised_hands: :point_right:
+:+1: :-1: :open_hands: :raised_hands: :point_right:
 
 
 :apple: :green_apple: :strawberry: :cake: :hamburger:
 :apple: :green_apple: :strawberry: :cake: :hamburger:
 
 
@@ -444,7 +442,7 @@ See [emojione](https://www.emojione.com/)
 
 
 :hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
 :hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
 
 
-:watch: :gear: :gem: :wrench: :envelope:
+:watch: :gear: :gem: :wrench: :email:
 
 
 
 
 # :heavy_plus_sign: More..
 # :heavy_plus_sign: More..

+ 50 - 3
packages/app/resource/locales/en_US/translation.json

@@ -186,9 +186,7 @@
     "error_message": "Some values ​​are incorrect",
     "error_message": "Some values ​​are incorrect",
     "required": "%s is required",
     "required": "%s is required",
     "invalid_syntax": "The syntax of %s is invalid.",
     "invalid_syntax": "The syntax of %s is invalid.",
-    "title_required": "Title is required.",
-    "slashed_are_not_yet_supported": "Titles containing slashes are not yet supported"
-
+    "title_required": "Title is required."
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "Create Page",
     "Create Page": "Create Page",
@@ -443,8 +441,11 @@
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages": "{{path}} has been deleted",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "deleted_pages_completely": "{{path}} has been deleted completely",
   "renamed_pages": "{{path}} has been renamed",
   "renamed_pages": "{{path}} has been renamed",
+  "empty_trash": "The trash has been emptied",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "Empty The Trash",
     "empty_the_trash": "Empty The Trash",
+    "empty_the_trash_button": "Empty The Trash",
+    "not_deletable_notice": "Some pages cannot be removed due to lack of permission.",
     "notice": "The pages deleted completely are unrecoverable."
     "notice": "The pages deleted completely are unrecoverable."
   },
   },
   "modal_duplicate": {
   "modal_duplicate": {
@@ -642,6 +643,7 @@
   "private_legacy_pages": {
   "private_legacy_pages": {
     "bulk_operation": "Bulk operation",
     "bulk_operation": "Bulk operation",
     "convert_all_selected_pages": "Convert all to new v5 compatible format",
     "convert_all_selected_pages": "Convert all to new v5 compatible format",
+    "input_path_to_convert": "Input a path to convert pages",
     "alert_title": "Old v4 compatible format private pages exist.",
     "alert_title": "Old v4 compatible format private pages exist.",
     "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
     "alert_desc1": "On this page, you can select pages with the checkbox and batch convert to the new v5 compatible format from the \"Bulk operation\" button at the top of the screen.",
     "nopages_title": "Congratulations. Ready to use GROWI v5!",
     "nopages_title": "Congratulations. Ready to use GROWI v5!",
@@ -653,6 +655,21 @@
       "convert_recursively_label": "Convert child pages recursively.",
       "convert_recursively_label": "Convert child pages recursively.",
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "convert_recursively_desc": "Convert pages under this path recursively.",
       "button_label": "Convert"
       "button_label": "Convert"
+    },
+    "toaster": {
+      "page_migration_succeeded": "Conversion of selected page to v5 has been successfully completed.",
+      "page_migration_failed_with_paths": "Conversion of {{paths}} to v5 has been failed.",
+      "page_migration_failed": "Conversion of page to v5 has been failed."
+    },
+    "by_path_modal": {
+      "title": "Convert to new v5 compatible format",
+      "description": "Enter a path and all pages under that path will be converted to v5 compatible format.",
+      "button_label": "Convert",
+      "success": "Successfully requested conversion.",
+      "error": "Failed to request conversion.",
+      "error_grant_invalid": "Page permissions are incorrect. Please correct it and try again.",
+      "error_page_not_found": "Page not found.",
+      "error_duplicate_pages_found": "Multiple pages with the same path name were found. Please rename or delete and try again."
     }
     }
   },
   },
   "security_setting": {
   "security_setting": {
@@ -1005,6 +1022,36 @@
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "incorrect_token_or_expired_url": "The token is incorrect or the URL has expired. Please resend a password reset request via the link below.",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match"
   },
   },
+  "emoji" :{
+    "title": "Pick an Emoji",
+    "search": "Search",
+    "clear": "Clear",
+    "notfound": "No Emoji Found",
+    "skintext": "Choose your default skin tone",
+    "categories": {
+      "search": "Search Results",
+      "recent": "Frequently Used",
+      "smileys": "Smileys & Emotion",
+      "people": "People & Body",
+      "nature": "Animals & Nature",
+      "foods": "Food & Drink",
+      "activity": "Activity",
+      "places": "Travel & Places",
+      "objects": "Objects",
+      "symbols": "Symbols",
+      "flags": "Flags",
+      "custom": "Custom"
+    },
+    "categorieslabel": "Emoji categories",
+    "skintones": {
+      "1": "Default Skin Tone",
+      "2": "Light Skin Tone",
+      "3": "Medium-Light Skin Tone",
+      "4": "Medium Skin Tone",
+      "5": "Medium-Dark Skin Tone",
+      "6": "Dark Skin Tone"
+    }
+  },
   "maintenance_mode":{
   "maintenance_mode":{
     "maintenance_mode": "Maintenance Mode",
     "maintenance_mode": "Maintenance Mode",
     "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",
     "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",

+ 11 - 13
packages/app/resource/locales/ja_JP/sandbox.md

@@ -12,7 +12,7 @@
   </div>
   </div>
 </div>
 </div>
 
 
-# :pencil: Block Elements
+# :memo: Block Elements
 
 
 ## Headers 見出し
 ## Headers 見出し
 
 
@@ -159,7 +159,7 @@ ___
 
 
 
 
 
 
-# :pencil: Typography
+# :memo: Typography
 
 
 ## 強調
 ## 強調
 
 
@@ -199,7 +199,7 @@ ___
 これは ***イタリック&ボールド*** です
 これは ***イタリック&ボールド*** です
 これは ___イタリック&ボールド___ です
 これは ___イタリック&ボールド___ です
 
 
-# :pencil: Images
+# :memo: Images
 
 
 `![Alt文字列](URL)` で`<img>`タグを挿入できます。
 `![Alt文字列](URL)` で`<img>`タグを挿入できます。
 
 
@@ -220,7 +220,7 @@ ___
 <img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
 <img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
 
 
 
 
-# :pencil: Link
+# :memo: Link
 
 
 ## Markdown 標準
 ## Markdown 標準
 
 
@@ -258,7 +258,7 @@ Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 [[./Bootstrap4]]  
 [[./Bootstrap4]]  
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 
 
-# :pencil: Lists
+# :memo: Lists
 
 
 ## Ul 箇条書きリスト
 ## Ul 箇条書きリスト
 
 
@@ -318,7 +318,7 @@ Bootstrap4のExampleは[[こちら>./Bootstrap4]]
 - [x] タスク2
 - [x] タスク2
 
 
 
 
-# :pencil: Table
+# :memo: Table
 
 
 ## Markdown 標準
 ## Markdown 標準
 
 
@@ -414,7 +414,7 @@ Content Cell,Content Cell
 :::
 :::
 
 
 
 
-# :pencil: Footnote
+# :memo: Footnote
 
 
 脚注への参照[^1]を書くことができます。また、インラインの脚注^[インラインで記述できる脚注です]を入れる事も出来ます。
 脚注への参照[^1]を書くことができます。また、インラインの脚注^[インラインで記述できる脚注です]を入れる事も出来ます。
 
 
@@ -427,15 +427,13 @@ Content Cell,Content Cell
     後続の段落はインデントされて、前の脚注に属します。
     後続の段落はインデントされて、前の脚注に属します。
 
 
 
 
-# :pencil: Emoji
-
-See [emojione](https://www.emojione.com/)
+# :memo: Emoji
 
 
 :smiley: :smile: :laughing: :innocent: :drooling_face:
 :smiley: :smile: :laughing: :innocent: :drooling_face:
 
 
-:family: :family_man_boy: :family_man_girl: :family_man_girl_girl: :family_woman_girl_girl:
+:family: :man-boy: :man-girl: :man-girl-girl: :woman-girl-girl:
 
 
-:thumbsup: :thumbsdown: :open_hands: :raised_hands: :point_right:
+:+1: :-1: :open_hands: :raised_hands: :point_right:
 
 
 :apple: :green_apple: :strawberry: :cake: :hamburger:
 :apple: :green_apple: :strawberry: :cake: :hamburger:
 
 
@@ -443,7 +441,7 @@ See [emojione](https://www.emojione.com/)
 
 
 :hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
 :hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
 
 
-:watch: :gear: :gem: :wrench: :envelope:
+:watch: :gear: :gem: :wrench: :email:
 
 
 
 
 
 

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

@@ -188,8 +188,7 @@
     "error_message": "いくつかの値が設定されていません",
     "error_message": "いくつかの値が設定されていません",
     "required": "%sに値を入力してください",
     "required": "%sに値を入力してください",
     "invalid_syntax": "%sの構文が不正です",
     "invalid_syntax": "%sの構文が不正です",
-    "title_required": "タイトルを入力してください",
-    "slashed_are_not_yet_supported": "スラッシュを含むタイトルにはまだ対応していません"
+    "title_required": "タイトルを入力してください"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "ページを作成する",
     "Create Page": "ページを作成する",
@@ -442,8 +441,11 @@
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages": "{{path}} をゴミ箱に入れました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "deleted_pages_completely": "{{path}} を完全に削除しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
   "renamed_pages": "{{path}} を移動/名前変更しました",
+  "empty_trash": "ゴミ箱を空にしました",
   "modal_empty":{
   "modal_empty":{
     "empty_the_trash": "ゴミ箱を空にする",
     "empty_the_trash": "ゴミ箱を空にする",
+    "empty_the_trash_button": "空にする",
+    "not_deletable_notice": "権限がないため、いくつかのページは削除できません",
     "notice": "完全削除したページは元に戻すことができません"
     "notice": "完全削除したページは元に戻すことができません"
   },
   },
   "modal_duplicate": {
   "modal_duplicate": {
@@ -641,6 +643,7 @@
   "private_legacy_pages": {
   "private_legacy_pages": {
     "bulk_operation": "一括操作",
     "bulk_operation": "一括操作",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
     "convert_all_selected_pages": "新しい v5 互換形式に一括変換",
+    "input_path_to_convert": "パスを入力して変換",
     "alert_title": "古い v4 互換形式のプライベートページが存在します",
     "alert_title": "古い v4 互換形式のプライベートページが存在します",
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "alert_desc1": "このページでは、チェックボックスでページを選択し、画面上部の「一括操作」ボタンから新しい v5 互換形式に一括変換できます。",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
     "nopages_title": "おめでとうございます。GROWI v5 を使う準備が完了しました!",
@@ -652,6 +655,21 @@
       "convert_recursively_label": "再起的に変換",
       "convert_recursively_label": "再起的に変換",
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "convert_recursively_desc": "このページの配下のページを再起的に変換します",
       "button_label": "変換"
       "button_label": "変換"
+    },
+    "toaster": {
+      "page_migration_succeeded": "選択されたページの v5 互換形式への変換が正常に終了しました。",
+      "page_migration_failed_with_paths": "{{paths}} の v5 互換形式への変換中にエラーが発生しました。",
+      "page_migration_failed": "ページの v5 互換形式への変換中にエラーが発生しました。"
+    },
+    "by_path_modal": {
+      "title": "新しい v5 互換形式への変換",
+      "description": "パスを入力することで、そのパスの配下のページを全て v5 互換形式に変換します",
+      "button_label": "変換",
+      "success": "正常に変換を開始しました",
+      "error": "変換を開始できませんでした",
+      "error_grant_invalid": "ページの権限が正しくありません。修正してから再度実行してください",
+      "error_page_not_found": "ページが見つかりませんでした",
+      "error_duplicate_pages_found": "同名のパスを持つページが複数見つかりました。リネームまたは削除してから再度実行してください"
     }
     }
   },
   },
   "security_setting": {
   "security_setting": {
@@ -997,6 +1015,36 @@
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "incorrect_token_or_expired_url":"トークンが正しくないか、URLの有効期限が切れています。 以下のリンクからパスワードリセットリクエストを再送信してください。",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません"
   },
   },
+  "emoji" :{
+    "title": "絵文字を選択",
+    "search": "探す",
+    "clear": "リセット",
+    "notfound": "絵文字が見つかりません",
+    "skintext": "デフォルトの肌の色を選択",
+    "categories": {
+      "search": "検索結果",
+      "recent": "最新履歴",
+      "smileys": "スマイリーと感情",
+      "people": "人と体",
+      "nature": "動物と自然",
+      "foods": "食べ物や飲み物",
+      "activity": "アクティビティ",
+      "places": "旅行と場所",
+      "objects": "オブジェクト",
+      "symbols": "シンボル",
+      "flags": "国旗",
+      "custom": "カスタマイズ"
+    },
+    "categorieslabel": "絵文字カテゴリ",
+    "skintones": {
+      "1": "デフォルトの肌の色",
+      "2": "明るい肌のトーン",
+      "3": "ミディアム-明るい肌のトーン",
+      "4": "ミディアムスキントーン",
+      "5": "ミディアムダークスキントーン",
+      "6": "肌の色が濃い"
+    }
+  },
   "maintenance_mode":{
   "maintenance_mode":{
     "maintenance_mode": "メンテナンスモード",
     "maintenance_mode": "メンテナンスモード",
     "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",
     "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",

+ 11 - 13
packages/app/resource/locales/zh_CN/sandbox.md

@@ -12,7 +12,7 @@
   </div>
   </div>
 </div>
 </div>
 
 
-# :pencil: Block Elements
+# :memo: Block Elements
 
 
 ## Headers
 ## Headers
 
 
@@ -160,7 +160,7 @@ ___
 
 
 
 
 
 
-# :pencil: Typography
+# :memo: Typography
 
 
 ## Strong Text
 ## Strong Text
 
 
@@ -200,7 +200,7 @@ This is ___Italic & Bold___.
 This is ***Italic & Bold***.
 This is ***Italic & Bold***.
 This is ___Italic & Bold___.
 This is ___Italic & Bold___.
 
 
-# :pencil: Images
+# :memo: Images
 
 
 You can insert `<img>` tag using `![description](URL)`.
 You can insert `<img>` tag using `![description](URL)`.
 
 
@@ -221,7 +221,7 @@ The size of the image can be set by using an HTML image tag
 <img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
 <img src="https://octodex.github.com/images/dojocat.jpg" width="200px">
 
 
 
 
-# :pencil: Link
+# :memo: Link
 
 
 ## Markdown standard
 ## Markdown standard
 
 
@@ -259,7 +259,7 @@ Example of Bootstrap4 is[[here>./Bootstrap4]]
 [[./Bootstrap4]]  
 [[./Bootstrap4]]  
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 Example of Bootstrap4 is [[here>./Bootstrap4]]
 
 
-# :pencil: Lists
+# :memo: Lists
 
 
 ## Ul Bulleted list
 ## Ul Bulleted list
 
 
@@ -319,7 +319,7 @@ The numbers don’t have to be in numerical order, but the list should start wit
 - [x] Task2
 - [x] Task2
 
 
 
 
-# :pencil: Table
+# :memo: Table
 
 
 ## Markdown Standard
 ## Markdown Standard
 
 
@@ -415,7 +415,7 @@ Content Cell,Content Cell
 :::
 :::
 
 
 
 
-# :pencil: Footnote
+# :memo: Footnote
 
 
 You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
 You can write a reference [^1] to a footnote. You can also add an inline footnote^[Inline_footnote].
 
 
@@ -428,15 +428,13 @@ Long footnotes can be written as [^longnote].
     Subsequent paragraphs are indented and belong to the previous footnote.
     Subsequent paragraphs are indented and belong to the previous footnote.
 
 
 
 
-# :pencil: Emoji
-
-See [emojione](https://www.emojione.com/)
+# :memo: Emoji
 
 
 :smiley: :smile: :laughing: :innocent: :drooling_face:
 :smiley: :smile: :laughing: :innocent: :drooling_face:
 
 
-:family: :family_man_boy: :family_man_girl: :family_man_girl_girl: :family_woman_girl_girl:
+:family: :man-boy: :man-girl: :man-girl-girl: :woman-girl-girl:
 
 
-:thumbsup: :thumbsdown: :open_hands: :raised_hands: :point_right:
+:+1: :-1: :open_hands: :raised_hands: :point_right:
 
 
 :apple: :green_apple: :strawberry: :cake: :hamburger:
 :apple: :green_apple: :strawberry: :cake: :hamburger:
 
 
@@ -444,7 +442,7 @@ See [emojione](https://www.emojione.com/)
 
 
 :hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
 :hearts: :broken_heart: :heartbeat: :heartpulse: :heart_decoration:
 
 
-:watch: :gear: :gem: :wrench: :envelope:
+:watch: :gear: :gem: :wrench: :email:
 
 
 
 
 # :heavy_plus_sign: More..
 # :heavy_plus_sign: More..

+ 51 - 3
packages/app/resource/locales/zh_CN/translation.json

@@ -186,8 +186,7 @@
 		"error_message": "有些值不正确",
 		"error_message": "有些值不正确",
 		"required": "%s 是必需的",
 		"required": "%s 是必需的",
 		"invalid_syntax": "%s的语法无效。",
 		"invalid_syntax": "%s的语法无效。",
-    "title_required": "标题是必需的。",
-    "slashed_are_not_yet_supported": "目前还不支持包含斜线的标题"
+    "title_required": "标题是必需的。"
   },
   },
   "not_found_page": {
   "not_found_page": {
     "Create Page": "创建页面",
     "Create Page": "创建页面",
@@ -421,8 +420,11 @@
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages": "将 {{path}} 放入垃圾箱",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "deleted_pages_completely": "{{path}} 已被完全删除",
   "renamed_pages": "移动/重命名 {{path}}",
   "renamed_pages": "移动/重命名 {{path}}",
+  "empty_trash": "清空垃圾",
 	"modal_empty": {
 	"modal_empty": {
-		"empty_the_trash": "Empty The Trash",
+		"empty_the_trash": "清空垃圾",
+    "empty_the_trash_button": "清空垃圾",
+    "not_deletable_notice": "由于缺乏权限,一些页面不能被删除",
 		"notice": "完全删除的页面是不可恢复的。"
 		"notice": "完全删除的页面是不可恢复的。"
 	},
 	},
 	"modal_duplicate": {
 	"modal_duplicate": {
@@ -928,6 +930,7 @@
   "private_legacy_pages": {
   "private_legacy_pages": {
     "bulk_operation": "批量操作",
     "bulk_operation": "批量操作",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
     "convert_all_selected_pages": "全部转换为新的v5兼容格式",
+		"input_path_to_convert": "输入一个转换页面的路径",
     "alert_title": "存在旧的v4兼容格式的私人网页。",
     "alert_title": "存在旧的v4兼容格式的私人网页。",
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "alert_desc1": "在这一页,你可以用复选框选择页面,并通过屏幕上方的批量操作按钮批量转换为新的v5兼容格式。",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
     "nopages_title": "恭喜你。准备使用GROWI v5!",
@@ -939,6 +942,21 @@
       "convert_recursively_label": "递归地转换子页面。",
       "convert_recursively_label": "递归地转换子页面。",
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "convert_recursively_desc": "递归地转换该路径下的页面。",
       "button_label": "转换"
       "button_label": "转换"
+    },
+    "toaster": {
+      "page_migration_succeeded": "已成功将所选页面转换为 v5 兼容格式。",
+      "page_migration_failed_with_paths": "将 {{paths}} 转换为 v5 兼容格式时出错",
+      "page_migration_failed": "将页面转换为 v5 兼容格式时出错。"
+    },
+    "by_path_modal": {
+      "title": "转换为新的v5兼容格式",
+      "description": "输入一个路径,该路径下的所有页面将被转换为v5兼容格式。",
+      "button_label": "转换",
+      "success": "成功地请求转换。",
+      "error": "请求转换失败。",
+      "error_grant_invalid": "页面权限不正确。请更正并重试。",
+      "error_page_not_found": "没有找到页面。",
+      "error_duplicate_pages_found": "发现多个具有相同路径名称的页面。请重新命名或删除并重试。"
     }
     }
   },
   },
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
 	"to_cloud_settings": "進入 GROWI.cloud 的管理界面",
@@ -1007,6 +1025,36 @@
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "incorrect_token_or_expired_url":"令牌不正确或 URL 已过期。 请通过以下链接重新发送密码重置请求",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配"
   },
   },
+  "emoji" :{
+    "title": "选择一个表情符号",
+    "search": "搜索",
+    "clear": "重置",
+    "notfound": "找不到表情符号",
+    "skintext": "选择您的默认肤色",
+    "categories": {
+      "search": "搜索结果",
+      "recent": "经常使用",
+      "smileys": "笑脸和情感",
+      "people": "人和身体",
+      "nature": "动物与自然",
+      "foods": "食物和饮料",
+      "activity": "活动",
+      "places": "旅行和地方",
+      "objects": "对象",
+      "symbols": "符号",
+      "flags": "旗帜",
+      "custom": "定制"
+    },
+    "categorieslabel": "表情符号类别",
+    "skintones": {
+      "1": "默认肤色",
+      "2": "浅肤色",
+      "3": "中浅肤色",
+      "4": "中等肤色",
+      "5": "中深肤色",
+      "6": "深色肤色"
+    }
+  },
   "maintenance_mode":{
   "maintenance_mode":{
     "maintenance_mode": "维护模式",
     "maintenance_mode": "维护模式",
     "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",
     "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",

+ 14 - 11
packages/app/src/client/app.jsx

@@ -16,38 +16,39 @@ import PersonalContainer from '~/client/services/PersonalContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import RevisionComparerContainer from '~/client/services/RevisionComparerContainer';
 import TagContainer from '~/client/services/TagContainer';
 import TagContainer from '~/client/services/TagContainer';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
 import IdenticalPathPage from '~/components/IdenticalPathPage';
-import { PrivateLegacyPages } from '~/components/PrivateLegacyPages';
+import PrivateLegacyPages from '~/components/PrivateLegacyPages';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import ErrorBoundary from '../components/ErrorBoudary';
 import ErrorBoundary from '../components/ErrorBoudary';
-import RedirectedAlert from '../components/Page/RedirectedAlert';
-import TrashPageList from '../components/TrashPageList';
-import TrashPageAlert from '../components/Page/TrashPageAlert';
-import NotFoundPage from '../components/NotFoundPage';
-import NotFoundAlert from '../components/Page/NotFoundAlert';
+import Fab from '../components/Fab';
 import ForbiddenPage from '../components/ForbiddenPage';
 import ForbiddenPage from '../components/ForbiddenPage';
-import PageStatusAlert from '../components/PageStatusAlert';
-import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
 import RecentlyCreatedIcon from '../components/Icons/RecentlyCreatedIcon';
-import MyDraftList from '../components/MyDraftList/MyDraftList';
-import BookmarkList from '../components/PageList/BookmarkList';
-import Fab from '../components/Fab';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
 import InAppNotificationPage from '../components/InAppNotification/InAppNotificationPage';
+import MaintenanceModeContent from '../components/MaintenanceModeContent';
 import PersonalSettings from '../components/Me/PersonalSettings';
 import PersonalSettings from '../components/Me/PersonalSettings';
+import MyDraftList from '../components/MyDraftList/MyDraftList';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiContextualSubNavigation from '../components/Navbar/GrowiContextualSubNavigation';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
 import GrowiSubNavigationSwitcher from '../components/Navbar/GrowiSubNavigationSwitcher';
+import NotFoundPage from '../components/NotFoundPage';
 import Page from '../components/Page';
 import Page from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
+import NotFoundAlert from '../components/Page/NotFoundAlert';
+import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
+import TrashPageAlert from '../components/Page/TrashPageAlert';
 import PageComment from '../components/PageComment';
 import PageComment from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
 import PageContentFooter from '../components/PageContentFooter';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
 import { defaultEditorOptions, defaultPreviewOptions } from '../components/PageEditor/OptionsSelector';
+import BookmarkList from '../components/PageList/BookmarkList';
+import PageStatusAlert from '../components/PageStatusAlert';
 import PageTimeline from '../components/PageTimeline';
 import PageTimeline from '../components/PageTimeline';
+import RecentCreated from '../components/RecentCreated/RecentCreated';
 import { SearchPage } from '../components/SearchPage';
 import { SearchPage } from '../components/SearchPage';
 import Sidebar from '../components/Sidebar';
 import Sidebar from '../components/Sidebar';
 import TagPage from '../components/TagPage';
 import TagPage from '../components/TagPage';
+import TrashPageList from '../components/TrashPageList';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
 import { toastError } from './util/apiNotification';
 import { toastError } from './util/apiNotification';
@@ -94,6 +95,8 @@ Object.assign(componentMappings, {
 
 
   'grw-page-status-alert-container': <PageStatusAlert />,
   'grw-page-status-alert-container': <PageStatusAlert />,
 
 
+  'maintenance-mode-content': <MaintenanceModeContent />,
+
   'trash-page-alert': <TrashPageAlert />,
   'trash-page-alert': <TrashPageAlert />,
 
 
   'trash-page-list-container': <TrashPageList />,
   'trash-page-list-container': <TrashPageList />,

+ 9 - 8
packages/app/src/client/base.jsx

@@ -1,22 +1,22 @@
 import React from 'react';
 import React from 'react';
 
 
+import AppContainer from '~/client/services/AppContainer';
+import SocketIoContainer from '~/client/services/SocketIoContainer';
+import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PutbackPageModal from '~/components/PutbackPageModal';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import EmptyTrashModal from '../components/EmptyTrashModal';
+import HotkeysManager from '../components/Hotkeys/HotkeysManager';
 import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbar from '../components/Navbar/GrowiNavbar';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
 import GrowiNavbarBottom from '../components/Navbar/GrowiNavbarBottom';
-import HotkeysManager from '../components/Hotkeys/HotkeysManager';
+import PageAccessoriesModal from '../components/PageAccessoriesModal';
 import PageCreateModal from '../components/PageCreateModal';
 import PageCreateModal from '../components/PageCreateModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDeleteModal from '../components/PageDeleteModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
 import PageDuplicateModal from '../components/PageDuplicateModal';
-import PageRenameModal from '../components/PageRenameModal';
 import PagePresentationModal from '../components/PagePresentationModal';
 import PagePresentationModal from '../components/PagePresentationModal';
-import PageAccessoriesModal from '../components/PageAccessoriesModal';
-import PutbackPageModal from '~/components/PutbackPageModal';
-
-import AppContainer from '~/client/services/AppContainer';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { DescendantsPageListModal } from '~/components/DescendantsPageListModal';
+import PageRenameModal from '../components/PageRenameModal';
 
 
 const logger = loggerFactory('growi:cli:app');
 const logger = loggerFactory('growi:cli:app');
 
 
@@ -48,6 +48,7 @@ const componentMappings = {
 
 
   'page-create-modal': <PageCreateModal />,
   'page-create-modal': <PageCreateModal />,
   'page-delete-modal': <PageDeleteModal />,
   'page-delete-modal': <PageDeleteModal />,
+  'empty-trash-modal': <EmptyTrashModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-duplicate-modal': <PageDuplicateModal />,
   'page-rename-modal': <PageRenameModal />,
   'page-rename-modal': <PageRenameModal />,
   'page-presentation-modal': <PagePresentationModal />,
   'page-presentation-modal': <PagePresentationModal />,

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

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

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

@@ -3,7 +3,6 @@ import { Container } from 'unstated';
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
 
 
 import GrowiRenderer from '../util/GrowiRenderer';
 import GrowiRenderer from '../util/GrowiRenderer';
-import emojiStrategy from '../util/emojione/emoji_strategy_shrinked.json';
 import { i18nFactory } from '../util/i18n';
 import { i18nFactory } from '../util/i18n';
 
 
 /**
 /**
@@ -187,9 +186,6 @@ export default class AppContainer extends Container {
     return renderer;
     return renderer;
   }
   }
 
 
-  getEmojiStrategy() {
-    return emojiStrategy;
-  }
 
 
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
   launchHandsontableModal(componentKind, beginLineNumber, endLineNumber) {
     let targetComponent;
     let targetComponent;

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

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

+ 23 - 2
packages/app/src/client/services/PageContainer.js

@@ -17,6 +17,7 @@ import {
 import {
 import {
   DrawioInterceptor,
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
 } from '../util/interceptor/drawio-interceptor';
+import { emojiMartData } from '../util/markdown-it/emoji-mart-data';
 
 
 const { isTrashPage } = pagePathUtils;
 const { isTrashPage } = pagePathUtils;
 
 
@@ -194,12 +195,32 @@ export default class PageContainer extends Container {
     this.setState(newState);
     this.setState(newState);
   }
   }
 
 
-  setTocHtml(tocHtml) {
+  async setTocHtml(tocHtml) {
     if (this.state.tocHtml !== tocHtml) {
     if (this.state.tocHtml !== tocHtml) {
-      this.setState({ tocHtml });
+      const tocHtmlWithEmoji = await this.colonsToEmoji(tocHtml);
+      this.setState({ tocHtml: tocHtmlWithEmoji });
     }
     }
   }
   }
 
 
+  /**
+   *
+   * @param {*} html TOC html string
+   * @returns TOC html with emoji (emoji-mart) in URL
+   */
+  async colonsToEmoji(html) {
+    // Emoji colons matching
+    const colons = ':[a-zA-Z0-9-_+]+:';
+    // Emoji with skin tone matching
+    const skin = ':skin-tone-[2-6]:';
+    const colonsRegex = new RegExp(`(${colons}${skin}|${colons})`, 'g');
+    const emojiData = await emojiMartData();
+    return html.replace(colonsRegex, (index, match) => {
+      const emojiName = match.slice(1, -1);
+      return emojiData[emojiName];
+    });
+
+  }
+
   /**
   /**
    * save success handler
    * save success handler
    * @param {object} page Page instance
    * @param {object} page Page instance

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

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

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
packages/app/src/client/util/emojione/emoji_strategy_shrinked.json


+ 66 - 0
packages/app/src/client/util/markdown-it/emoji-mart-data.ts

@@ -0,0 +1,66 @@
+import { Emoji } from 'emoji-mart';
+import data from 'emoji-mart/data/apple.json';
+
+const DEFAULT_EMOJI_SIZE = 24;
+
+/**
+ *
+ * Get native emoji with skin tone
+ * @param emoji Emoji object
+ * @param skin number
+ * @returns emoji data with skin tone
+ */
+const getEmojiSkinTone = async(emoji) => {
+  const emojiData = {};
+  [...Array(6).keys()].forEach((index) => {
+    if (index > 0) {
+      const elem = Emoji({
+        emoji,
+        skin: index + 1,
+        size: DEFAULT_EMOJI_SIZE,
+      });
+      if (elem) {
+        emojiData[`${emoji}::skin-tone-${index + 1}`] = elem.props['aria-label'].split(',')[0];
+      }
+    }
+  });
+  return emojiData;
+};
+
+/**
+ * Get native emoji from emoji array
+ * @param emojis array of emoji
+ * @returns emoji data
+ */
+
+const getNativeEmoji = async(emojis) => {
+  const emojiData = {};
+  emojis.forEach(async(emoji) => {
+    const emojiName = emoji[0];
+    const hasSkinVariation = emoji[1].skin_variations;
+    const elem = Emoji({
+      emoji: emojiName,
+      size: DEFAULT_EMOJI_SIZE,
+    });
+    if (elem != null) {
+      emojiData[emojiName] = elem.props['aria-label'].split(',')[0];
+      if (hasSkinVariation) {
+        const emojiWithSkinTone = await getEmojiSkinTone(emojiName);
+        Object.assign(emojiData, emojiWithSkinTone);
+      }
+    }
+  });
+  return emojiData;
+};
+
+/**
+ * Get native emoji mart data
+ * @returns native emoji mart data
+ */
+
+export const emojiMartData = () => {
+  const emojis = Object.entries(data.emojis).map((emoji) => {
+    return emoji;
+  });
+  return getNativeEmoji(emojis);
+};

+ 5 - 19
packages/app/src/client/util/markdown-it/emoji.js

@@ -1,3 +1,5 @@
+import { emojiMartData } from './emoji-mart-data';
+
 export default class EmojiConfigurer {
 export default class EmojiConfigurer {
 
 
   constructor(crowi) {
   constructor(crowi) {
@@ -5,25 +7,9 @@ export default class EmojiConfigurer {
   }
   }
 
 
   configure(md) {
   configure(md) {
-    const emojiStrategy = this.crowi.getEmojiStrategy();
-
-    const emojiShortnameUnicodeMap = {};
-
-    /* eslint-disable guard-for-in, no-restricted-syntax */
-    for (const unicode in emojiStrategy) {
-      const data = emojiStrategy[unicode];
-      const shortname = data.shortname.replace(/:/g, '');
-      emojiShortnameUnicodeMap[shortname] = String.fromCharCode(unicode);
-    }
-    /* eslint-enable guard-for-in, no-restricted-syntax */
-
-    md.use(require('markdown-it-emoji'), { defs: emojiShortnameUnicodeMap });
-
-    // integrate markdown-it-emoji and emojione
-    md.renderer.rules.emoji = (token, idx) => {
-      const shortname = `:${token[idx].markup}:`;
-      return emojione.shortnameToImage(shortname);
-    };
+    emojiMartData().then((data) => {
+      md.use(require('markdown-it-emoji-mart'), { defs: data });
+    });
   }
   }
 
 
 }
 }

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

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

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

@@ -171,7 +171,7 @@ class UserInviteModal extends React.Component {
     return (
     return (
       <ul>
       <ul>
         {userList.map((user) => {
         {userList.map((user) => {
-          const copyText = `Email:${user.email} Password:${user.password} `;
+          const copyText = `Email:${user.email} Password:${user.password}`;
           return (
           return (
             <div className="my-1" key={user.email}>
             <div className="my-1" key={user.email}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>
               <CopyToClipboard text={copyText} onCopy={this.showToaster}>

+ 5 - 2
packages/app/src/components/CustomNavigation/CustomNav.jsx

@@ -1,6 +1,7 @@
 import React, {
 import React, {
   useEffect, useState, useRef, useMemo, useCallback,
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
 } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import {
 import {
   Nav, NavItem, NavLink,
   Nav, NavItem, NavLink,
@@ -87,7 +88,7 @@ export const CustomNavTab = (props) => {
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
 
   const {
   const {
-    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown,
+    activeTab, navTabMapping, onNavSelected, hideBorderBottom, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   } = props;
 
 
   const navTabRefs = useMemo(() => {
   const navTabRefs = useMemo(() => {
@@ -149,7 +150,7 @@ export const CustomNavTab = (props) => {
 
 
   return (
   return (
     <div className="grw-custom-nav-tab">
     <div className="grw-custom-nav-tab">
-      <div ref={navContainer}>
+      <div ref={navContainer} className="d-flex justify-content-between">
         <Nav className="nav-title">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
           {Object.entries(navTabMapping).map(([key, value]) => {
 
 
@@ -169,6 +170,7 @@ export const CustomNavTab = (props) => {
             );
             );
           })}
           })}
         </Nav>
         </Nav>
+        {navRightElement}
       </div>
       </div>
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
       <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
       { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
       { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
@@ -183,6 +185,7 @@ CustomNavTab.propTypes = {
   onNavSelected: PropTypes.func,
   onNavSelected: PropTypes.func,
   hideBorderBottom: PropTypes.bool,
   hideBorderBottom: PropTypes.bool,
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 };
 
 
 CustomNavTab.defaultProps = {
 CustomNavTab.defaultProps = {

+ 4 - 1
packages/app/src/components/CustomNavigation/CustomNavAndContents.jsx

@@ -1,4 +1,5 @@
 import React, { useState } from 'react';
 import React, { useState } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
 import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
@@ -7,7 +8,7 @@ import CustomTabContent from './CustomTabContent';
 
 
 const CustomNavAndContents = (props) => {
 const CustomNavAndContents = (props) => {
   const {
   const {
-    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown,
+    navTabMapping, defaultTabIndex, navigationMode, tabContentClasses, breakpointToHideInactiveTabsDown, navRightElement,
   } = props;
   } = props;
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
   const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
 
 
@@ -31,6 +32,7 @@ const CustomNavAndContents = (props) => {
         navTabMapping={navTabMapping}
         navTabMapping={navTabMapping}
         onNavSelected={setActiveTab}
         onNavSelected={setActiveTab}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
+        navRightElement={navRightElement}
       />
       />
       <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
       <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
     </>
     </>
@@ -43,6 +45,7 @@ CustomNavAndContents.propTypes = {
   navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
   navigationMode: PropTypes.oneOf(['both', 'tab', 'dropdown']),
   tabContentClasses: PropTypes.arrayOf(PropTypes.string),
   tabContentClasses: PropTypes.arrayOf(PropTypes.string),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
   breakpointToHideInactiveTabsDown: PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl']),
+  navRightElement: PropTypes.node,
 };
 };
 CustomNavAndContents.defaultProps = {
 CustomNavAndContents.defaultProps = {
   navigationMode: 'tab',
   navigationMode: 'tab',

+ 61 - 0
packages/app/src/components/EmptyTrashButton.tsx

@@ -0,0 +1,61 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-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';
+
+
+const EmptyTrashButton = () => {
+  const { t } = useTranslation();
+  const { open: openEmptyTrashModal } = useEmptyTrashModal();
+  const { data: pagingResult, mutate } = useSWRxDescendantsPageListForCurrrentPath();
+
+  const pageIds = pagingResult?.items?.map(page => page._id);
+  const { injectTo } = useSWRxPageInfoForList(pageIds, 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 = () => {
+    if (deletablePages.length === 0) { return }
+    openEmptyTrashModal(deletablePages, { onEmptiedTrash: onEmptiedTrashHandler, canDelepeAllPages: pagingResult?.totalCount === deletablePages.length });
+  };
+
+  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"
+        onClick={() => emptyTrashClickHandler()}
+      >
+        <i className="icon-fw icon-trash"></i>
+        <div>{t('modal_empty.empty_the_trash')}</div>
+      </button>
+    </div>
+  );
+};
+
+export default EmptyTrashButton;

+ 0 - 72
packages/app/src/components/EmptyTrashModal.jsx

@@ -1,72 +0,0 @@
-import React, { useState } from 'react';
-
-import PropTypes from 'prop-types';
-import { withTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import SocketIoContainer from '~/client/services/SocketIoContainer';
-import { apiv3Delete } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-const EmptyTrashModal = (props) => {
-  const {
-    t, isOpen, onClose, appContainer, socketIoContainer,
-  } = props;
-
-  const [errs, setErrs] = useState(null);
-
-  async function emptyTrash() {
-    setErrs(null);
-
-    try {
-      await apiv3Delete('/pages/empty-trash');
-      window.location.reload();
-    }
-    catch (err) {
-      setErrs(err);
-    }
-  }
-
-  function emptyButtonHandler() {
-    emptyTrash();
-  }
-
-  return (
-    <Modal isOpen={isOpen} toggle={onClose} className="grw-create-page">
-      <ModalHeader tag="h4" toggle={onClose} className="bg-danger text-light">
-        { t('modal_empty.empty_the_trash')}
-      </ModalHeader>
-      <ModalBody>
-        { t('modal_empty.notice')}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button type="button" className="btn btn-danger" onClick={emptyButtonHandler}>
-          <i className="icon-trash mr-2" aria-hidden="true"></i> Empty
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
-
-/**
- * Wrapper component for using unstated
- */
-const EmptyTrashModalWrapper = withUnstatedContainers(EmptyTrashModal, [AppContainer, SocketIoContainer]);
-
-
-EmptyTrashModal.propTypes = {
-  t: PropTypes.func.isRequired, //  i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  socketIoContainer: PropTypes.instanceOf(SocketIoContainer),
-
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func.isRequired,
-};
-
-export default withTranslation()(EmptyTrashModalWrapper);

+ 92 - 0
packages/app/src/components/EmptyTrashModal.tsx

@@ -0,0 +1,92 @@
+import React, {
+  useState, FC,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { apiv3Delete } from '~/client/util/apiv3-client';
+import { useEmptyTrashModal } from '~/stores/modal';
+
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
+const EmptyTrashModal: FC = () => {
+  const { t } = useTranslation();
+
+  const { data: emptyTrashModalData, close: closeEmptyTrashModal } = useEmptyTrashModal();
+
+  const isOpened = emptyTrashModalData?.isOpened ?? false;
+
+  const canDeleteAllpages = emptyTrashModalData?.opts?.canDelepeAllPages ?? false;
+
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  async function emptyTrash() {
+    if (emptyTrashModalData == null || emptyTrashModalData.pages == null) {
+      return;
+    }
+
+    try {
+      await apiv3Delete('/pages/empty-trash');
+      const onEmptiedTrash = emptyTrashModalData.opts?.onEmptiedTrash;
+      if (onEmptiedTrash != null) {
+        onEmptiedTrash();
+      }
+      closeEmptyTrashModal();
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }
+
+  async function emptyTrashButtonHandler() {
+    await emptyTrash();
+  }
+
+  const renderPagePaths = () => {
+    const pages = emptyTrashModalData?.pages;
+
+    if (pages != null) {
+      return pages.map(page => (
+        <p key={page.data._id} className="mb-1">
+          <code>{ page.data.path }</code>
+        </p>
+      ));
+    }
+    return <></>;
+  };
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
+        <i className="icon-fw icon-fire"></i>
+        {t('modal_empty.empty_the_trash')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="form-group grw-scrollable-modal-body pb-1">
+          <label>{ t('modal_delete.deleting_page') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {renderPagePaths()}
+        </div>
+        {!canDeleteAllpages && t('modal_empty.not_deletable_notice')}<br />
+        {t('modal_empty.notice')}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={emptyTrashButtonHandler}
+        >
+          <i className="mr-1 icon-fire" aria-hidden="true"></i>
+          {t('modal_empty.empty_the_trash_button')}
+        </button>
+      </ModalFooter>
+    </Modal>
+
+  );
+};
+
+export default EmptyTrashModal;

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

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

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

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

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

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

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

@@ -5,7 +5,7 @@ import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiGet } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-import { ITagsSearchApiv1Result } from '~/interfaces/tag';
+import { IResTagsSearchApiv1 } from '~/interfaces/tag';
 
 
 type TypeaheadInstance = {
 type TypeaheadInstance = {
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
   _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
@@ -36,7 +36,7 @@ const TagsInput: FC<Props> = (props: Props) => {
     setLoading(true);
     setLoading(true);
     try {
     try {
       // TODO: 91698 SWRize
       // TODO: 91698 SWRize
-      const res = await apiGet('/tags.search', { q: query }) as ITagsSearchApiv1Result;
+      const res = await apiGet('/tags.search', { q: query }) as IResTagsSearchApiv1;
       res.tags.unshift(query);
       res.tags.unshift(query);
       setResultTags(Array.from(new Set(res.tags)));
       setResultTags(Array.from(new Set(res.tags)));
     }
     }

+ 0 - 2
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -278,7 +278,6 @@ class CommentEditor extends React.Component {
     const { activeTab } = this.state;
     const { activeTab } = this.state;
 
 
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
     const commentPreview = this.state.isMarkdown ? this.getCommentHtml() : null;
-    const emojiStrategy = appContainer.getEmojiStrategy();
 
 
     const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
     const errorMessage = <span className="text-danger text-right mr-2">{this.state.errorMessage}</span>;
     const cancelButton = (
     const cancelButton = (
@@ -312,7 +311,6 @@ class CommentEditor extends React.Component {
                 isMobile={isMobile}
                 isMobile={isMobile}
                 isUploadable={this.state.isUploadable}
                 isUploadable={this.state.isUploadable}
                 isUploadableFile={this.state.isUploadableFile}
                 isUploadableFile={this.state.isUploadableFile}
-                emojiStrategy={emojiStrategy}
                 onChange={this.updateState}
                 onChange={this.updateState}
                 onUpload={this.uploadHandler}
                 onUpload={this.uploadHandler}
                 onCtrlEnter={this.ctrlEnterHandler}
                 onCtrlEnter={this.ctrlEnterHandler}

+ 8 - 22
packages/app/src/components/PageCreateModal.jsx

@@ -1,24 +1,22 @@
 import React, {
 import React, {
   useEffect, useState, useMemo, useCallback,
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
-import PropTypes from 'prop-types';
 
 
+import { pagePathUtils, pathUtils } from '@growi/core';
+import { format } from 'date-fns';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { withTranslation } from 'react-i18next';
-import { format } from 'date-fns';
-
-import { pagePathUtils, pathUtils } from '@growi/core';
-
 
 
 import AppContainer from '~/client/services/AppContainer';
 import AppContainer from '~/client/services/AppContainer';
-import { withUnstatedContainers } from './UnstatedUtils';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
+import { withUnstatedContainers } from './UnstatedUtils';
+
 
 
 const {
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
@@ -83,14 +81,6 @@ const PageCreateModal = (props) => {
     setTodayInput2(value);
     setTodayInput2(value);
   }
   }
 
 
-  /**
-   * change pageNameInput
-   * @param {string} value
-   */
-  function onChangePageNameInputHandler(value) {
-    setPageNameInput(value);
-  }
-
   /**
   /**
    * change template
    * change template
    * @param {string} value
    * @param {string} value
@@ -131,10 +121,6 @@ const PageCreateModal = (props) => {
     redirectToEditor(pageNameInput);
     redirectToEditor(pageNameInput);
   }
   }
 
 
-  function ppacInputChangeHandler(value) {
-    setPageNameInput(value);
-  }
-
   function ppacSubmitHandler(input) {
   function ppacSubmitHandler(input) {
     redirectToEditor(input);
     redirectToEditor(input);
   }
   }
@@ -212,7 +198,7 @@ const PageCreateModal = (props) => {
                     initializedPath={pageNameInput}
                     initializedPath={pageNameInput}
                     addTrailingSlash
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onSubmit={ppacSubmitHandler}
-                    onInputChange={ppacInputChangeHandler}
+                    onInputChange={value => setPageNameInput(value)}
                     autoFocus
                     autoFocus
                   />
                   />
                 )
                 )
@@ -223,7 +209,7 @@ const PageCreateModal = (props) => {
                       value={pageNameInput}
                       value={pageNameInput}
                       className="form-control flex-fill"
                       className="form-control flex-fill"
                       placeholder={t('Input page name')}
                       placeholder={t('Input page name')}
-                      onChange={e => onChangePageNameInputHandler(e.target.value)}
+                      onChange={e => setPageNameInput(e.target.value)}
                       required
                       required
                     />
                     />
                   </form>
                   </form>

+ 12 - 10
packages/app/src/components/PageDeleteModal.tsx

@@ -1,22 +1,26 @@
-import React, { useState, FC, useMemo } from 'react';
+import React, {
+  useState, FC, useMemo,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-import { useTranslation } from 'react-i18next';
 
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { usePageDeleteModal } from '~/stores/modal';
-import loggerFactory from '~/utils/logger';
-
+import { HasObjectId } from '~/interfaces/has-object-id';
 import {
 import {
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
   IDeleteSinglePageApiv1Result, IDeleteManyPageApiv3Result, IPageToDeleteWithMeta, IDataWithMeta, isIPageInfoForEntity, IPageInfoForEntity,
 } from '~/interfaces/page';
 } from '~/interfaces/page';
-import { HasObjectId } from '~/interfaces/has-object-id';
+import { usePageDeleteModal } from '~/stores/modal';
+import { useSWRxPageInfoForList } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
 
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
 import { isTrashPage } from '^/../core/src/utils/page-path-utils';
-import { useSWRxPageInfoForList } from '~/stores/page';
 
 
 
 
 const logger = loggerFactory('growi:cli:PageDeleteModal');
 const logger = loggerFactory('growi:cli:PageDeleteModal');
@@ -121,7 +125,6 @@ const PageDeleteModal: FC = () => {
         if (onDeleted != null) {
         if (onDeleted != null) {
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
           onDeleted(data.paths, data.isRecursively, data.isCompletely);
         }
         }
-
         closeDeleteModal();
         closeDeleteModal();
       }
       }
       catch (err) {
       catch (err) {
@@ -231,7 +234,6 @@ const PageDeleteModal: FC = () => {
         <div className="form-group grw-scrollable-modal-body pb-1">
         <div className="form-group grw-scrollable-modal-body pb-1">
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           <label>{ t('modal_delete.deleting_page') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
-          {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPagePathsToDelete()}
           {renderPagePathsToDelete()}
         </div>
         </div>
         { isDeletable && renderDeleteRecursivelyForm()}
         { isDeletable && renderDeleteRecursivelyForm()}
@@ -245,7 +247,7 @@ const PageDeleteModal: FC = () => {
           disabled={!isDeletable}
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
           onClick={deleteButtonHandler}
         >
         >
-          <i className={`icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
+          <i className={`mr-1 icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>

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

@@ -2,22 +2,20 @@ import React, {
   useState, useEffect, useCallback, useMemo,
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 } from 'react';
 
 
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
-
-import { usePageDuplicateModal } from '~/stores/modal';
+import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
 import { useIsSearchServiceReachable, useSiteUrl } from '~/stores/context';
+import { usePageDuplicateModal } from '~/stores/modal';
 
 
-import PagePathAutoComplete from './PagePathAutoComplete';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import DuplicatePathsTable from './DuplicatedPathsTable';
 import DuplicatePathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
 
 
 
 
 const PageDuplicateModal = (): JSX.Element => {
 const PageDuplicateModal = (): JSX.Element => {

+ 26 - 33
packages/app/src/components/PageEditor.jsx

@@ -329,40 +329,33 @@ class PageEditor extends React.Component {
 
 
     const config = this.props.appContainer.getConfig();
     const config = this.props.appContainer.getConfig();
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
     const noCdn = envUtils.toBoolean(config.env.NO_CDN);
-    const emojiStrategy = this.props.appContainer.getEmojiStrategy();
-
-    const { path } = this.props.pageContainer.state;
 
 
     return (
     return (
-      <>
-        <div className="d-flex flex-wrap">
-          <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
-            <Editor
-              ref={(c) => { this.editor = c }}
-              value={this.state.markdown}
-              noCdn={noCdn}
-              isMobile={this.props.isMobile}
-              isUploadable={this.state.isUploadable}
-              isUploadableFile={this.state.isUploadableFile}
-              emojiStrategy={emojiStrategy}
-              onScroll={this.onEditorScroll}
-              onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
-              onChange={this.onMarkdownChanged}
-              onUpload={this.onUpload}
-              onSave={this.onSaveWithShortcut}
-            />
-          </div>
-          <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
-            <Preview
-              markdown={this.state.markdown}
-              pagePath={path}
-              // eslint-disable-next-line no-return-assign
-              inputRef={(el) => { return this.previewElement = el }}
-              isMathJaxEnabled={this.state.isMathJaxEnabled}
-              renderMathJaxOnInit={false}
-              onScroll={this.onPreviewScroll}
-            />
-          </div>
+      <div className="d-flex flex-wrap">
+        <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
+          <Editor
+            ref={(c) => { this.editor = c }}
+            value={this.state.markdown}
+            noCdn={noCdn}
+            isMobile={this.props.isMobile}
+            isUploadable={this.state.isUploadable}
+            isUploadableFile={this.state.isUploadableFile}
+            onScroll={this.onEditorScroll}
+            onScrollCursorIntoView={this.onEditorScrollCursorIntoView}
+            onChange={this.onMarkdownChanged}
+            onUpload={this.onUpload}
+            onSave={this.onSaveWithShortcut}
+          />
+        </div>
+        <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
+          <Preview
+            markdown={this.state.markdown}
+            // eslint-disable-next-line no-return-assign
+            inputRef={(el) => { return this.previewElement = el }}
+            isMathJaxEnabled={this.state.isMathJaxEnabled}
+            renderMathJaxOnInit={false}
+            onScroll={this.onPreviewScroll}
+          />
         </div>
         </div>
         <ConflictDiffModal
         <ConflictDiffModal
           isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
           isOpen={this.props.pageContainer.state.isConflictDiffModalOpen}
@@ -371,7 +364,7 @@ class PageEditor extends React.Component {
           pageContainer={this.props.pageContainer}
           pageContainer={this.props.pageContainer}
           markdownOnEdit={this.state.markdown}
           markdownOnEdit={this.state.markdown}
         />
         />
-      </>
+      </div>
     );
     );
   }
   }
 
 

+ 85 - 32
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -1,36 +1,36 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
-import urljoin from 'url-join';
+import { createValidator } from '@growi/codemirror-textlint';
 import * as codemirror from 'codemirror';
 import * as codemirror from 'codemirror';
-import { Button } from 'reactstrap';
-
 import { JSHINT } from 'jshint';
 import { JSHINT } from 'jshint';
-
-import * as loadScript from 'simple-load-script';
 import * as loadCssSync from 'load-css-file';
 import * as loadCssSync from 'load-css-file';
+import PropTypes from 'prop-types';
+import { Button } from 'reactstrap';
+import * as loadScript from 'simple-load-script';
+import urljoin from 'url-join';
 
 
-import { createValidator } from '@growi/codemirror-textlint';
-import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import InterceptorManager from '~/services/interceptor-manager';
 import InterceptorManager from '~/services/interceptor-manager';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import AbstractEditor from './AbstractEditor';
-import SimpleCheatsheet from './SimpleCheatsheet';
+import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
-import pasteHelper from './PasteHelper';
-import EmojiAutoCompleteHelper from './EmojiAutoCompleteHelper';
-import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
-import MarkdownTableInterceptor from './MarkdownTableInterceptor';
-import mlu from './MarkdownLinkUtil';
-import mtu from './MarkdownTableUtil';
-import mdu from './MarkdownDrawioUtil';
-import geu from './GridEditorUtil';
+import AbstractEditor from './AbstractEditor';
+import DrawioModal from './DrawioModal';
+import EditorIcon from './EditorIcon';
+import EmojiPicker from './EmojiPicker';
+import EmojiPickerHelper from './EmojiPickerHelper';
 import GridEditModal from './GridEditModal';
 import GridEditModal from './GridEditModal';
-import LinkEditModal from './LinkEditModal';
+import geu from './GridEditorUtil';
 import HandsontableModal from './HandsontableModal';
 import HandsontableModal from './HandsontableModal';
-import EditorIcon from './EditorIcon';
-import DrawioModal from './DrawioModal';
+import LinkEditModal from './LinkEditModal';
+import mdu from './MarkdownDrawioUtil';
+import mlu from './MarkdownLinkUtil';
+import MarkdownTableInterceptor from './MarkdownTableInterceptor';
+import mtu from './MarkdownTableUtil';
+import pasteHelper from './PasteHelper';
+import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
+import SimpleCheatsheet from './SimpleCheatsheet';
+
 
 
 // Textlint
 // Textlint
 window.JSHINT = JSHINT;
 window.JSHINT = JSHINT;
@@ -109,11 +109,12 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.state = {
     this.state = {
       value: this.props.value,
       value: this.props.value,
       isGfmMode: this.props.isGfmMode,
       isGfmMode: this.props.isGfmMode,
-      isEnabledEmojiAutoComplete: false,
       isLoadingKeymap: false,
       isLoadingKeymap: false,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
       isSimpleCheatsheetShown: this.props.isGfmMode && this.props.value.length === 0,
       isCheatsheetModalShown: false,
       isCheatsheetModalShown: false,
       additionalClassSet: new Set(),
       additionalClassSet: new Set(),
+      isEmojiPickerShown: false,
+      emojiSearchText: null,
     };
     };
 
 
     this.gridEditModal = React.createRef();
     this.gridEditModal = React.createRef();
@@ -138,6 +139,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.pasteHandler = this.pasteHandler.bind(this);
     this.pasteHandler = this.pasteHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.cursorHandler = this.cursorHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
     this.changeHandler = this.changeHandler.bind(this);
+    this.keyUpHandler = this.keyUpHandler.bind(this);
 
 
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
     this.updateCheatsheetStates = this.updateCheatsheetStates.bind(this);
 
 
@@ -152,6 +154,8 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.foldDrawioSection = this.foldDrawioSection.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
     this.onSaveForDrawio = this.onSaveForDrawio.bind(this);
+    this.checkWhetherEmojiPickerShouldBeShown = this.checkWhetherEmojiPickerShouldBeShown.bind(this);
+
   }
   }
 
 
   init() {
   init() {
@@ -169,11 +173,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   componentWillMount() {
   componentWillMount() {
-    if (this.props.emojiStrategy != null) {
-      this.emojiAutoCompleteHelper = new EmojiAutoCompleteHelper(this.props.emojiStrategy);
-      this.setState({ isEnabledEmojiAutoComplete: true });
-    }
-
     this.initializeTextlint();
     this.initializeTextlint();
   }
   }
 
 
@@ -191,6 +190,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     // fold drawio section
     // fold drawio section
     this.foldDrawioSection();
     this.foldDrawioSection();
+    this.emojiPickerHelper = new EmojiPickerHelper(this.getCodeMirror());
   }
   }
 
 
   componentWillReceiveProps(nextProps) {
   componentWillReceiveProps(nextProps) {
@@ -251,7 +251,6 @@ export default class CodeMirrorEditor extends AbstractEditor {
     // update state
     // update state
     this.setState({
     this.setState({
       isGfmMode: bool,
       isGfmMode: bool,
-      isEnabledEmojiAutoComplete: bool,
     });
     });
 
 
     this.updateCheatsheetStates(bool, null);
     this.updateCheatsheetStates(bool, null);
@@ -568,9 +567,11 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
     this.updateCheatsheetStates(null, value);
     this.updateCheatsheetStates(null, value);
 
 
-    // Emoji AutoComplete
-    if (this.state.isEnabledEmojiAutoComplete) {
-      this.emojiAutoCompleteHelper.showHint(editor);
+  }
+
+  keyUpHandler(editor, event) {
+    if (event.key !== 'Backspace') {
+      this.checkWhetherEmojiPickerShouldBeShown();
     }
     }
   }
   }
 
 
@@ -595,6 +596,26 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
 
   }
   }
 
 
+  /**
+   * Show emoji picker component when emoji pattern (`:` + searchWord ) found
+   * eg `:a`, `:ap`
+   */
+  checkWhetherEmojiPickerShouldBeShown() {
+    const searchWord = this.emojiPickerHelper.getEmoji();
+
+    if (searchWord == null) {
+      this.setState({ isEmojiPickerShown: false });
+      this.setState({ emojiSearchText: null });
+    }
+    else {
+      this.setState({ emojiSearchText: searchWord });
+      // Show emoji picker after user stop typing
+      setTimeout(() => {
+        this.setState({ isEmojiPickerShown: true });
+      }, 700);
+    }
+  }
+
   /**
   /**
    * update states which related to cheatsheet
    * update states which related to cheatsheet
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
    * @param {boolean} isGfmModeTmp (use state.isGfmMode if null is set)
@@ -667,6 +688,24 @@ export default class CodeMirrorEditor extends AbstractEditor {
     );
     );
   }
   }
 
 
+  renderEmojiPicker() {
+    const { emojiSearchText } = this.state;
+    return this.state.isEmojiPickerShown
+      ? (
+        <div className="text-left">
+          <div className="mb-2 d-none d-md-block">
+            <EmojiPicker
+              onClose={() => this.setState({ isEmojiPickerShown: false, emojiSearchText: null })}
+              emojiSearchText={emojiSearchText}
+              emojiPickerHelper={this.emojiPickerHelper}
+              isOpen={this.state.isEmojiPickerShown}
+            />
+          </div>
+        </div>
+      )
+      : '';
+  }
+
   /**
   /**
    * return a function to replace a selected range with prefix + selection + suffix
    * return a function to replace a selected range with prefix + selection + suffix
    *
    *
@@ -750,6 +789,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
     this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
   }
 
 
+
   // fold draw.io section (::: drawio ~ :::)
   // fold draw.io section (::: drawio ~ :::)
   foldDrawioSection() {
   foldDrawioSection() {
     const editor = this.getCodeMirror();
     const editor = this.getCodeMirror();
@@ -766,6 +806,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     return range;
     return range;
   }
   }
 
 
+
   getNavbarItems() {
   getNavbarItems() {
     return [
     return [
       <Button
       <Button
@@ -903,9 +944,19 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
       >
         <EditorIcon icon="Drawio" />
         <EditorIcon icon="Drawio" />
       </Button>,
       </Button>,
+      <Button
+        key="nav-item-emoji"
+        color={null}
+        bssize="small"
+        title="Emoji"
+        onClick={() => this.setState({ isEmojiPickerShown: true })}
+      >
+        <EditorIcon icon="Emoji" />
+      </Button>,
     ];
     ];
   }
   }
 
 
+
   render() {
   render() {
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const lint = this.props.isTextlintEnabled ? this.codemirrorLintConfig : false;
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
     const additionalClasses = Array.from(this.state.additionalClassSet).join(' ');
@@ -940,6 +991,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
             autoCloseTags: true,
             autoCloseTags: true,
             placeholder,
             placeholder,
             matchBrackets: true,
             matchBrackets: true,
+            emoji: true,
             matchTags: { bothTags: true },
             matchTags: { bothTags: true },
             // folding
             // folding
             foldGutter: this.props.lineNumbers,
             foldGutter: this.props.lineNumbers,
@@ -972,11 +1024,13 @@ export default class CodeMirrorEditor extends AbstractEditor {
               this.props.onDragEnter(event);
               this.props.onDragEnter(event);
             }
             }
           }}
           }}
+          onKeyUp={this.keyUpHandler}
         />
         />
 
 
         { this.renderLoadingKeymapOverlay() }
         { this.renderLoadingKeymapOverlay() }
 
 
         { this.renderCheatsheetOverlay() }
         { this.renderCheatsheetOverlay() }
+        { this.renderEmojiPicker() }
 
 
         <GridEditModal
         <GridEditModal
           ref={this.gridEditModal}
           ref={this.gridEditModal}
@@ -1006,7 +1060,6 @@ CodeMirrorEditor.propTypes = Object.assign({
   editorOptions: PropTypes.object.isRequired,
   editorOptions: PropTypes.object.isRequired,
   isTextlintEnabled: PropTypes.bool,
   isTextlintEnabled: PropTypes.bool,
   textlintRules: PropTypes.array,
   textlintRules: PropTypes.array,
-  emojiStrategy: PropTypes.object,
   lineNumbers: PropTypes.bool,
   lineNumbers: PropTypes.bool,
   onMarkdownHelpButtonClicked: PropTypes.func,
   onMarkdownHelpButtonClicked: PropTypes.func,
   onAddAttachmentButtonClicked: PropTypes.func,
   onAddAttachmentButtonClicked: PropTypes.func,

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

@@ -384,7 +384,6 @@ Editor.propTypes = Object.assign({
   isMobile: PropTypes.bool,
   isMobile: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadable: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
   isUploadableFile: PropTypes.bool,
-  emojiStrategy: PropTypes.object,
   onChange: PropTypes.func,
   onChange: PropTypes.func,
   onUpload: PropTypes.func,
   onUpload: PropTypes.func,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,

+ 14 - 0
packages/app/src/components/PageEditor/EditorIcon.jsx

@@ -125,6 +125,20 @@ const EditorIcon = (props) => {
           <path d="M9.71,22.5a2.57,2.57,0,0,1-1.85-.79,2.79,2.79,0,0,1,0-4l9-9.23a3.21,3.21,0,0,1,1.59-.87,3.39,3.39,0,0,1,1.81.1,4.38,4.38,0,0,1,1.7,1.05,4.15,4.15,0,0,1,.46.56,3.73,3.73,0,0,1,.35.65,4.25,4.25,0,0,1,.2.72,3.91,3.91,0,0,1,.07.76,3.71,3.71,0,0,1-1.12,2.67l-6.79,7a.48.48,0,0,1-.34.16.51.51,0,0,1-.35-.13.48.48,0,0,1,0-.7l6.78-7a2.8,2.8,0,0,0,.84-2,2.58,2.58,0,0,0-.79-2,3.63,3.63,0,0,0-1.11-.75,2.41,2.41,0,0,0-1.31-.17,2.19,2.19,0,0,0-1.25.62l-9,9.22A1.8,1.8,0,0,0,8,19.69,1.78,1.78,0,0,0,8.58,21a1.81,1.81,0,0,0,.57.39,1.48,1.48,0,0,0,.66.1,2,2,0,0,0,1.28-.62l7.12-7.35.15-.16a1.15,1.15,0,0,0,.15-.2.9.9,0,0,0,.12-.24,1.17,1.17,0,0,0,.07-.25.52.52,0,0,0-.05-.27.75.75,0,0,0-.19-.26.73.73,0,0,0-.58-.27,1.29,1.29,0,0,0-.67.38l-5.36,5.53a.5.5,0,0,1-.22.13.46.46,0,0,1-.26,0,.48.48,0,0,1-.22-.12A.41.41,0,0,1,11,17.5a.5.5,0,0,1,.14-.35L16.5,11.6a2.19,2.19,0,0,1,1.29-.67,1.69,1.69,0,0,1,1.37.55,1.54,1.54,0,0,1,.53,1.31,2.26,2.26,0,0,1-.76,1.42L11.8,21.58a3.06,3.06,0,0,1-2,.91H9.71Z" />
           <path d="M9.71,22.5a2.57,2.57,0,0,1-1.85-.79,2.79,2.79,0,0,1,0-4l9-9.23a3.21,3.21,0,0,1,1.59-.87,3.39,3.39,0,0,1,1.81.1,4.38,4.38,0,0,1,1.7,1.05,4.15,4.15,0,0,1,.46.56,3.73,3.73,0,0,1,.35.65,4.25,4.25,0,0,1,.2.72,3.91,3.91,0,0,1,.07.76,3.71,3.71,0,0,1-1.12,2.67l-6.79,7a.48.48,0,0,1-.34.16.51.51,0,0,1-.35-.13.48.48,0,0,1,0-.7l6.78-7a2.8,2.8,0,0,0,.84-2,2.58,2.58,0,0,0-.79-2,3.63,3.63,0,0,0-1.11-.75,2.41,2.41,0,0,0-1.31-.17,2.19,2.19,0,0,0-1.25.62l-9,9.22A1.8,1.8,0,0,0,8,19.69,1.78,1.78,0,0,0,8.58,21a1.81,1.81,0,0,0,.57.39,1.48,1.48,0,0,0,.66.1,2,2,0,0,0,1.28-.62l7.12-7.35.15-.16a1.15,1.15,0,0,0,.15-.2.9.9,0,0,0,.12-.24,1.17,1.17,0,0,0,.07-.25.52.52,0,0,0-.05-.27.75.75,0,0,0-.19-.26.73.73,0,0,0-.58-.27,1.29,1.29,0,0,0-.67.38l-5.36,5.53a.5.5,0,0,1-.22.13.46.46,0,0,1-.26,0,.48.48,0,0,1-.22-.12A.41.41,0,0,1,11,17.5a.5.5,0,0,1,.14-.35L16.5,11.6a2.19,2.19,0,0,1,1.29-.67,1.69,1.69,0,0,1,1.37.55,1.54,1.54,0,0,1,.53,1.31,2.26,2.26,0,0,1-.76,1.42L11.8,21.58a3.06,3.06,0,0,1-2,.91H9.71Z" />
         </svg>
         </svg>
       );
       );
+    case 'Emoji':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">
+          <g transform="translate(-435 -392)">
+            <rect width="30" height="30" transform="translate(435 392)" fillOpacity="0" />
+            <path d="M8,1a7,7,0,1,0,7,7A7.008,7.008,0,0,0,8,1M8,0A8,8,0,1,1,0,8,8,8,0,0,1,8,0Z" transform="translate(442 399)" />
+            <circle cx="1" cy="1" r="1" transform="translate(446 403)" />
+            <circle cx="1" cy="1" r="1" transform="translate(452 403)" />
+            <g transform="translate(445 406.5)">
+              <path d="M5,5.5a5.006,5.006,0,0,1-5-5,.5.5,0,1,1,1,0,4,4,0,0,0,8,0,.5.5,0,0,1,1,0A5.006,5.006,0,0,1,5,5.5Z" />
+            </g>
+          </g>
+        </svg>
+      );
   }
   }
 
 
 
 

+ 0 - 149
packages/app/src/components/PageEditor/EmojiAutoCompleteHelper.js

@@ -1,149 +0,0 @@
-import UpdateDisplayUtil from '~/client/util/codemirror/update-display-util.ext';
-
-class EmojiAutoCompleteHelper {
-
-  constructor(emojiStrategy) {
-    this.emojiStrategy = emojiStrategy;
-
-    this.emojiShortnameImageMap = {};
-
-    this.initEmojiImageMap = this.initEmojiImageMap.bind(this);
-    this.showHint = this.showHint.bind(this);
-
-    this.initEmojiImageMap();
-  }
-
-  initEmojiImageMap() {
-    for (const data of Object.values(this.emojiStrategy)) {
-      const shortname = data.shortname;
-      // add image tag
-      this.emojiShortnameImageMap[shortname] = emojione.shortnameToImage(shortname);
-    }
-  }
-
-  /**
-   * try to find emoji terms and show hint
-   * @param {any} editor An editor instance of CodeMirror
-   */
-  showHint(editor) {
-    // see https://regex101.com/r/gy3i03/1
-    const pattern = /:[^:\s]+/;
-
-    const currentPos = editor.getCursor();
-    // find previous ':shortname'
-    const sc = editor.getSearchCursor(pattern, currentPos, { multiline: false });
-    if (sc.findPrevious()) {
-      const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
-      // return if it isn't inputting emoji
-      if (!isInputtingEmoji) {
-        return;
-      }
-    }
-    else {
-      return;
-    }
-
-    /*
-     * https://github.com/weseek/growi/issues/703 is caused
-     * because 'editor.display.viewOffset' is zero
-     *
-     * call stack:
-     *   1. https://github.com/codemirror/CodeMirror/blob/5.42.0/addon/hint/show-hint.js#L220
-     *   2. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/edit/methods.js#L189
-     *   3. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/measurement/position_measurement.js#L372
-     *   4. https://github.com/codemirror/CodeMirror/blob/5.42.0/src/measurement/position_measurement.js#L315
-     */
-    UpdateDisplayUtil.forceUpdateViewOffset(editor);
-
-    // see https://codemirror.net/doc/manual.html#addon_show-hint
-    editor.showHint({
-      completeSingle: false,
-      // closeOnUnfocus: false,  // for debug
-      hint: () => {
-        const matched = editor.getDoc().getRange(sc.from(), sc.to());
-        const term = matched.replace(':', ''); // remove ':' in the head
-
-        // get a list of shortnames
-        const shortnames = this.searchEmojiShortnames(term);
-        if (shortnames.length >= 1) {
-          return {
-            list: this.generateEmojiRenderer(shortnames),
-            from: sc.from(),
-            to: sc.to(),
-          };
-        }
-      },
-    });
-  }
-
-  /**
-   * see https://codemirror.net/doc/manual.html#addon_show-hint
-   * @param {string[]} emojiShortnames a list of shortname
-   */
-  generateEmojiRenderer(emojiShortnames) {
-    return emojiShortnames.map((shortname) => {
-      return {
-        text: shortname,
-        className: 'crowi-emoji-autocomplete',
-        render: (element) => {
-          element.innerHTML = `<div class="img-container">${this.emojiShortnameImageMap[shortname]}</div>`
-            + `<span class="shortname-container">${shortname}</span>`;
-        },
-      };
-    });
-  }
-
-  /**
-   * transplanted from https://github.com/emojione/emojione/blob/master/examples/OTHER.md
-   * @param {string} term
-   * @returns {string[]} a list of shortname
-   */
-  searchEmojiShortnames(term) {
-    const maxLength = 12;
-
-    const results1 = [];
-    const results2 = [];
-    const results3 = [];
-    const results4 = [];
-    const countLen1 = () => { return results1.length };
-    const countLen2 = () => { return countLen1() + results2.length };
-    const countLen3 = () => { return countLen2() + results3.length };
-    const countLen4 = () => { return countLen3() + results4.length };
-
-    // TODO performance tune
-    // when total length of all results is less than `maxLength`
-    for (const data of Object.values(this.emojiStrategy)) {
-      if (maxLength <= countLen1()) { break }
-      // prefix match to shortname
-      else if (data.shortname.indexOf(`:${term}`) > -1) {
-        results1.push(data.shortname);
-        continue;
-      }
-      else if (maxLength <= countLen2()) { continue }
-      // partial match to shortname
-      else if (data.shortname.indexOf(term) > -1) {
-        results2.push(data.shortname);
-        continue;
-      }
-      else if (maxLength <= countLen3()) { continue }
-      // partial match to elements of aliases
-      else if ((data.aliases != null) && data.aliases.find((elem) => { return elem.indexOf(term) > -1 })) {
-        results3.push(data.shortname);
-        continue;
-      }
-      else if (maxLength <= countLen4()) { continue }
-      // partial match to elements of keywords
-      else if ((data.keywords != null) && data.keywords.find((elem) => { return elem.indexOf(term) > -1 })) {
-        results4.push(data.shortname);
-      }
-    }
-
-    let results = results1.concat(results2).concat(results3).concat(results4);
-    results = results.slice(0, maxLength);
-
-    return results;
-  }
-
-}
-
-export default EmojiAutoCompleteHelper;

+ 64 - 0
packages/app/src/components/PageEditor/EmojiPicker.tsx

@@ -0,0 +1,64 @@
+import React, { FC } from 'react';
+
+import { Picker } from 'emoji-mart';
+import { Modal } from 'reactstrap';
+
+import { isDarkMode } from '~/client/util/color-scheme';
+
+import EmojiPickerHelper, { getEmojiTranslation } from './EmojiPickerHelper';
+
+type Props = {
+  onClose: () => void,
+  emojiSearchText: string,
+  emojiPickerHelper: EmojiPickerHelper,
+  isOpen: boolean
+}
+
+const EmojiPicker: FC<Props> = (props: Props) => {
+
+  const {
+    onClose, emojiSearchText, emojiPickerHelper, isOpen,
+  } = props;
+
+  // Set search emoji input and trigger search
+  const searchEmoji = () => {
+    const input = window.document.querySelector('[id^="emoji-mart-search"]') as HTMLInputElement;
+    if (emojiSearchText !== null) {
+
+      const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
+      valueSetter?.call(input, emojiSearchText);
+      const event = new Event('input', { bubbles: true });
+      input.dispatchEvent(event);
+      input.focus();
+    }
+  };
+
+  const selectEmoji = (emoji) => {
+    if (emojiSearchText !== null) {
+      emojiPickerHelper.addEmojiOnSearch(emoji);
+    }
+    else {
+      emojiPickerHelper.addEmoji(emoji);
+    }
+    onClose();
+  };
+
+
+  const translation = getEmojiTranslation();
+  const theme = isDarkMode() ? 'dark' : 'light';
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose} onOpened={searchEmoji} backdropClassName="emoji-picker-modal" fade={false}>
+      <Picker
+        onSelect={selectEmoji}
+        i18n={translation}
+        title={translation.title}
+        emojiTooltip
+        style={emojiPickerHelper.setStyle()}
+        theme={theme}
+      />
+    </Modal>
+  );
+};
+
+export default EmojiPicker;

+ 124 - 0
packages/app/src/components/PageEditor/EmojiPickerHelper.ts

@@ -0,0 +1,124 @@
+import { CSSProperties } from 'react';
+
+import i18n from 'i18next';
+
+export default class EmojiPickerHelper {
+
+editor;
+
+pattern: RegExp;
+
+constructor(editor) {
+  this.editor = editor;
+  this.pattern = /:[^:\s]+/;
+}
+
+setStyle = ():CSSProperties => {
+  const offset = 20;
+  const emojiPickerHeight = 420;
+  const cursorPos = this.editor.cursorCoords(true);
+  const editorPos = this.editor.getWrapperElement().getBoundingClientRect();
+  // Emoji Picker bottom position exceed editor's bottom position
+  if (cursorPos.bottom + emojiPickerHeight > editorPos.bottom) {
+    return {
+      top: editorPos.bottom - emojiPickerHeight,
+      left: cursorPos.left + offset,
+      position: 'fixed',
+    };
+  }
+  return {
+    top: cursorPos.top + offset,
+    left: cursorPos.left + offset,
+    position: 'fixed',
+  };
+}
+
+getSearchCursor =() => {
+  const currentPos = this.editor.getCursor();
+  const sc = this.editor.getSearchCursor(this.pattern, currentPos, { multiline: false });
+  return sc;
+}
+
+// Add emoji when triggered by search
+addEmojiOnSearch = (emoji) => {
+  const currentPos = this.editor.getCursor();
+  const sc = this.getSearchCursor();
+  if (sc.findPrevious()) {
+    sc.replace(`${emoji.colons} `, this.editor.getTokenAt(currentPos).string);
+    this.editor.focus();
+    this.editor.refresh();
+  }
+}
+
+
+// Add emoji when triggered by click emoji icon on top of editor
+addEmoji = (emoji) => {
+  const currentPos = this.editor.getCursor();
+  const doc = this.editor.getDoc();
+  doc.replaceRange(`${emoji.colons} `, currentPos);
+  this.editor.focus();
+  this.editor.refresh();
+}
+
+getEmoji = () => {
+  const sc = this.getSearchCursor();
+  const currentPos = this.editor.getCursor();
+
+  if (sc.findPrevious()) {
+    const isInputtingEmoji = (currentPos.line === sc.to().line && currentPos.ch === sc.to().ch);
+    // current search cursor position
+    if (!isInputtingEmoji) {
+      return;
+    }
+    const pos = {
+      line: sc.to().line,
+      ch: sc.to().ch,
+    };
+    const currentSearchText = sc.matches(true, pos).match[0];
+    const searchWord = currentSearchText.replace(':', '');
+    return searchWord;
+  }
+
+  return;
+}
+
+}
+
+export const getEmojiTranslation = () => {
+
+  const categories = {};
+  [
+    'search',
+    'recent',
+    'smileys',
+    'people',
+    'nature',
+    'foods',
+    'activity',
+    'places',
+    'objects',
+    'symbols',
+    'flags',
+    'custom',
+  ].forEach((category) => {
+    categories[category] = i18n.t(`emoji.categories.${category}`);
+  });
+
+  const skintones = {};
+  (Array.from(Array(6).keys())).forEach((tone) => {
+    skintones[tone + 1] = i18n.t(`emoji.skintones.${tone + 1}`);
+  });
+
+  const translation = {
+    search: i18n.t('emoji.search'),
+    clear: i18n.t('emoji.clear'),
+    notfound: i18n.t('emoji.notfound'),
+    skintext: i18n.t('emoji.skintext'),
+    categories,
+    categorieslabel: i18n.t('emoji.categorieslabel'),
+    skintones,
+    title: i18n.t('emoji.title'),
+  };
+
+  return translation;
+};

+ 48 - 21
packages/app/src/components/PageRenameModal.tsx

@@ -2,25 +2,24 @@ import React, {
   useState, useEffect, useCallback, useMemo,
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 } from 'react';
 
 
+import { pagePathUtils } from '@growi/core';
+import { useTranslation } from 'react-i18next';
 import {
 import {
   Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
   Collapse, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
-
-import { useTranslation } from 'react-i18next';
-
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
-import { pagePathUtils } from '@growi/core';
-import { usePageRenameModal } from '~/stores/modal';
-import { toastError } from '~/client/util/apiNotification';
 
 
+import { toastError } from '~/client/util/apiNotification';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-import { useSiteUrl } from '~/stores/context';
 import { isIPageInfoForEntity } from '~/interfaces/page';
 import { isIPageInfoForEntity } from '~/interfaces/page';
+import { useSiteUrl, useIsSearchServiceReachable } from '~/stores/context';
+import { usePageRenameModal } from '~/stores/modal';
 import { useSWRxPageInfo } from '~/stores/page';
 import { useSWRxPageInfo } from '~/stores/page';
 
 
+import DuplicatedPathsTable from './DuplicatedPathsTable';
+import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from './PagePathAutoComplete';
+
 
 
 const isV5Compatible = (meta: unknown): boolean => {
 const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
@@ -33,6 +32,7 @@ const PageRenameModal = (): JSX.Element => {
   const { isUsersHomePage } = pagePathUtils;
   const { isUsersHomePage } = pagePathUtils;
   const { data: siteUrl } = useSiteUrl();
   const { data: siteUrl } = useSiteUrl();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
   const { data: renameModalData, close: closeRenameModal } = usePageRenameModal();
+  const { data: isReachable } = useIsSearchServiceReachable();
 
 
   const isOpened = renameModalData?.isOpened ?? false;
   const isOpened = renameModalData?.isOpened ?? false;
   const page = renameModalData?.page;
   const page = renameModalData?.page;
@@ -50,6 +50,7 @@ const PageRenameModal = (): JSX.Element => {
 
 
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [subordinatedPages, setSubordinatedPages] = useState([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
   const [existingPaths, setExistingPaths] = useState<string[]>([]);
+  const [canRename, setCanRename] = useState(false);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRecursively, setIsRenameRecursively] = useState(true);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
   const [isRenameRedirect, setIsRenameRedirect] = useState(false);
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
   const [isRemainMetadata, setIsRemainMetadata] = useState(false);
@@ -81,7 +82,7 @@ const PageRenameModal = (): JSX.Element => {
   }, [isOpened, page, updateSubordinatedList]);
   }, [isOpened, page, updateSubordinatedList]);
 
 
   const rename = useCallback(async() => {
   const rename = useCallback(async() => {
-    if (page == null) {
+    if (page == null || !canRename) {
       return;
       return;
     }
     }
 
 
@@ -116,7 +117,7 @@ const PageRenameModal = (): JSX.Element => {
     catch (err) {
     catch (err) {
       setErrs(err);
       setErrs(err);
     }
     }
-  }, [closeRenameModal, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
+  }, [closeRenameModal, canRename, isRemainMetadata, isRenameRecursively, isRenameRedirect, page, pageNameInput, renameModalData?.opts?.onRenamed]);
 
 
   const checkExistPaths = useCallback(async(fromPath, toPath) => {
   const checkExistPaths = useCallback(async(fromPath, toPath) => {
     if (page == null) {
     if (page == null) {
@@ -124,8 +125,11 @@ const PageRenameModal = (): JSX.Element => {
     }
     }
 
 
     try {
     try {
-      const res = await apiv3Get<{ existPaths: string[] }>('/page/exist-paths', { fromPath, toPath });
+      const res = await apiv3Get<{ existPaths: string[]}>('/page/exist-paths', { fromPath, toPath });
       const { existPaths } = res.data;
       const { existPaths } = res.data;
+      if (existPaths.length === 0) {
+        setCanRename(true);
+      }
       setExistingPaths(existPaths);
       setExistingPaths(existPaths);
     }
     }
     catch (err) {
     catch (err) {
@@ -153,6 +157,15 @@ const PageRenameModal = (): JSX.Element => {
     }
     }
   }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
   }, [pageNameInput, subordinatedPages, checkExistPathsDebounce, page, checkIsUsersHomePageDebounce]);
 
 
+  useEffect(() => {
+    setCanRename(false);
+  }, [pageNameInput]);
+
+
+  function ppacInputChangeHandler(value) {
+    setErrs(null);
+    setPageNameInput(value);
+  }
 
 
   /**
   /**
    * change pageNameInput
    * change pageNameInput
@@ -194,6 +207,9 @@ const PageRenameModal = (): JSX.Element => {
   if (isMatchedWithUserHomePagePath) {
   if (isMatchedWithUserHomePagePath) {
     submitButtonDisabled = true;
     submitButtonDisabled = true;
   }
   }
+  else if (!canRename) {
+    submitButtonDisabled = true;
+  }
   else if (isV5Compatible(page.meta)) {
   else if (isV5Compatible(page.meta)) {
     submitButtonDisabled = existingPaths.length !== 0; // v5 data
     submitButtonDisabled = existingPaths.length !== 0; // v5 data
   }
   }
@@ -219,14 +235,25 @@ const PageRenameModal = (): JSX.Element => {
               <span className="input-group-text">{siteUrl}</span>
               <span className="input-group-text">{siteUrl}</span>
             </div>
             </div>
             <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
             <form className="flex-fill" onSubmit={(e) => { e.preventDefault(); rename() }}>
-              <input
-                type="text"
-                value={pageNameInput}
-                className="form-control"
-                onChange={e => inputChangeHandler(e.target.value)}
-                required
-                autoFocus
-              />
+              {isReachable
+                ? (
+                  <PagePathAutoComplete
+                    initializedPath={path}
+                    onSubmit={rename}
+                    onInputChange={ppacInputChangeHandler}
+                    autoFocus
+                  />
+                )
+                : (
+                  <input
+                    type="text"
+                    value={pageNameInput}
+                    className="form-control"
+                    onChange={e => inputChangeHandler(e.target.value)}
+                    required
+                    autoFocus
+                  />
+                )}
             </form>
             </form>
           </div>
           </div>
         </div>
         </div>

+ 118 - 18
packages/app/src/components/PrivateLegacyPages.tsx

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

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

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

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

@@ -368,13 +368,6 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
       };
       };
     }
     }
 
 
-    if (title.includes('/')) {
-      return {
-        type: AlertType.WARNING,
-        message: t('form_validation.slashed_are_not_yet_supported'),
-      };
-    }
-
     return null;
     return null;
   };
   };
 
 
@@ -446,7 +439,7 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
                 <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
                 <i className="fa fa-spinner fa-pulse mr-2 text-muted"></i>
               )}
               )}
               <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
               <a href={`/${page._id}`} className="grw-pagetree-title-anchor flex-grow-1">
-                <p className={`text-truncate m-auto ${page.isEmpty && 'text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
+                <p className={`text-truncate m-auto ${page.isEmpty && 'grw-sidebar-text-muted'}`}>{nodePath.basename(page.path ?? '') || '/'}</p>
               </a>
               </a>
             </>
             </>
           )}
           )}

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

@@ -2,7 +2,7 @@ import React, { FC, useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 
 import TagCloudBox from '../TagCloudBox';
 import TagCloudBox from '../TagCloudBox';
@@ -16,7 +16,7 @@ const Tag: FC = () => {
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
 
 
   const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
   const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
   const isLoading = tagDataList === undefined && error == null;
 
 

+ 5 - 3
packages/app/src/components/TagCloudBox.tsx

@@ -1,9 +1,11 @@
 import React, { FC, memo } from 'react';
 import React, { FC, memo } from 'react';
+
 import { TagCloud } from 'react-tagcloud';
 import { TagCloud } from 'react-tagcloud';
-import { ITagCountHasId } from '~/interfaces/tag';
+
+import { IDataTagCount } from '~/interfaces/tag';
 
 
 type Props = {
 type Props = {
-  tags:ITagCountHasId[],
+  tags:IDataTagCount[],
   minSize?: number,
   minSize?: number,
   maxSize?: number,
   maxSize?: number,
   maxTagTextLength?: number,
   maxTagTextLength?: number,
@@ -29,7 +31,7 @@ const TagCloudBox: FC<Props> = memo((props:(Props & typeof defaultProps)) => {
       <TagCloud
       <TagCloud
         minSize={minSize ?? MIN_FONT_SIZE}
         minSize={minSize ?? MIN_FONT_SIZE}
         maxSize={maxSize ?? MAX_FONT_SIZE}
         maxSize={maxSize ?? MAX_FONT_SIZE}
-        tags={tags.map((tag:ITagCountHasId) => {
+        tags={tags.map((tag:IDataTagCount) => {
           return {
           return {
             // text truncation
             // text truncation
             value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,
             value: (tag.name).length > maxTagTextLength ? `${(tag.name).slice(0, maxTagTextLength)}...` : tag.name,

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

@@ -4,12 +4,12 @@ import React, {
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 
 
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
 type TagListProps = {
 type TagListProps = {
-  tagData: ITagCountHasId[],
+  tagData: IDataTagCount[],
   totalTags: number,
   totalTags: number,
   activePage: number,
   activePage: number,
   onChangePage?: (selectedPageNumber: number) => void,
   onChangePage?: (selectedPageNumber: number) => void,
@@ -29,7 +29,7 @@ const TagList: FC<TagListProps> = (props:(TagListProps & typeof defaultProps)) =
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
   const generateTagList = useCallback((tagData) => {
   const generateTagList = useCallback((tagData) => {
-    return tagData.map((tag:ITagCountHasId, index:number) => {
+    return tagData.map((tag:IDataTagCount, index:number) => {
       const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
       const tagListClasses: string = index === 0 ? 'list-group-item d-flex' : 'list-group-item d-flex border-top-0';
 
 
       return (
       return (

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

@@ -2,7 +2,7 @@ import React, { FC, useState, useCallback } from 'react';
 
 
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 
 
-import { ITagCountHasId } from '~/interfaces/tag';
+import { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 
 import TagCloudBox from './TagCloudBox';
 import TagCloudBox from './TagCloudBox';
@@ -15,7 +15,7 @@ const TagPage: FC = () => {
   const [offset, setOffset] = useState<number>(0);
   const [offset, setOffset] = useState<number>(0);
 
 
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
   const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: ITagCountHasId[] = tagDataList?.data || [];
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
   const isLoading = tagDataList === undefined && error == null;
 
 

+ 9 - 2
packages/app/src/components/TrashPageList.jsx

@@ -1,9 +1,12 @@
 import React, { useMemo } from 'react';
 import React, { useMemo } from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import { withTranslation } from 'react-i18next';
-import PageListIcon from './Icons/PageListIcon';
+
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
 import { DescendantsPageListForCurrentPath } from './DescendantsPageList';
+import EmptyTrashButton from './EmptyTrashButton';
+import PageListIcon from './Icons/PageListIcon';
 
 
 
 
 const TrashPageList = (props) => {
 const TrashPageList = (props) => {
@@ -20,9 +23,13 @@ const TrashPageList = (props) => {
     };
     };
   }, [t]);
   }, [t]);
 
 
+  const emptyTrashButton = useMemo(() => {
+    return <EmptyTrashButton />;
+  }, [t]);
+
   return (
   return (
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
     <div data-testid="trash-page-list" className="mt-5 d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} />
+      <CustomNavAndContents navTabMapping={navTabMapping} navRightElement={emptyTrashButton} />
     </div>
     </div>
   );
   );
 };
 };

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

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

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

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

+ 3 - 3
packages/app/src/interfaces/page.ts

@@ -1,9 +1,9 @@
 import { Ref, Nullable } from './common';
 import { Ref, Nullable } from './common';
-import { IUser } from './user';
-import { IRevision, HasRevisionShortbody } from './revision';
-import { ITag } from './tag';
 import { HasObjectId } from './has-object-id';
 import { HasObjectId } from './has-object-id';
+import { IRevision, HasRevisionShortbody } from './revision';
 import { SubscriptionStatusType } from './subscription';
 import { SubscriptionStatusType } from './subscription';
+import { ITag } from './tag';
+import { IUser } from './user';
 
 
 
 
 export interface IPage {
 export interface IPage {

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

@@ -1,21 +1,18 @@
-import { HasObjectId } from './has-object-id';
-
-export type ITag = {
+export type ITag<ID = string> = {
+  _id: ID
   name: string,
   name: string,
-  createdAt: Date;
 }
 }
 
 
-export type ITagCount = Omit<ITag, 'createdAt'> & {count: number}
+export type IDataTagCount = ITag & {count: number}
 
 
-export type ITagCountHasId = ITagCount & HasObjectId
 
 
-export type ITagsSearchApiv1Result = {
+export type IResTagsSearchApiv1 = {
   ok: boolean,
   ok: boolean,
   tags: string[]
   tags: string[]
 }
 }
 
 
-export type ITagsListApiv1Result = {
+export type IResTagsListApiv1 = {
   ok: boolean,
   ok: boolean,
-  data: ITagCountHasId[],
+  data: IDataTagCount[],
   totalCount: number,
   totalCount: number,
 }
 }

+ 6 - 0
packages/app/src/interfaces/websocket.ts

@@ -7,6 +7,10 @@ export const SocketEventName = {
   PMMigrating: 'PublicMigrationMigrating',
   PMMigrating: 'PublicMigrationMigrating',
   PMErrorCount: 'PublicMigrationErrorCount',
   PMErrorCount: 'PublicMigrationErrorCount',
   PMEnded: 'PublicMigrationEnded',
   PMEnded: 'PublicMigrationEnded',
+
+  // Page migration
+  PageMigrationSuccess: 'PageMigrationSuccess',
+  PageMigrationError: 'PageMigrationError',
 } as const;
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
 
@@ -22,3 +26,5 @@ export type PMStartedData = { total: number };
 export type PMMigratingData = { count: number };
 export type PMMigratingData = { count: number };
 export type PMErrorCountData = { skip: number };
 export type PMErrorCountData = { skip: number };
 export type PMEndedData = { isSucceeded: boolean };
 export type PMEndedData = { isSucceeded: boolean };
+
+export type PageMigrationErrorData = { paths: string[] }

+ 13 - 12
packages/app/src/server/crowi/index.js

@@ -1,12 +1,13 @@
 /* eslint-disable @typescript-eslint/no-this-alias */
 /* eslint-disable @typescript-eslint/no-this-alias */
 
 
-import path from 'path';
 import http from 'http';
 import http from 'http';
-import mongoose from 'mongoose';
+import path from 'path';
 
 
 import { createTerminus } from '@godaddy/terminus';
 import { createTerminus } from '@godaddy/terminus';
-
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
 import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '@growi/core';
+import mongoose from 'mongoose';
+
+
 import pkg from '^/package.json';
 import pkg from '^/package.json';
 
 
 import CdnResourcesService from '~/services/cdn-resources-service';
 import CdnResourcesService from '~/services/cdn-resources-service';
@@ -15,26 +16,25 @@ import Xss from '~/services/xss';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
-import ConfigManager from '../service/config-manager';
-import AppService from '../service/app';
+import Activity from '../models/activity';
+import PageRedirect from '../models/page-redirect';
+import Tag from '../models/tag';
+import UserGroup from '../models/user-group';
 import AclService from '../service/acl';
 import AclService from '../service/acl';
-import SearchService from '../service/search';
+import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
 import AttachmentService from '../service/attachment';
+import ConfigManager from '../service/config-manager';
+import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
 import PageOperationService from '../service/page-operation';
+import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
-import { InstallerService } from '../service/installer';
-import Activity from '../models/activity';
-import UserGroup from '../models/user-group';
-import PageRedirect from '../models/page-redirect';
 
 
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
-
 const models = require('../models');
 const models = require('../models');
-
 const PluginService = require('../plugins/plugin.service');
 const PluginService = require('../plugins/plugin.service');
 
 
 const sep = path.sep;
 const sep = path.sep;
@@ -281,6 +281,7 @@ Crowi.prototype.setupModels = async function() {
 
 
   // include models that independent from crowi
   // include models that independent from crowi
   allModels.Activity = Activity;
   allModels.Activity = Activity;
+  allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
   allModels.PageRedirect = PageRedirect;
 
 

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

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

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

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

+ 3 - 5
packages/app/src/server/models/page-tag-relation.js

@@ -1,8 +1,9 @@
+import Tag from './tag';
+
 // disable no-return-await for model functions
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 /* eslint-disable no-return-await */
 
 
 const flatMap = require('array.prototype.flatmap');
 const flatMap = require('array.prototype.flatmap');
-
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const mongoosePaginate = require('mongoose-paginate-v2');
 const uniqueValidator = require('mongoose-unique-validator');
 const uniqueValidator = require('mongoose-unique-validator');
@@ -110,8 +111,7 @@ class PageTagRelation {
       .flatMap(result => result.tagIds); // map + flatten
       .flatMap(result => result.tagIds); // map + flatten
     const distinctTagIds = Array.from(new Set(allTagIds));
     const distinctTagIds = Array.from(new Set(allTagIds));
 
 
-    // retrieve tag documents
-    const Tag = mongoose.model('Tag');
+    // TODO: set IdToNameMap type by 93933
     const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
     const tagIdToNameMap = await Tag.getIdToNameMap(distinctTagIds);
 
 
     // convert to map
     // convert to map
@@ -136,8 +136,6 @@ class PageTagRelation {
     // eslint-disable-next-line no-param-reassign
     // eslint-disable-next-line no-param-reassign
     tags = tags.filter((tag) => { return tag !== '' });
     tags = tags.filter((tag) => { return tag !== '' });
 
 
-    const Tag = mongoose.model('Tag');
-
     // get relations for this page
     // get relations for this page
     const relations = await this.findByPageId(pageId, { nullable: true });
     const relations = await this.findByPageId(pageId, { nullable: true });
 
 

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

@@ -57,7 +57,7 @@ export interface PageModel extends Model<PageDocument> {
   createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
   createEmptyPagesByPaths(paths: string[], user: any | null, onlyMigratedAsExistingPages?: boolean, andFilter?): Promise<void>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   getParentAndFillAncestors(path: string, user): Promise<PageDocument & { _id: any }>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
   findByIdsAndViewer(pageIds: ObjectIdLike[], user, userGroups?, includeEmpty?: boolean): Promise<PageDocument[]>
-  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument[]>
+  findByPathAndViewer(path: string | null, user, userGroups?, useFindOne?: boolean, includeEmpty?: boolean): Promise<PageDocument | PageDocument[] | null>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findChildrenByParentPathOrIdAndViewer(parentPathOrId: string, user, userGroups?): Promise<PageDocument[]>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
   findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>
@@ -495,6 +495,7 @@ schema.statics.createEmptyPagesByPaths = async function(paths: string[], user: a
     aggregationPipeline.push({
     aggregationPipeline.push({
       $match: {
       $match: {
         $or: [
         $or: [
+          { grant: GRANT_PUBLIC },
           { parent: { $ne: null } },
           { parent: { $ne: null } },
           { path: '/' },
           { path: '/' },
         ],
         ],

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

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

+ 0 - 69
packages/app/src/server/models/tag.js

@@ -1,69 +0,0 @@
-// disable no-return-await for model functions
-/* eslint-disable no-return-await */
-
-const mongoose = require('mongoose');
-const mongoosePaginate = require('mongoose-paginate-v2');
-const uniqueValidator = require('mongoose-unique-validator');
-
-/*
- * define schema
- */
-const schema = new mongoose.Schema({
-  name: {
-    type: String,
-    required: true,
-    unique: true,
-  },
-});
-schema.plugin(mongoosePaginate);
-schema.plugin(uniqueValidator);
-
-/**
- * Tag Class
- *
- * @class Tag
- */
-class Tag {
-
-  static async getIdToNameMap(tagIds) {
-    const tags = await this.find({ _id: { $in: tagIds } });
-
-    const idToNameMap = {};
-    tags.forEach((tag) => {
-      idToNameMap[tag._id.toString()] = tag.name;
-    });
-
-    return idToNameMap;
-  }
-
-  static async findOrCreate(tagName) {
-    const tag = await this.findOne({ name: tagName });
-    if (!tag) {
-      return this.create({ name: tagName });
-    }
-    return tag;
-  }
-
-  static async findOrCreateMany(tagNames) {
-    const existTags = await this.find({ name: { $in: tagNames } });
-    const existTagNames = existTags.map((tag) => { return tag.name });
-
-    // bulk insert
-    const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
-    await this.insertMany(
-      tagsToCreate.map((tag) => {
-        return { name: tag };
-      }),
-    );
-
-    return this.find({ name: { $in: tagNames } });
-  }
-
-}
-
-module.exports = function(crowi) {
-  Tag.crowi = crowi;
-  schema.loadClass(Tag);
-  const model = mongoose.model('Tag', schema);
-  return model;
-};

+ 63 - 0
packages/app/src/server/models/tag.ts

@@ -0,0 +1,63 @@
+import { getOrCreateModel } from '@growi/core';
+import {
+  Types, Model, Schema,
+} from 'mongoose';
+
+import { ObjectIdLike } from '../interfaces/mongoose-utils';
+
+const mongoosePaginate = require('mongoose-paginate-v2');
+const uniqueValidator = require('mongoose-unique-validator');
+
+
+export interface TagDocument {
+  _id: Types.ObjectId;
+  name: string;
+}
+
+export type IdToNameMap = {[key: string] : string }
+
+export interface TagModel extends Model<TagDocument>{
+  getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap
+  findOrCreateMany(tagNames: string[]): Promise<TagDocument[]>
+}
+
+
+const tagSchema = new Schema<TagDocument, TagModel>({
+  name: {
+    type: String,
+    require: true,
+    unique: true,
+  },
+});
+tagSchema.plugin(mongoosePaginate);
+tagSchema.plugin(uniqueValidator);
+
+
+tagSchema.statics.getIdToNameMap = async function(tagIds: ObjectIdLike[]): Promise<IdToNameMap> {
+  const tags = await this.find({ _id: { $in: tagIds } });
+
+  const idToNameMap = {};
+  tags.forEach((tag) => {
+    idToNameMap[tag._id.toString()] = tag.name;
+  });
+
+  return idToNameMap;
+};
+
+tagSchema.statics.findOrCreateMany = async function(tagNames: string[]): Promise<TagDocument[]> {
+  const existTags = await this.find({ name: { $in: tagNames } });
+  const existTagNames = existTags.map((tag) => { return tag.name });
+
+  // bulk insert
+  const tagsToCreate = tagNames.filter((tagName) => { return !existTagNames.includes(tagName) });
+  await this.insertMany(
+    tagsToCreate.map((tag) => {
+      return { name: tag };
+    }),
+  );
+
+  return this.find({ name: { $in: tagNames } });
+};
+
+
+export default getOrCreateModel<TagDocument, TagModel>('Tag', tagSchema);

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


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

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

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

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

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

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

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

@@ -1,8 +1,8 @@
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
-import loggerFactory from '~/utils/logger';
 
 
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import { AllSubscriptionStatusType } from '~/interfaces/subscription';
 import Subscription from '~/server/models/subscription';
 import Subscription from '~/server/models/subscription';
+import loggerFactory from '~/utils/logger';
 
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 
@@ -474,6 +474,10 @@ module.exports = (crowi) => {
 
 
     try {
     try {
       const fromPage = await Page.findByPath(fromPath);
       const fromPage = await Page.findByPath(fromPath);
+      if (fromPage == null) {
+        return res.apiv3Err(new ErrorV3('fromPage is Null'), 400);
+      }
+
       const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
       const fromPageDescendants = await Page.findManageableListWithDescendants(fromPage, req.user);
 
 
       const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {
       const toPathDescendantsArray = fromPageDescendants.map((subordinatedPage) => {

+ 80 - 9
packages/app/src/server/routes/apiv3/pages.js

@@ -1,7 +1,10 @@
+import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
+import { isV5ConversionError } from '../../models/vo/v5-conversion-error';
 
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
 const { pathUtils, pagePathUtils } = require('@growi/core');
 const { pathUtils, pagePathUtils } = require('@growi/core');
@@ -194,8 +197,10 @@ module.exports = (crowi) => {
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
     ],
     legacyPagesMigration: [
     legacyPagesMigration: [
-      body('pageIds').isArray().withMessage('pageIds is required'),
+      body('convertPath').optional().isString().withMessage('convertPath must be a string'),
+      body('pageIds').optional().isArray().withMessage('pageIds must be an array'),
       body('isRecursively')
       body('isRecursively')
+        .optional()
         .custom(v => v === 'true' || v === true || v == null)
         .custom(v => v === 'true' || v === true || v == null)
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
         .withMessage('The body property "isRecursively" must be "true" or true. (Omit param for false)'),
     ],
     ],
@@ -339,6 +344,20 @@ module.exports = (crowi) => {
       }
       }
     }
     }
 
 
+    // create activity
+    try {
+      const parameters = {
+        user: req.user._id,
+        targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
+        target: createdPage,
+        action: SUPPORTED_ACTION_TYPE.ACTION_PAGE_CREATE,
+      };
+      await crowi.activityService.createByParameters(parameters);
+    }
+    catch (err) {
+      logger.error('Failed to create activity', err);
+    }
+
     // create subscription
     // create subscription
     try {
     try {
       await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
       await crowi.inAppNotificationService.createSubscription(req.user.id, createdPage._id, subscribeRuleNames.PAGE_CREATE);
@@ -543,15 +562,38 @@ module.exports = (crowi) => {
    *          200:
    *          200:
    *            description: Succeeded to remove all trash pages
    *            description: Succeeded to remove all trash pages
    */
    */
-  router.delete('/empty-trash', accessTokenParser, loginRequired, adminRequired, csrf, apiV3FormValidator, async(req, res) => {
+  router.delete('/empty-trash', accessTokenParser, loginRequired, csrf, apiV3FormValidator, async(req, res) => {
     const options = {};
     const options = {};
 
 
-    try {
-      const pages = await crowi.pageService.emptyTrashPage(req.user, options);
-      return res.apiv3({ pages });
+    const pagesInTrash = await Page.findChildrenByParentPathOrIdAndViewer('/trash', req.user);
+
+    const deletablePages = crowi.pageService.filterPagesByCanDeleteCompletely(pagesInTrash, req.user, true);
+
+    if (deletablePages.length === 0) {
+      const msg = 'No pages can be deleted.';
+      return res.apiv3Err(new ErrorV3(msg), 500);
     }
     }
-    catch (err) {
-      return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+
+    // when some pages are not deletable
+    if (deletablePages.length < pagesInTrash.length) {
+      try {
+        const options = { isCompletely: true, isRecursively: true };
+        await crowi.pageService.deleteMultiplePages(deletablePages, req.user, options);
+        return res.apiv3({ deletablePages });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
+    }
+    // when all pages are deletable
+    else {
+      try {
+        const pages = await crowi.pageService.emptyTrashPage(req.user, options);
+        return res.apiv3({ pages });
+      }
+      catch (err) {
+        return res.apiv3Err(new ErrorV3('Failed to update page.', 'unknown'), 500);
+      }
     }
     }
   });
   });
 
 
@@ -783,15 +825,44 @@ module.exports = (crowi) => {
 
 
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, csrf, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
-    const { pageIds: _pageIds, isRecursively } = req.body;
+    const { convertPath, pageIds: _pageIds, isRecursively } = req.body;
+
+    // Convert by path
+    if (convertPath != null) {
+      const normalizedPath = pathUtils.normalizePath(convertPath);
+      try {
+        await crowi.pageService.normalizeParentByPath(normalizedPath, req.user);
+      }
+      catch (err) {
+        logger.error(err);
+
+        if (isV5ConversionError(err)) {
+          return res.apiv3Err(new ErrorV3(err.message, err.code), 400);
+        }
+
+        return res.apiv3Err(new ErrorV3('Failed to convert pages.'), 400);
+      }
+
+      return res.apiv3({});
+    }
+
+    // Convert by pageIds
     const pageIds = _pageIds == null ? [] : _pageIds;
     const pageIds = _pageIds == null ? [] : _pageIds;
 
 
     if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
     if (pageIds.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
       return res.apiv3Err(new ErrorV3(`The maximum number of pages you can select is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`, 'exceeded_maximum_number'), 400);
     }
     }
+    if (pageIds.length === 0) {
+      return res.apiv3Err(new ErrorV3('No page is selected.'), 400);
+    }
 
 
     try {
     try {
-      await crowi.pageService.normalizeParentByPageIds(pageIds, req.user, isRecursively);
+      if (isRecursively) {
+        await crowi.pageService.normalizeParentByPageIdsRecursively(pageIds, req.user);
+      }
+      else {
+        await crowi.pageService.normalizeParentByPageIds(pageIds, req.user);
+      }
     }
     }
     catch (err) {
     catch (err) {
       return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);
       return res.apiv3Err(new ErrorV3(`Failed to migrate pages: ${err.message}`), 500);

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

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

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

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

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

@@ -1,3 +1,5 @@
+import Tag from '~/server/models/tag';
+
 /**
 /**
  * @swagger
  * @swagger
  *
  *
@@ -29,7 +31,6 @@
  */
  */
 module.exports = function(crowi, app) {
 module.exports = function(crowi, app) {
 
 
-  const Tag = crowi.model('Tag');
   const PageTagRelation = crowi.model('PageTagRelation');
   const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const actions = {};

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

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

+ 119 - 30
packages/app/src/server/service/page.ts

@@ -6,7 +6,9 @@ import escapeStringRegexp from 'escape-string-regexp';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import mongoose, { ObjectId, QueryCursor } from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
+import { SUPPORTED_TARGET_MODEL_TYPE, SUPPORTED_ACTION_TYPE } from '~/interfaces/activity';
 import { Ref } from '~/interfaces/common';
 import { Ref } from '~/interfaces/common';
+import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import { HasObjectId } from '~/interfaces/has-object-id';
 import {
 import {
   IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
   IPage, IPageInfo, IPageInfoForEntity, IPageWithMeta,
@@ -15,7 +17,7 @@ import {
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
   PageDeleteConfigValue, IPageDeleteConfigValueToProcessValidation,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
 import { IUserHasId } from '~/interfaces/user';
 import { IUserHasId } from '~/interfaces/user';
-import { SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { PageMigrationErrorData, SocketEventName, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import { stringifySnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
 import {
 import {
   CreateMethod, PageCreateOptions, PageModel, PageDocument,
   CreateMethod, PageCreateOptions, PageModel, PageDocument,
@@ -30,7 +32,7 @@ import PageOperation, { PageActionStage, PageActionType } from '../models/page-o
 import { PageRedirectModel } from '../models/page-redirect';
 import { PageRedirectModel } from '../models/page-redirect';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import { serializePageSecurely } from '../models/serializers/page-serializer';
 import Subscription from '../models/subscription';
 import Subscription from '../models/subscription';
-import ActivityDefine from '../util/activityDefine';
+import { V5ConversionError } from '../models/vo/v5-conversion-error';
 
 
 const debug = require('debug')('growi:services:page');
 const debug = require('debug')('growi:services:page');
 
 
@@ -157,7 +159,7 @@ class PageService {
       this.pageEvent.onUpdate();
       this.pageEvent.onUpdate();
 
 
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_UPDATE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_UPDATE);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -167,7 +169,7 @@ class PageService {
     // rename
     // rename
     this.pageEvent.on('rename', async(page, user) => {
     this.pageEvent.on('rename', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_RENAME);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_RENAME);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -177,7 +179,7 @@ class PageService {
     // duplicate
     // duplicate
     this.pageEvent.on('duplicate', async(page, user) => {
     this.pageEvent.on('duplicate', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DUPLICATE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DUPLICATE);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -187,7 +189,7 @@ class PageService {
     // delete
     // delete
     this.pageEvent.on('delete', async(page, user) => {
     this.pageEvent.on('delete', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -197,7 +199,7 @@ class PageService {
     // delete completely
     // delete completely
     this.pageEvent.on('deleteCompletely', async(page, user) => {
     this.pageEvent.on('deleteCompletely', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_DELETE_COMPLETELY);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_DELETE_COMPLETELY);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -207,7 +209,7 @@ class PageService {
     // revert
     // revert
     this.pageEvent.on('revert', async(page, user) => {
     this.pageEvent.on('revert', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_REVERT);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_REVERT);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -217,7 +219,7 @@ class PageService {
     // likes
     // likes
     this.pageEvent.on('like', async(page, user) => {
     this.pageEvent.on('like', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_LIKE);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_LIKE);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -227,7 +229,7 @@ class PageService {
     // bookmark
     // bookmark
     this.pageEvent.on('bookmark', async(page, user) => {
     this.pageEvent.on('bookmark', async(page, user) => {
       try {
       try {
-        await this.createAndSendNotifications(page, user, ActivityDefine.ACTION_PAGE_BOOKMARK);
+        await this.createAndSendNotifications(page, user, SUPPORTED_ACTION_TYPE.ACTION_PAGE_BOOKMARK);
       }
       }
       catch (err) {
       catch (err) {
         logger.error(err);
         logger.error(err);
@@ -2237,7 +2239,7 @@ class PageService {
     // Create activity
     // Create activity
     const parameters = {
     const parameters = {
       user: user._id,
       user: user._id,
-      targetModel: ActivityDefine.MODEL_PAGE,
+      targetModel: SUPPORTED_TARGET_MODEL_TYPE.MODEL_PAGE,
       target: page,
       target: page,
       action,
       action,
     };
     };
@@ -2251,24 +2253,99 @@ class PageService {
     await inAppNotificationService.emitSocketIo(targetUsers);
     await inAppNotificationService.emitSocketIo(targetUsers);
   }
   }
 
 
-  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user, isRecursively: boolean): Promise<void> {
+  async normalizeParentByPath(path: string, user): Promise<void> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
-    if (isRecursively) {
-      const pages = await Page.findByIdsAndViewer(pageIds, user, null);
+    const pages = await Page.findByPathAndViewer(path, user, null, false);
+    if (pages == null || !Array.isArray(pages)) {
+      throw Error('Something went wrong while converting pages.');
+    }
+    if (pages.length === 0) {
+      throw new V5ConversionError(`Could not find the page "${path}" to convert.`, V5ConversionErrCode.PAGE_NOT_FOUND);
+    }
+    if (pages.length > 1) {
+      throw new V5ConversionError(
+        `There are more than two pages at the path "${path}". Please rename or delete the page first.`,
+        V5ConversionErrCode.DUPLICATE_PAGES_FOUND,
+      );
+    }
 
 
-      // DO NOT await !!
-      this.normalizeParentRecursivelyByPages(pages, user);
+    const page = pages[0];
+    const {
+      grant, grantedUsers: grantedUserIds, grantedGroup: grantedGroupId,
+    } = page;
 
 
-      return;
+    /*
+     * UserGroup & Owner validation
+     */
+    let isGrantNormalized = false;
+    try {
+      const shouldCheckDescendants = true;
+
+      isGrantNormalized = await this.crowi.pageGrantService.isGrantNormalized(user, path, grant, grantedUserIds, grantedGroupId, shouldCheckDescendants);
+    }
+    catch (err) {
+      logger.error(`Failed to validate grant of page at "${path}"`, err);
+      throw err;
+    }
+    if (!isGrantNormalized) {
+      throw new V5ConversionError(
+        'This page cannot be migrated since the selected grant or grantedGroup is not assignable to this page.',
+        V5ConversionErrCode.GRANT_INVALID,
+      );
+    }
+
+    let pageOp;
+    try {
+      pageOp = await PageOperation.create({
+        actionType: PageActionType.NormalizeParent,
+        actionStage: PageActionStage.Main,
+        page,
+        user,
+        fromPath: page.path,
+        toPath: page.path,
+      });
+    }
+    catch (err) {
+      logger.error('Failed to create PageOperation document.', err);
+      throw err;
     }
     }
 
 
+    // no await
+    this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
+  }
+
+  async normalizeParentByPageIdsRecursively(pageIds: ObjectIdLike[], user): Promise<void> {
+    const Page = mongoose.model('Page') as unknown as PageModel;
+
+    const pages = await Page.findByIdsAndViewer(pageIds, user, null);
+
+    if (pages == null || pages.length === 0) {
+      throw Error('pageIds is null or 0 length.');
+    }
+
+    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
+      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
+    }
+
+    this.normalizeParentRecursivelyByPages(pages, user);
+
+    return;
+  }
+
+  async normalizeParentByPageIds(pageIds: ObjectIdLike[], user): Promise<void> {
+    const Page = await mongoose.model('Page') as unknown as PageModel;
+
+    const socket = this.crowi.socketIoService.getDefaultSocket();
+
     for await (const pageId of pageIds) {
     for await (const pageId of pageIds) {
       const page = await Page.findById(pageId);
       const page = await Page.findById(pageId);
       if (page == null) {
       if (page == null) {
         continue;
         continue;
       }
       }
 
 
+      const errorData: PageMigrationErrorData = { paths: [page.path] };
+
       try {
       try {
         const canOperate = await this.crowi.pageOperationService.canOperate(false, page.path, page.path);
         const canOperate = await this.crowi.pageOperationService.canOperate(false, page.path, page.path);
         if (!canOperate) {
         if (!canOperate) {
@@ -2278,14 +2355,16 @@ class PageService {
         const normalizedPage = await this.normalizeParentByPage(page, user);
         const normalizedPage = await this.normalizeParentByPage(page, user);
 
 
         if (normalizedPage == null) {
         if (normalizedPage == null) {
+          socket.emit(SocketEventName.PageMigrationError, errorData);
           logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
           logger.error(`Failed to update descendantCount of page of id: "${pageId}"`);
         }
         }
       }
       }
       catch (err) {
       catch (err) {
+        socket.emit(SocketEventName.PageMigrationError, errorData);
         logger.error('Something went wrong while normalizing parent.', err);
         logger.error('Something went wrong while normalizing parent.', err);
-        // socket.emit('normalizeParentByPageIds', { error: err.message }); TODO: use socket to tell user
       }
       }
     }
     }
+    socket.emit(SocketEventName.PageMigrationSuccess);
   }
   }
 
 
   private async normalizeParentByPage(page, user) {
   private async normalizeParentByPage(page, user) {
@@ -2348,14 +2427,7 @@ class PageService {
     /*
     /*
      * Main Operation
      * Main Operation
      */
      */
-    if (pages == null || pages.length === 0) {
-      logger.error('pageIds is null or 0 length.');
-      return;
-    }
-
-    if (pages.length > LIMIT_FOR_MULTIPLE_PAGE_OP) {
-      throw Error(`The maximum number of pageIds allowed is ${LIMIT_FOR_MULTIPLE_PAGE_OP}.`);
-    }
+    const socket = this.crowi.socketIoService.getDefaultSocket();
 
 
     const pagesToNormalize = omitDuplicateAreaPageFromPages(pages);
     const pagesToNormalize = omitDuplicateAreaPageFromPages(pages);
 
 
@@ -2365,25 +2437,29 @@ class PageService {
       [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
       [normalizablePages, nonNormalizablePages] = await this.crowi.pageGrantService.separateNormalizableAndNotNormalizablePages(user, pagesToNormalize);
     }
     }
     catch (err) {
     catch (err) {
+      socket.emit(SocketEventName.PageMigrationError);
       throw err;
       throw err;
     }
     }
 
 
     if (normalizablePages.length === 0) {
     if (normalizablePages.length === 0) {
-      // socket.emit('normalizeParentRecursivelyByPages', { error: err.message }); TODO: use socket to tell user
+      socket.emit(SocketEventName.PageMigrationError);
       return;
       return;
     }
     }
 
 
     if (nonNormalizablePages.length !== 0) {
     if (nonNormalizablePages.length !== 0) {
-      // TODO: iterate nonNormalizablePages and send socket error to client so that the user can know which path failed to migrate
-      // socket.emit('normalizeParentRecursivelyByPages', { error: err.message }); TODO: use socket to tell user
+      const nonNormalizablePagePaths: string[] = nonNormalizablePages.map(p => p.path);
+      socket.emit(SocketEventName.PageMigrationError, { paths: nonNormalizablePagePaths });
+      logger.debug('Some pages could not be converted.', nonNormalizablePagePaths);
     }
     }
 
 
     /*
     /*
      * Main Operation (s)
      * Main Operation (s)
      */
      */
+    const errorPagePaths: string[] = [];
     for await (const page of normalizablePages) {
     for await (const page of normalizablePages) {
       const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, page.path);
       const canOperate = await this.crowi.pageOperationService.canOperate(true, page.path, page.path);
       if (!canOperate) {
       if (!canOperate) {
+        errorPagePaths.push(page.path);
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
         throw Error(`Cannot operate normalizeParentRecursiively to path "${page.path}" right now.`);
       }
       }
 
 
@@ -2395,6 +2471,7 @@ class PageService {
       const existingPage = await builder.query.exec();
       const existingPage = await builder.query.exec();
 
 
       if (existingPage?.parent != null) {
       if (existingPage?.parent != null) {
+        errorPagePaths.push(page.path);
         throw Error('This page has already converted.');
         throw Error('This page has already converted.');
       }
       }
 
 
@@ -2410,6 +2487,7 @@ class PageService {
         });
         });
       }
       }
       catch (err) {
       catch (err) {
+        errorPagePaths.push(page.path);
         logger.error('Failed to create PageOperation document.', err);
         logger.error('Failed to create PageOperation document.', err);
         throw err;
         throw err;
       }
       }
@@ -2418,10 +2496,17 @@ class PageService {
         await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
         await this.normalizeParentRecursivelyMainOperation(page, user, pageOp._id);
       }
       }
       catch (err) {
       catch (err) {
+        errorPagePaths.push(page.path);
         logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
         logger.err('Failed to run normalizeParentRecursivelyMainOperation.', err);
         throw err;
         throw err;
       }
       }
     }
     }
+    if (errorPagePaths.length === 0) {
+      socket.emit(SocketEventName.PageMigrationSuccess);
+    }
+    else {
+      socket.emit(SocketEventName.PageMigrationError, { paths: errorPagePaths });
+    }
   }
   }
 
 
   async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
   async normalizeParentRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike): Promise<void> {
@@ -2469,7 +2554,11 @@ class PageService {
 
 
       const { prevDescendantCount } = options;
       const { prevDescendantCount } = options;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
       const newDescendantCount = pageAfterUpdatingDescendantCount.descendantCount;
-      const inc = (newDescendantCount - prevDescendantCount) + 1;
+      let inc = newDescendantCount - prevDescendantCount;
+      const isAlreadyConverted = page.parent != null;
+      if (!isAlreadyConverted) {
+        inc += 1;
+      }
       await this.updateDescendantCountOfAncestors(page._id, inc, false);
       await this.updateDescendantCountOfAncestors(page._id, inc, false);
     }
     }
     catch (err) {
     catch (err) {

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

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

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

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

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

@@ -104,6 +104,7 @@
 
 
 <div id="page-create-modal"></div>
 <div id="page-create-modal"></div>
 <div id="page-delete-modal"></div>
 <div id="page-delete-modal"></div>
+<div id="empty-trash-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-duplicate-modal"></div>
 <div id="page-rename-modal"></div>
 <div id="page-rename-modal"></div>
 <div id="page-presentation-modal"></div>
 <div id="page-presentation-modal"></div>

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

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

+ 60 - 14
packages/app/src/stores/modal.tsx

@@ -1,11 +1,13 @@
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
-import { useStaticSWR } from './use-static-swr';
+
+import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import {
 import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 } from '~/interfaces/ui';
-import { IPageToDeleteWithMeta, IPageToRenameWithMeta } from '~/interfaces/page';
 import { IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroupHasId } from '~/interfaces/user';
 
 
+import { useStaticSWR } from './use-static-swr';
+
 
 
 /*
 /*
 * PageCreateModal
 * PageCreateModal
@@ -31,6 +33,9 @@ export const usePageCreateModal = (status?: CreateModalStatus): SWRResponse<Crea
   };
   };
 };
 };
 
 
+/*
+* PageDeleteModal
+*/
 export type IDeleteModalOption = {
 export type IDeleteModalOption = {
   onDeleted?: OnDeletedFunction,
   onDeleted?: OnDeletedFunction,
 }
 }
@@ -68,6 +73,47 @@ export const usePageDeleteModal = (status?: DeleteModalStatus): SWRResponse<Dele
   };
   };
 };
 };
 
 
+/*
+* EmptyTrashModal
+*/
+type IEmptyTrashModalOption = {
+  onEmptiedTrash?: () => void,
+  canDelepeAllPages: boolean,
+}
+
+type EmptyTrashModalStatus = {
+  isOpened: boolean,
+  pages?: IPageToDeleteWithMeta[],
+  opts?: IEmptyTrashModalOption,
+}
+
+type EmptyTrashModalStatusUtils = {
+  open(
+    pages?: IPageToDeleteWithMeta[],
+    opts?: IEmptyTrashModalOption,
+  ): Promise<EmptyTrashModalStatus | undefined>,
+  close(): Promise<EmptyTrashModalStatus | undefined>,
+}
+
+export const useEmptyTrashModal = (status?: EmptyTrashModalStatus): SWRResponse<EmptyTrashModalStatus, Error> & EmptyTrashModalStatusUtils => {
+  const initialData: EmptyTrashModalStatus = {
+    isOpened: false,
+    pages: [],
+  };
+  const swrResponse = useStaticSWR<EmptyTrashModalStatus, Error>('emptyTrashModalStatus', status, { fallbackData: initialData });
+
+  return {
+    ...swrResponse,
+    open: (
+        pages?: IPageToDeleteWithMeta[],
+        opts?: IEmptyTrashModalOption,
+    ) => swrResponse.mutate({
+      isOpened: true, pages, opts,
+    }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};
+
 /*
 /*
 * PageDuplicateModal
 * PageDuplicateModal
 */
 */
@@ -222,32 +268,32 @@ export const usePagePresentationModal = (
 
 
 
 
 /*
 /*
- * LegacyPrivatePagesMigrationModal
+ * PrivateLegacyPagesMigrationModal
  */
  */
 
 
 export type ILegacyPrivatePage = { pageId: string, path: string };
 export type ILegacyPrivatePage = { pageId: string, path: string };
 
 
-export type LegacyPrivatePagesMigrationModalSubmitedHandler = (pages: ILegacyPrivatePage[], isRecursively?: boolean) => void;
+export type PrivateLegacyPagesMigrationModalSubmitedHandler = (pages: ILegacyPrivatePage[], isRecursively?: boolean) => void;
 
 
-type LegacyPrivatePagesMigrationModalStatus = {
+type PrivateLegacyPagesMigrationModalStatus = {
   isOpened: boolean,
   isOpened: boolean,
   pages?: ILegacyPrivatePage[],
   pages?: ILegacyPrivatePage[],
-  onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler,
+  onSubmited?: PrivateLegacyPagesMigrationModalSubmitedHandler,
 }
 }
 
 
-type LegacyPrivatePagesMigrationModalStatusUtils = {
-  open(pages: ILegacyPrivatePage[], onSubmited?: LegacyPrivatePagesMigrationModalSubmitedHandler): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
-  close(): Promise<LegacyPrivatePagesMigrationModalStatus | undefined>,
+type PrivateLegacyPagesMigrationModalStatusUtils = {
+  open(pages: ILegacyPrivatePage[], onSubmited?: PrivateLegacyPagesMigrationModalSubmitedHandler): Promise<PrivateLegacyPagesMigrationModalStatus | undefined>,
+  close(): Promise<PrivateLegacyPagesMigrationModalStatus | undefined>,
 }
 }
 
 
-export const useLegacyPrivatePagesMigrationModal = (
-    status?: LegacyPrivatePagesMigrationModalStatus,
-): SWRResponse<LegacyPrivatePagesMigrationModalStatus, Error> & LegacyPrivatePagesMigrationModalStatusUtils => {
-  const initialData: LegacyPrivatePagesMigrationModalStatus = {
+export const usePrivateLegacyPagesMigrationModal = (
+    status?: PrivateLegacyPagesMigrationModalStatus,
+): SWRResponse<PrivateLegacyPagesMigrationModalStatus, Error> & PrivateLegacyPagesMigrationModalStatusUtils => {
+  const initialData: PrivateLegacyPagesMigrationModalStatus = {
     isOpened: false,
     isOpened: false,
     pages: [],
     pages: [],
   };
   };
-  const swrResponse = useStaticSWR<LegacyPrivatePagesMigrationModalStatus, Error>('legacyPrivatePagesMigrationModal', status, { fallbackData: initialData });
+  const swrResponse = useStaticSWR<PrivateLegacyPagesMigrationModalStatus, Error>('privateLegacyPagesMigrationModal', status, { fallbackData: initialData });
 
 
   return {
   return {
     ...swrResponse,
     ...swrResponse,

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

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

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

@@ -223,3 +223,13 @@
     }
     }
   }
   }
 }
 }
+
+@mixin count-badge($color, $bg-color, $min-width: initial) {
+  min-width: $min-width;
+  padding: 0.1rem 0.5rem;
+  font-family: $font-family-monospace;
+  font-size: 12px;
+  font-weight: 500;
+  color: $color;
+  background-color: $bg-color;
+}

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

@@ -115,7 +115,6 @@
 
 
   //Modals
   //Modals
   .modal-open {
   .modal-open {
-    position: fixed;
     width: 100%;
     width: 100%;
     padding-right: 0 !important;
     padding-right: 0 !important;
   }
   }

+ 0 - 6
packages/app/src/styles/_page-tree.scss

@@ -48,12 +48,6 @@ $grw-pagetree-item-padding-left: 10px;
       &:hover {
       &:hover {
         display: none;
         display: none;
       }
       }
-
-      .grw-count-badge {
-        min-width: 28px;
-        padding: 0.1rem 0.5rem;
-        font-size: 12px;
-      }
     }
     }
   }
   }
 
 

+ 3 - 0
packages/app/src/styles/_vendor.scss

@@ -27,3 +27,6 @@
 
 
 // import SimpleBar styles
 // import SimpleBar styles
 @import '~simplebar/dist/simplebar.min.css';
 @import '~simplebar/dist/simplebar.min.css';
+
+// Emoji-mart style
+@import '~emoji-mart/css/emoji-mart.css';

+ 5 - 9
packages/app/src/styles/theme/_apply-colors-dark.scss

@@ -277,20 +277,17 @@ ul.pagination {
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
     @include override-list-group-item-for-pagetree(
-      $gray-200,
-      $bgcolor-sidebar-list-group,
       $gray-200,
       $gray-200,
       lighten($bgcolor-sidebar-context, 8%),
       lighten($bgcolor-sidebar-context, 8%),
+      lighten($bgcolor-sidebar-context, 15%),
+      $gray-500,
       $gray-200,
       $gray-200,
-      lighten($bgcolor-sidebar-context, 15%)
+      lighten($bgcolor-sidebar-context, 18%),
+      lighten($bgcolor-sidebar-context, 24%)
     );
     );
     .grw-pagetree-triangle-btn {
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($secondary, $gray-200);
       @include button-outline-svg-icon-variant($secondary, $gray-200);
     }
     }
-    .grw-count-badge {
-      color: $gray-400;
-      background: lighten($bgcolor-sidebar-context, 15%);
-    }
     .btn-page-item-control {
     .btn-page-item-control {
       @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
       @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);
       @include hover() {
       @include hover() {
@@ -482,8 +479,7 @@ ul.pagination {
 */
 */
 .grw-side-contents-sticky-container {
 .grw-side-contents-sticky-container {
   .grw-count-badge {
   .grw-count-badge {
-    color: $gray-400;
-    background: $gray-700;
+    @include count-badge($gray-400, $gray-700);
   }
   }
 
 
   .grw-border-vr {
   .grw-border-vr {

+ 8 - 12
packages/app/src/styles/theme/_apply-colors-light.scss

@@ -182,20 +182,17 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
   // Pagetree
   // Pagetree
   .grw-pagetree {
   .grw-pagetree {
     @include override-list-group-item-for-pagetree(
     @include override-list-group-item-for-pagetree(
-      $color-list,
-      $bgcolor-sidebar-list-group,
-      $color-list-hover,
-      $bgcolor-list-hover,
-      $color-list-active,
-      $bgcolor-list-active
+      $color-sidebar-context,
+      darken($bgcolor-sidebar-context, 5%),
+      darken($bgcolor-sidebar-context, 12%),
+      lighten($color-sidebar-context, 10%),
+      lighten($color-sidebar-context, 8%),
+      darken($bgcolor-sidebar-context, 15%),
+      darken($bgcolor-sidebar-context, 24%)
     );
     );
     .grw-pagetree-triangle-btn {
     .grw-pagetree-triangle-btn {
       @include button-outline-svg-icon-variant($gray-400, $primary);
       @include button-outline-svg-icon-variant($gray-400, $primary);
     }
     }
-    .grw-count-badge {
-      color: $gray-500;
-      background: $gray-200;
-    }
   }
   }
   .private-legacy-pages-link {
   .private-legacy-pages-link {
     &:hover {
     &:hover {
@@ -358,8 +355,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
 */
 */
 .grw-side-contents-sticky-container {
 .grw-side-contents-sticky-container {
   .grw-count-badge {
   .grw-count-badge {
-    color: $primary;
-    background: $gray-200;
+    @include count-badge($gray-600, $gray-200);
   }
   }
 
 
   .grw-border-vr {
   .grw-border-vr {

+ 8 - 0
packages/app/src/styles/theme/_apply-colors.scss

@@ -45,6 +45,7 @@ $nav-tabs-link-active-border-color: $bordercolor-nav-tabs-active;
 @import 'reboot-bootstrap-theme-colors';
 @import 'reboot-bootstrap-theme-colors';
 @import 'reboot-bootstrap-nav';
 @import 'reboot-bootstrap-nav';
 @import 'reboot-toastr-colors';
 @import 'reboot-toastr-colors';
+@import '~emoji-mart/css/emoji-mart'; // Emoji-mart style
 
 
 // determine variables with bootstrap function (These variables can be used after importing bootstrap above)
 // determine variables with bootstrap function (These variables can be used after importing bootstrap above)
 $color-modal-header: color-yiq($primary) !default;
 $color-modal-header: color-yiq($primary) !default;
@@ -700,3 +701,10 @@ mark.rbt-highlight-text {
   height: 7px;
   height: 7px;
   background-color: $primary;
   background-color: $primary;
 }
 }
+
+/*
+Emoji picker modal
+*/
+.emoji-picker-modal {
+  background-color: transparent !important;
+}

+ 0 - 17
packages/app/src/styles/theme/christmas.scss

@@ -179,21 +179,4 @@ html[dark] {
       @include btn-page-editor-mode-manager(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
       @include btn-page-editor-mode-manager(darken($subthemecolor, 15%), lighten($subthemecolor, 35%), lighten($subthemecolor, 45%));
     }
     }
   }
   }
-
-  /*
- * GROWI Sidebar
- */
-  .grw-sidebar {
-    // Pagetree
-    .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        $bgcolor-list-hover
-      );
-    }
-  }
 }
 }

+ 1 - 1
packages/app/src/styles/theme/default.scss

@@ -170,7 +170,7 @@ html[dark] {
   $color-resize-button-hover: white;
   $color-resize-button-hover: white;
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   $bgcolor-resize-button-hover: darken($bgcolor-resize-button, 5%);
   // Sidebar contents
   // Sidebar contents
-  $bgcolor-sidebar-context: lighten($bgcolor-global, 10%);
+  $bgcolor-sidebar-context: lighten($bgcolor-global, 8%);
   $color-sidebar-context: $color-global;
   $color-sidebar-context: $color-global;
   // Sidebar list group
   // Sidebar list group
   $bgcolor-sidebar-list-group: #1c2a3e; // optional
   $bgcolor-sidebar-list-group: #1c2a3e; // optional

+ 0 - 17
packages/app/src/styles/theme/future.scss

@@ -104,21 +104,4 @@ html[dark] {
     color: #95abba;
     color: #95abba;
     background-color: #1f1f22;
     background-color: #1f1f22;
   }
   }
-
-  /*
- * GROWI Sidebar
- */
-  .grw-sidebar {
-    // Pagetree
-    .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        lighten($bgcolor-list-hover, 5%)
-      );
-    }
-  }
 }
 }

+ 0 - 8
packages/app/src/styles/theme/island.scss

@@ -123,14 +123,6 @@ html[dark] {
   .grw-sidebar {
   .grw-sidebar {
     // Pagetree
     // Pagetree
     .grw-pagetree {
     .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        lighten($bgcolor-list-hover, 5%)
-      );
       .grw-pagetree-triangle-btn {
       .grw-pagetree-triangle-btn {
         @include button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
         @include button-outline-svg-icon-variant($gray-400, $bgcolor-sidebar);
       }
       }

+ 0 - 17
packages/app/src/styles/theme/kibela.scss

@@ -107,21 +107,4 @@ html[dark] {
       @include btn-page-editor-mode-manager(darken($primary, 15%), lighten($primary, 45%), lighten($primary, 50%));
       @include btn-page-editor-mode-manager(darken($primary, 15%), lighten($primary, 45%), lighten($primary, 50%));
     }
     }
   }
   }
-
-  /*
- * GROWI Sidebar
- */
-  .grw-sidebar {
-    // Pagetree
-    .grw-pagetree {
-      @include override-list-group-item-for-pagetree(
-        $color-list,
-        $bgcolor-sidebar-list-group,
-        $color-list-hover,
-        $bgcolor-list-hover,
-        $color-list-active,
-        lighten($bgcolor-list-active, 55%)
-      );
-    }
-  }
 }
 }

+ 24 - 8
packages/app/src/styles/theme/mixins/_list-group.scss

@@ -18,14 +18,7 @@
   }
   }
 }
 }
 
 
-@mixin override-list-group-item-for-pagetree(
-  $color,
-  $bgcolor,
-  $color-hover: $color,
-  $bgcolor-hover: $bgcolor,
-  $color-active: $color,
-  $bgcolor-active: $bgcolor
-) {
+@mixin override-list-group-item-for-pagetree($color, $bgcolor-hover, $bgcolor-active, $btn-color, $btn-color-hover, $btn-bgcolor-hover, $btn-bgcolor-active) {
   .grw-pagetree-is-over {
   .grw-pagetree-is-over {
     background: $bgcolor-hover;
     background: $bgcolor-hover;
   }
   }
@@ -34,6 +27,24 @@
     background-color: transparent;
     background-color: transparent;
     border-color: $border-color-global;
     border-color: $border-color-global;
 
 
+    .grw-count-badge {
+      @include count-badge($btn-color, $bgcolor-hover, 28px);
+    }
+
+    .btn.btn-page-item-control {
+      color: $btn-color;
+      background-color: transparent;
+      @include hover() {
+        color: $btn-color-hover;
+        background-color: $btn-bgcolor-hover;
+      }
+      &:not(:disabled):not(.disabled):active,
+      &:not(:disabled):not(.disabled).active {
+        color: $btn-color-hover;
+        background-color: $btn-bgcolor-active;
+      }
+    }
+
     &.grw-pagetree-current-page-item {
     &.grw-pagetree-current-page-item {
       background: $bgcolor-hover;
       background: $bgcolor-hover;
     }
     }
@@ -46,5 +57,10 @@
         background-color: $bgcolor-active;
         background-color: $bgcolor-active;
       }
       }
     }
     }
+    .grw-pagetree-title-anchor {
+      .grw-sidebar-text-muted {
+        color: rgba(desaturate($color, 50%), 0.6);
+      }
+    }
   }
   }
 }
 }

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio