Просмотр исходного кода

Merge pull request #7803 from weseek/master

Release v6.1.5
Ryoji Shimizu 2 лет назад
Родитель
Сommit
cfb7c4d6d4
100 измененных файлов с 961 добавлено и 377 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug-report.md
  2. 2 2
      .github/workflows/release-slackbot-proxy.yml
  3. 5 3
      .github/workflows/release.yml
  4. 1 0
      apps/app/next.config.js
  5. 16 12
      apps/app/package.json
  6. 11 0
      apps/app/public/images/icons/editor/attachment.svg
  7. 3 1
      apps/app/public/static/locales/en_US/commons.json
  8. 6 0
      apps/app/public/static/locales/en_US/translation.json
  9. 6 1
      apps/app/public/static/locales/ja_JP/commons.json
  10. 6 0
      apps/app/public/static/locales/ja_JP/translation.json
  11. 6 1
      apps/app/public/static/locales/zh_CN/commons.json
  12. 6 0
      apps/app/public/static/locales/zh_CN/translation.json
  13. 0 0
      apps/app/resource/locales/en_US/admin/userInvitation.ejs
  14. 0 0
      apps/app/resource/locales/en_US/admin/userResetPassword.ejs
  15. 0 0
      apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs
  16. 1 1
      apps/app/resource/locales/en_US/notifications/comment.ejs
  17. 0 0
      apps/app/resource/locales/en_US/notifications/notActiveUser.ejs
  18. 1 1
      apps/app/resource/locales/en_US/notifications/pageCreate.ejs
  19. 1 1
      apps/app/resource/locales/en_US/notifications/pageDelete.ejs
  20. 1 1
      apps/app/resource/locales/en_US/notifications/pageEdit.ejs
  21. 1 1
      apps/app/resource/locales/en_US/notifications/pageLike.ejs
  22. 1 1
      apps/app/resource/locales/en_US/notifications/pageMove.ejs
  23. 0 0
      apps/app/resource/locales/en_US/notifications/passwordReset.ejs
  24. 0 0
      apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs
  25. 0 0
      apps/app/resource/locales/en_US/notifications/userActivation.ejs
  26. 1 1
      apps/app/resource/locales/en_US/welcome.md
  27. 0 0
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  28. 0 0
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  29. 0 0
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  30. 9 0
      apps/app/resource/locales/ja_JP/notifications/comment.ejs
  31. 0 0
      apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs
  32. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs
  33. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageCreate.txt
  34. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs
  35. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageDelete.txt
  36. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs
  37. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageEdit.txt
  38. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.ejs
  39. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageLike.txt
  40. 5 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.ejs
  41. 0 0
      apps/app/resource/locales/ja_JP/notifications/pageMove.txt
  42. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs
  43. 0 0
      apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs
  44. 0 0
      apps/app/resource/locales/ja_JP/notifications/userActivation.ejs
  45. 0 0
      apps/app/resource/locales/zh_CN/admin/userInvitation.ejs
  46. 0 0
      apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs
  47. 0 0
      apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs
  48. 1 1
      apps/app/resource/locales/zh_CN/notifications/comment.ejs
  49. 0 0
      apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs
  50. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs
  51. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs
  52. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs
  53. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageLike.ejs
  54. 1 1
      apps/app/resource/locales/zh_CN/notifications/pageMove.ejs
  55. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs
  56. 0 0
      apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs
  57. 0 0
      apps/app/resource/locales/zh_CN/notifications/userActivation.ejs
  58. 11 1
      apps/app/src/client/services/renderer/renderer.tsx
  59. 15 11
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  60. 9 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  61. 103 10
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  62. 2 0
      apps/app/src/components/Layout/BasicLayout.tsx
  63. 11 2
      apps/app/src/components/Me/BasicInfoSettings.tsx
  64. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  65. 10 11
      apps/app/src/components/Page/TagEditModal.tsx
  66. 4 3
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  67. 7 57
      apps/app/src/components/PageAttachment.tsx
  68. 65 48
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  69. 1 1
      apps/app/src/components/PageAttachment/PageAttachmentList.tsx
  70. 31 31
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  71. 0 17
      apps/app/src/components/ReactMarkdownComponents/NextLink.tsx
  72. 7 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss
  73. 84 0
      apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx
  74. 1 1
      apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx
  75. 20 4
      apps/app/src/components/SearchPage.tsx
  76. 14 5
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  77. 3 2
      apps/app/src/components/Sidebar/SidebarNav.tsx
  78. 197 56
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  79. 9 15
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  80. 5 8
      apps/app/src/components/TemplateModal/use-formatter.tsx
  81. 1 6
      apps/app/src/components/User/Username.tsx
  82. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  83. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  84. 4 4
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  85. 2 2
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  86. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/index.ts
  87. 1 0
      apps/app/src/features/growi-plugin/client/components/Admin/index.ts
  88. 1 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  89. 1 0
      apps/app/src/features/growi-plugin/client/components/index.ts
  90. 24 0
      apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx
  91. 0 0
      apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts
  92. 0 1
      apps/app/src/features/growi-plugin/components/index.ts
  93. 14 11
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  94. 5 0
      apps/app/src/features/growi-plugin/server/consts/index.ts
  95. 157 0
      apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts
  96. 17 28
      apps/app/src/features/growi-plugin/server/models/growi-plugin.ts
  97. 0 0
      apps/app/src/features/growi-plugin/server/models/index.ts
  98. 0 0
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  99. 4 2
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  100. 2 2
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

+ 1 - 1
.github/ISSUE_TEMPLATE/bug-report.md

@@ -1,7 +1,7 @@
 ---
 name: Bug report
 about: Create a report to help us improve
-labels: ['phase/new']
+labels: ['0️⃣ phase/new']
 ---
 
 Environment

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

@@ -113,7 +113,7 @@ jobs:
 
     - name: Bump versions for next RC
       run: |
-        yarn bump-versions:slackbot-proxy
+        turbo run version --filter=@growi/slackbot-proxy -- --prerelease
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
@@ -135,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: flag/exclude-from-changelog
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

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

@@ -35,7 +35,8 @@ jobs:
 
     - name: Bump versions
       run: |
-        yarn bump-versions:patch
+        turbo run version --filter=@growi/app -- --patch
+        yarn upgrade --scope=@growi
         sh ./apps/app/bin/github-actions/update-readme.sh
 
     - name: Retrieve information from package.json
@@ -97,8 +98,9 @@ jobs:
 
     - name: Bump versions for next RC
       run: |
-        yarn bump-versions:rc
-        yarn bump-versions:slackbot-proxy
+        turbo run version --filter=@growi/app -- --prepatch
+        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
+        yarn upgrade --scope=@growi
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0

+ 1 - 0
apps/app/next.config.js

@@ -30,6 +30,7 @@ const getTranspilePackages = () => {
     'hastscript',
     'html-void-elements',
     'is-absolute-url',
+    'is-plain-obj',
     'longest-streak',
     'micromark',
     'property-information',

+ 16 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.4",
+  "version": "6.1.5-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -46,7 +46,8 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
-    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config"
+    "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
@@ -63,14 +64,16 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.4",
-    "@growi/hackmd": "^6.1.4",
-    "@growi/preset-themes": "^6.1.4",
-    "@growi/remark-attachment-refs": "^6.1.4",
-    "@growi/remark-drawio": "^6.1.4",
-    "@growi/remark-growi-directive": "^6.1.4",
-    "@growi/remark-lsx": "^6.1.4",
-    "@growi/slack": "^6.1.4",
+    "@growi/core": "link:../../packages/core",
+    "@growi/hackmd": "link:../../packages/hackmd",
+    "@growi/pluginkit": "link:../../packages/pluginkit",
+    "@growi/preset-templates": "link:../../packages/preset-templates",
+    "@growi/preset-themes": "link:../../packages/preset-themes",
+    "@growi/remark-attachment-refs": "link:../../packages/remark-attachment-refs",
+    "@growi/remark-drawio": "link:../../packages/remark-drawio",
+    "@growi/remark-growi-directive": "link:../../packages/remark-growi-directive",
+    "@growi/remark-lsx": "link:../../packages/remark-lsx",
+    "@growi/slack": "link:../../packages/slack",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
@@ -185,6 +188,7 @@
     "remark-math": "^5.1.1",
     "remark-toc": "^8.0.1",
     "remark-wiki-link": "^1.0.4",
+    "sanitize-filename": "^1.6.3",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",
@@ -208,8 +212,8 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.4",
-    "@growi/ui": "^6.1.4",
+    "@growi/presentation": "link:../../packages/presentation",
+    "@growi/ui": "link:../../packages/ui",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",

+ 11 - 0
apps/app/public/images/icons/editor/attachment.svg

@@ -0,0 +1,11 @@
+<svg id="group_5327" data-name="group 5327" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="28.093" viewBox="0 0 24 28.093">
+  <defs>
+    <clipPath id="clip-path">
+      <rect id="rectangle_1922" data-name="rectangle 1922" width="24" height="28.093" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    </clipPath>
+  </defs>
+  <g id="group_5319" data-name="group 5319" clip-path="url(#clip-path)">
+    <path id="pass_4850" data-name="pass 4850" d="M20.6,16.976l-.651,1.17a4.292,4.292,0,0,1-.828,1.031V21H13.7v5.619H1.479V1.479H19.123v2a1.932,1.932,0,0,1,.2.094l1.282.714V0H0V28.093H15.18v0h0L20.6,22.48l-.006-.006H20.6ZM15.18,25.957V22.474h3.369Z" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+    <path id="pass_4851" data-name="pass 4851" d="M203.477,65.236a.648.648,0,0,1,.509.96l-5.117,9.2a3.483,3.483,0,0,0,1.537,4.427,3.8,3.8,0,0,0,3.11.3,3.293,3.293,0,0,0,1.744-1.212l4.784-8.6-3.846-2.14L201.727,76.2c0,.007-.36.684.2,1a.825.825,0,0,0,.689.1.9.9,0,0,0,.461-.417l3.591-6.454,1.131.629-3.592,6.454a2.176,2.176,0,0,1-1.158,1.008,2.074,2.074,0,0,1-1.752-.19,1.832,1.832,0,0,1-.973-1.509,2.366,2.366,0,0,1,.271-1.248l4.786-8.6a.647.647,0,0,1,.88-.251l4.978,2.77a.647.647,0,0,1,.251.88l-5.1,9.163a4.531,4.531,0,0,1-2.469,1.811,5.062,5.062,0,0,1-4.146-.4,4.767,4.767,0,0,1-2.039-6.188l5.117-9.2a.648.648,0,0,1,.622-.33" transform="translate(-187.572 -62.019)" fill="#6c757d" stroke="rgba(0,0,0,0)" stroke-width="1"/>
+  </g>
+</svg>

+ 3 - 1
apps/app/public/static/locales/en_US/commons.json

@@ -6,6 +6,7 @@
   "Reset": "Reset",
   "Sign out": "Logout",
   "New": "New",
+  "Delete": "Delete",
 
   "meta": {
     "display_name": "English"
@@ -23,7 +24,8 @@
   "alert": {
     "siteUrl_is_not_set": "'Site URL' is NOT set. Set it from the {{link}}",
     "please_enable_mailer": "Please setup mailer first.",
-    "password_reset_please_enable_mailer": "Please setup mailer first."
+    "password_reset_please_enable_mailer": "Please setup mailer first.",
+    "email_is_already_in_use": "The email address is already in use."
   },
   "headers": {
     "app_settings": "App Settings"

+ 6 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -820,5 +820,11 @@
     "tags_input": {
       "tag_name": "tag name"
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "Delete attachment?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "The attachment could not be found"
   }
 }

+ 6 - 1
apps/app/public/static/locales/ja_JP/commons.json

@@ -6,6 +6,10 @@
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",
+  "Send": "送信",
+  "Close": "閉じる",
+  "Done": "完了",
+  "Delete": "削除",
   "meta": {
     "display_name": "日本語"
   },
@@ -22,7 +26,8 @@
   "alert": {
     "siteUrl_is_not_set": "'サイトURL' が設定されていません。{{link}} から設定してください。",
     "please_enable_mailer": "メール認証を有効にするには、メール設定を完了させてください。",
-    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。"
+    "password_reset_please_enable_mailer": "パスワード再設定を有効にするには、メール設定を完了させてください。",
+    "email_is_already_in_use": "そのメールアドレスは既に使用されています。"
   },
   "headers": {
     "app_settings": "アプリ設定"

+ 6 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -853,5 +853,11 @@
     "tags_input": {
       "tag_name": "タグ名"
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "アタッチメントを削除しますか?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "アタッチメントが見つかりません"
   }
 }

+ 6 - 1
apps/app/public/static/locales/zh_CN/commons.json

@@ -6,6 +6,10 @@
   "Reset": "重启",
 	"Sign out": "退出",
   "New": "新建",
+  "Send": "发送",
+  "Close": "关闭",
+  "Done": "完成",
+  "Delete": "删除",
 
   "meta": {
     "display_name": "简体中文"
@@ -23,7 +27,8 @@
   "alert": {
     "siteUrl_is_not_set": "主页URL未设置,通过 {{link}} 设置",
     "please_enable_mailer": "请先设置邮件程序。",
-    "password_reset_please_enable_mailer": "请先设置邮件程序。"
+    "password_reset_please_enable_mailer": "请先设置邮件程序。",
+    "email_is_already_in_use": "这个电子邮件地址已经在使用了。"
   },
   "headers": {
     "app_settings": "系统设置"

+ 6 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -823,5 +823,11 @@
     "tags_input": {
       "tag_name": "标签名称"
     }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "你想删除一个附件吗?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "没有找到附件"
   }
 }

+ 0 - 0
apps/app/resource/locales/en_US/admin/userInvitation.txt → apps/app/resource/locales/en_US/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/en_US/admin/userResetPassword.txt → apps/app/resource/locales/en_US/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/en_US/admin/userWaitingActivation.txt → apps/app/resource/locales/en_US/admin/userWaitingActivation.ejs


+ 1 - 1
apps/app/resource/locales/en_US/notifications/comment.txt → apps/app/resource/locales/en_US/notifications/comment.ejs

@@ -6,4 +6,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/en_US/notifications/notActiveUser.txt → apps/app/resource/locales/en_US/notifications/notActiveUser.ejs


+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageCreate.txt → apps/app/resource/locales/en_US/notifications/pageCreate.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageDelete.txt → apps/app/resource/locales/en_US/notifications/pageDelete.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageEdit.txt → apps/app/resource/locales/en_US/notifications/pageEdit.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageLike.txt → apps/app/resource/locales/en_US/notifications/pageLike.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageMove.txt → apps/app/resource/locales/en_US/notifications/pageMove.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/en_US/notifications/passwordReset.txt → apps/app/resource/locales/en_US/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/en_US/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/en_US/notifications/userActivation.txt → apps/app/resource/locales/en_US/notifications/userActivation.ejs


+ 1 - 1
apps/app/resource/locales/en_US/welcome.md

@@ -60,5 +60,5 @@ We can display the content list using a table and `$lsx`.
 
 <a href="https://growi-slackin.weseek.co.jp/"><img src="https://growi-slackin.weseek.co.jp/badge.svg"></a>
 
-We welcome newcomers joining our slack channel to help improve Growi.
+We welcome newcomers joining our slack channel to help improve GROWI.
 In addition to discussing development, we are also happy to answer your questions when you join.

+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userInvitation.txt → apps/app/resource/locales/ja_JP/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userResetPassword.txt → apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/admin/userWaitingActivation.txt → apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs


+ 9 - 0
apps/app/resource/locales/ja_JP/notifications/comment.ejs

@@ -0,0 +1,9 @@
+<%- username %> が <%- path %> にコメントしました。
+
+----------------------
+
+<%- comment %>
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/notActiveUser.txt → apps/app/resource/locales/ja_JP/notifications/notActiveUser.ejs


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageCreate.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を作成しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageCreate.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageDelete.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を削除しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageDelete.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageEdit.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を編集しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageEdit.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageLike.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- path %> を「いいね」しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageLike.txt


+ 5 - 0
apps/app/resource/locales/ja_JP/notifications/pageMove.ejs

@@ -0,0 +1,5 @@
+<%- username %> が <%- oldPath %> を <%- newPath %> に移動(名前を変更)しました。
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/pageMove.txt


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/passwordReset.txt → apps/app/resource/locales/ja_JP/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/ja_JP/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/ja_JP/notifications/userActivation.txt → apps/app/resource/locales/ja_JP/notifications/userActivation.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userInvitation.txt → apps/app/resource/locales/zh_CN/admin/userInvitation.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userResetPassword.txt → apps/app/resource/locales/zh_CN/admin/userResetPassword.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/admin/userWaitingActivation.txt → apps/app/resource/locales/zh_CN/admin/userWaitingActivation.ejs


+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/comment.txt → apps/app/resource/locales/zh_CN/notifications/comment.ejs

@@ -6,4 +6,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/notActiveUser.txt → apps/app/resource/locales/zh_CN/notifications/notActiveUser.ejs


+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageCreate.txt → apps/app/resource/locales/zh_CN/notifications/pageCreate.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageDelete.txt → apps/app/resource/locales/zh_CN/notifications/pageDelete.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/en_US/notifications/pageEdit.txt → apps/app/resource/locales/zh_CN/notifications/pageEdit.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageLike.txt → apps/app/resource/locales/zh_CN/notifications/pageLike.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 1 - 1
apps/app/resource/locales/zh_CN/notifications/pageMove.txt → apps/app/resource/locales/zh_CN/notifications/pageMove.ejs

@@ -2,4 +2,4 @@
 
 ----------------------
 
-Growi: <%- appTitle %>
+GROWI: <%- appTitle %>

+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/passwordReset.txt → apps/app/resource/locales/zh_CN/notifications/passwordReset.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.txt → apps/app/resource/locales/zh_CN/notifications/passwordResetSuccessful.ejs


+ 0 - 0
apps/app/resource/locales/zh_CN/notifications/userActivation.txt → apps/app/resource/locales/zh_CN/notifications/userActivation.ejs


+ 11 - 1
apps/app/src/client/services/renderer/renderer.tsx

@@ -14,9 +14,9 @@ import math from 'remark-math';
 import deepmerge from 'ts-deepmerge';
 import type { Pluggable } from 'unified';
 
-
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
+import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
@@ -25,6 +25,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
+import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
@@ -61,6 +62,7 @@ export const generateViewOptions = (
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
@@ -77,6 +79,7 @@ export const generateViewOptions = (
       commonSanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
@@ -109,6 +112,7 @@ export const generateViewOptions = (
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -167,6 +171,7 @@ export const generateSimpleViewOptions = (
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
@@ -187,6 +192,7 @@ export const generateSimpleViewOptions = (
       commonSanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
@@ -211,6 +217,7 @@ export const generateSimpleViewOptions = (
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -244,6 +251,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     drawio.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
@@ -260,6 +268,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       commonSanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
+      attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
@@ -285,6 +294,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
   }
 
   if (config.isEnabledXssPrevention) {

+ 15 - 11
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -7,9 +7,10 @@ import { IUserGroupHasId } from '~/interfaces/user';
 
 type Props = {
   userGroup: IUserGroupHasId,
+  parentUserGroup?: IUserGroupHasId,
   selectableParentUserGroups?: IUserGroupHasId[],
   submitButtonLabel: string;
-  onSubmit?: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void> | void
+  onSubmit: (targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>) => Promise<void>
 };
 
 export const UserGroupForm: FC<Props> = (props: Props) => {
@@ -17,16 +18,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
+    userGroup, parentUserGroup, selectableParentUserGroups, submitButtonLabel, onSubmit,
   } = props;
-
   /*
    * State
    */
-  const [currentName, setName] = useState(userGroup != null ? userGroup.name : '');
-  const [currentDescription, setDescription] = useState(userGroup != null ? userGroup.description : '');
-  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(userGroup?.parent as IUserGroupHasId);
-
+  const [currentName, setName] = useState<string>(userGroup.name);
+  const [currentDescription, setDescription] = useState<string>(userGroup.description);
+  const [selectedParent, setSelectedParent] = useState<IUserGroupHasId | undefined>(parentUserGroup);
   /*
    * Function
    */
@@ -44,10 +43,15 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
     }
   }, [selectedParent, setSelectedParent]);
 
+  const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
+
+  const isChildUserGroup = parentUserGroup !== undefined;
+  const messageAtReleaseParentGroup = isChildUserGroup ? t('user_group_management.release_parent_group') : t('user_group_management.select_parent_group');
+
   return (
     <form onSubmit={(e) => {
       e.preventDefault();
-      onSubmit?.(props.userGroup, {
+      onSubmit(props.userGroup, {
         name: currentName,
         description: currentDescription,
         parent: selectedParent,
@@ -103,14 +107,14 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
               id="dropdownMenuButton"
               data-toggle="dropdown"
               className={`
-                btn btn-outline-secondary dropdown-toggle mb-3 ${selectableParentUserGroups != null && selectableParentUserGroups.length > 0 ? '' : 'disabled'}
+                btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
               `}
             >
-              {selectedParent?.name ?? t('user_group_management.select_parent_group')}
+              {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {
-                (selectableParentUserGroups != null && selectableParentUserGroups.length > 0) && (
+                isSelectableParentUserGroups && (
                   <>
                     {
                       selectableParentUserGroups.map(userGroup => (

+ 9 - 2
apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -21,9 +21,12 @@ import {
   useSWRxUserGroupPages, useSWRxUserGroupRelationList, useSWRxChildUserGroupList, useSWRxUserGroup,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups, useSWRxUserGroupRelations,
 } from '~/stores/user-group';
+import loggerFactory from '~/utils/logger';
 
 import styles from './UserGroupDetailPage.module.scss';
 
+const logger = loggerFactory('growi:services:AdminCustomizeContainer');
+
 const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
 const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
 
@@ -48,6 +51,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { userGroupId: currentUserGroupId } = props;
 
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
+
   const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
@@ -91,6 +95,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
+  const parentUserGroup = selectableParentUserGroups?.find(selectableParentUserGroup => selectableParentUserGroup._id === currentUserGroup?.parent);
   /*
    * Function
    */
@@ -135,9 +140,10 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     [t, updateUserGroup],
   );
 
-  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: Partial<IUserGroupHasId>): Promise<void> => {
-    if (typeof userGroupData?.parent === 'string') {
+  const onClickSubmitForm = useCallback(async(targetGroup: IUserGroupHasId, userGroupData: IUserGroupHasId) => {
+    if (typeof userGroupData.parent === 'string') {
       toastError(t('Something went wrong. Please try again.'));
+      logger.error('Something went wrong.');
       return;
     }
 
@@ -356,6 +362,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
       <div className="mt-4 form-box">
         <UserGroupForm
           userGroup={currentUserGroup}
+          parentUserGroup={parentUserGroup}
           selectableParentUserGroups={selectableParentUserGroups}
           submitButtonLabel={t('Update')}
           onSubmit={onClickSubmitForm}

+ 103 - 10
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -2,13 +2,15 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter, Tooltip,
 } from 'reactstrap';
 
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 class PasswordResetModal extends React.Component {
 
@@ -16,11 +18,15 @@ class PasswordResetModal extends React.Component {
     super(props);
 
     this.state = {
-      temporaryPassword: [],
+      temporaryPassword: '',
       isPasswordResetDone: false,
+      isEmailSent: false,
+      isEmailSending: false,
+      showTooltip: false,
     };
 
     this.resetPassword = this.resetPassword.bind(this);
+    this.onClickSendNewPasswordButton = this.onClickSendNewPasswordButton.bind(this);
   }
 
   async resetPassword() {
@@ -35,6 +41,44 @@ class PasswordResetModal extends React.Component {
     }
   }
 
+  renderButtons() {
+    const { t, isMailerSetup } = this.props;
+    const { isEmailSent, isEmailSending } = this.state;
+
+    return (
+      <>
+        <button type="submit" className={`btn ${isEmailSent ? 'btn-secondary' : 'btn-primary'}`}
+          onClick={this.onClickSendNewPasswordButton} disabled={!isMailerSetup || isEmailSending || isEmailSent}>
+          {isEmailSending && <i className='fa fa-spinner fa-pulse mx-2' />}
+          {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
+        </button>
+        <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>
+          {t('commons:Close')}
+        </button>
+      </>
+    );
+  }
+
+  renderAddress() {
+    const { t, isMailerSetup, userForPasswordResetModal } = this.props;
+
+    return (
+      <div className="d-flex col text-left ml-1 pl-0">
+        {!isMailerSetup ? (
+          <label className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+        ) : (
+          <>
+            <p className="mr-2">To:</p>
+            <div>
+              <p className="mb-0">{userForPasswordResetModal.username}</p>
+              <p className="mb-0">{userForPasswordResetModal.email}</p>
+            </div>
+          </>
+        )}
+      </div>
+    );
+  }
+
   renderModalBodyBeforeReset() {
     const { t, userForPasswordResetModal } = this.props;
 
@@ -53,6 +97,11 @@ class PasswordResetModal extends React.Component {
 
   returnModalBodyAfterReset() {
     const { t, userForPasswordResetModal } = this.props;
+    const { temporaryPassword, showPassword, showTooltip } = this.state;
+
+    const maskedPassword = showPassword
+      ? temporaryPassword
+      : '•'.repeat(temporaryPassword.length);
 
     return (
       <>
@@ -61,7 +110,28 @@ class PasswordResetModal extends React.Component {
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <p>
-          {t('user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('user_management.reset_password_modal.new_password')}:{' '}
+          <code>
+            <span
+              onMouseEnter={() => this.setState({ showPassword: true })}
+              onMouseLeave={() => this.setState({ showPassword: false })}
+            >
+              {showPassword ? temporaryPassword : maskedPassword}
+            </span>
+          </code>
+          <CopyToClipboard text={ temporaryPassword } onCopy={() => this.setState({ showTooltip: true })}>
+            <button id="copy-tooltip" type="button" className="btn btn-outline-secondary border-0">
+              <i className="fa fa-clone" aria-hidden="true"></i>
+            </button>
+          </CopyToClipboard>
+          <Tooltip
+            placement="right"
+            isOpen={showTooltip}
+            target="copy-tooltip"
+            toggle={() => this.setState({ showTooltip: false })}
+          >
+            {t('Copied!')}
+          </Tooltip>
         </p>
       </>
     );
@@ -77,15 +147,35 @@ class PasswordResetModal extends React.Component {
   }
 
   returnModalFooterAfterReset() {
-    const { t } = this.props;
-
     return (
-      <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>
-        {t('Close')}
-      </button>
+      <>
+        {this.renderAddress()}
+        {this.renderButtons()}
+      </>
     );
   }
 
+  async onClickSendNewPasswordButton() {
+
+    const {
+      userForPasswordResetModal,
+    } = this.props;
+
+    this.setState({ isEmailSending: true });
+
+    try {
+      await apiv3Put('/users/reset-password-email', { id: userForPasswordResetModal._id, newPassword: this.state.temporaryPassword });
+      this.setState({ isEmailSent: true });
+    }
+    catch (err) {
+      this.setState({ isEmailSent: false });
+      toastError(err);
+    }
+    finally {
+      this.setState({ isEmailSending: false });
+    }
+  }
+
 
   render() {
     const { t } = this.props;
@@ -109,7 +199,8 @@ class PasswordResetModal extends React.Component {
 
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
-  return <PasswordResetModal t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 
 /**
@@ -122,6 +213,8 @@ PasswordResetModal.propTypes = {
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   userForPasswordResetModal: PropTypes.object,
+  onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 
 };
 

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -10,6 +10,7 @@ import Sidebar from '../Sidebar';
 import { RawLayout } from './RawLayout';
 
 const AlertSiteUrlUndefined = dynamic(() => import('../AlertSiteUrlUndefined').then(mod => mod.AlertSiteUrlUndefined), { ssr: false });
+const DeleteAttachmentModal = dynamic(() => import('../PageAttachment/DeleteAttachmentModal').then(mod => mod.DeleteAttachmentModal), { ssr: false });
 const HotkeysManager = dynamic(() => import('../Hotkeys/HotkeysManager'), { ssr: false });
 // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
 const GrowiNavbarBottom = dynamic(() => import('../Navbar/GrowiNavbarBottom').then(mod => mod.GrowiNavbarBottom), { ssr: false });
@@ -56,6 +57,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <PageDeleteModal />
         <PageRenameModal />
         <PageAccessoriesModal />
+        <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
       </DndProvider>
 

+ 11 - 2
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -24,8 +24,17 @@ export const BasicInfoSettings = (): JSX.Element => {
       sync();
       toastSuccess(t('toaster.update_successed', { target: t('Basic Info'), ns: 'commons' }));
     }
-    catch (err) {
-      toastError(err);
+    catch (errs) {
+      const err = errs[0];
+      const message = err.message;
+      const code = err.code;
+
+      if (code === 'email-is-already-in-use') {
+        toastError(t('alert.email_is_already_in_use', { ns: 'commons' }));
+      }
+      else {
+        toastError(message);
+      }
     }
   };
 

+ 3 - 1
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -317,9 +317,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       else if (currentPathname != null) {
         router.push(currentPathname);
       }
+
+      mutateCurrentPage();
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, openDeleteModal, router]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     if (!isSharedPage) {

+ 10 - 11
apps/app/src/components/Page/TagEditModal.jsx → apps/app/src/components/Page/TagEditModal.tsx

@@ -1,18 +1,24 @@
 import React, { useState, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import TagsInput from './TagsInput';
 
-function TagEditModal(props) {
-  const [tags, setTags] = useState([]);
+type Props = {
+  tags: string[],
+  isOpen: boolean,
+  onClose?: () => void,
+  onTagsUpdated?: (tags: string[]) => Promise<void> | void,
+};
+
+function TagEditModal(props: Props): JSX.Element {
+  const [tags, setTags] = useState<string[]>([]);
   const { t } = useTranslation();
 
-  function onTagsUpdatedByTagsInput(tags) {
+  function onTagsUpdatedByTagsInput(tags: string[]) {
     setTags(tags);
   }
 
@@ -54,11 +60,4 @@ function TagEditModal(props) {
 
 }
 
-TagEditModal.propTypes = {
-  tags: PropTypes.array,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-  onTagsUpdated: PropTypes.func,
-};
-
 export default TagEditModal;

+ 4 - 3
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -9,7 +9,7 @@ import { unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import {
-  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage,
+  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage, useSWRMUTxCurrentPage,
 } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
@@ -33,11 +33,11 @@ export const TrashPageAlert = (): JSX.Element => {
   const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
-
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
@@ -55,13 +55,14 @@ export const TrashPageAlert = (): JSX.Element => {
       try {
         unlink(currentPagePath);
         router.push(`/${pageId}`);
+        mutateCurrentPage();
       }
       catch (err) {
         toastError(err);
       }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, openPutBackPageModal, pageId, pagePath, router]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

+ 7 - 57
apps/app/src/components/PageAttachment.tsx

@@ -6,9 +6,9 @@ import { IAttachmentHasId } from '@growi/core';
 
 import { useSWRxAttachments } from '~/stores/attachment';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
+import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
-import { DeleteAttachmentModal } from './PageAttachment/DeleteAttachmentModal';
 import { PageAttachmentList } from './PageAttachment/PageAttachmentList';
 import PaginationWrapper from './PaginationWrapper';
 
@@ -19,9 +19,6 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 
 const PageAttachment = (): JSX.Element => {
 
-  const { data: currentPage } = useSWRxCurrentPage();
-  const markdown = currentPage?.revision.body;
-
   // Static SWRs
   const { data: pageId } = useCurrentPageId();
   const { data: isGuestUser } = useIsGuestUser();
@@ -31,12 +28,12 @@ const PageAttachment = (): JSX.Element => {
 
   // States
   const [pageNumber, setPageNumber] = useState(1);
-  const [attachmentToDelete, setAttachmentToDelete] = useState<(IAttachmentHasId) | null>(null);
-  const [deleting, setDeleting] = useState(false);
-  const [deleteError, setDeleteError] = useState('');
 
   // SWRs
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const markdown = currentPage?.revision.body;
 
   // Custom hooks
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
@@ -57,29 +54,9 @@ const PageAttachment = (): JSX.Element => {
     setPageNumber(newPageNumber);
   }, []);
 
-  const onAttachmentDeleteClicked = useCallback((attachment) => {
-    setAttachmentToDelete(attachment);
-  }, []);
-
-  const onAttachmentDeleteClickedConfirmHandler = useCallback(async(attachment: IAttachmentHasId) => {
-    setDeleting(true);
-
-    try {
-      await remove({ attachment_id: attachment._id });
-
-      setAttachmentToDelete(null);
-      setDeleting(false);
-    }
-    catch {
-      setDeleteError('Something went wrong.');
-      setDeleting(false);
-    }
-  }, [remove]);
-
-  const onToggleHandler = useCallback(() => {
-    setAttachmentToDelete(null);
-    setDeleteError('');
-  }, []);
+  const onAttachmentDeleteClicked = useCallback((attachment: IAttachmentHasId) => {
+    openDeleteAttachmentModal(attachment, remove);
+  }, [openDeleteAttachmentModal, remove]);
 
   // Renderers
   const renderPageAttachmentList = useCallback(() => {
@@ -101,30 +78,6 @@ const PageAttachment = (): JSX.Element => {
     );
   }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
 
-  const renderDeleteAttachmentModal = useCallback(() => {
-    if (isPageAttachmentDisabled) {
-      return <></>;
-    }
-
-    if (dataAttachments == null || dataAttachments.attachments.length === 0 || attachmentToDelete == null) {
-      return <></>;
-    }
-
-    const isOpen = attachmentToDelete != null;
-
-    return (
-      <DeleteAttachmentModal
-        isOpen={isOpen}
-        toggle={onToggleHandler}
-        attachmentToDelete={attachmentToDelete}
-        deleting={deleting}
-        deleteError={deleteError}
-        onAttachmentDeleteClickedConfirm={onAttachmentDeleteClickedConfirmHandler}
-      />
-    );
-  // eslint-disable-next-line max-len
-  }, [attachmentToDelete, dataAttachments, deleteError, deleting, isPageAttachmentDisabled, onAttachmentDeleteClickedConfirmHandler, onToggleHandler]);
-
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {
       return <></>;
@@ -144,9 +97,6 @@ const PageAttachment = (): JSX.Element => {
   return (
     <div data-testid="page-attachment">
       {renderPageAttachmentList()}
-
-      {renderDeleteAttachmentModal()}
-
       {renderPaginationWrapper()}
     </div>
   );

+ 65 - 48
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -1,79 +1,97 @@
-/* eslint-disable react/prop-types */
-import React, { useCallback } from 'react';
+import React, {
+  useCallback, useMemo, useState,
+} from 'react';
 
-import { IAttachmentHasId } from '@growi/core';
+import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
 import {
-  Button,
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+import loggerFactory from '~/utils/logger';
+
 import { Username } from '../User/Username';
 
 import styles from './DeleteAttachmentModal.module.scss';
 
+const logger = loggerFactory('growi:attachmentDelete');
+
+const iconByFormat = (format: string): string => {
+  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+};
 
-function iconNameByFormat(format: string): string {
-  if (format.match(/image\/.+/i)) {
-    return 'icon-picture';
-  }
+export const DeleteAttachmentModal: React.FC = () => {
+  const [deleting, setDeleting] = useState<boolean>(false);
+  const [deleteError, setDeleteError] = useState<string>('');
 
-  return 'icon-doc';
-}
+  const { t } = useTranslation();
+  const { data: deleteAttachmentModal, close: closeDeleteAttachmentModal } = useDeleteAttachmentModal();
+  const isOpen = deleteAttachmentModal?.isOpened;
+  const attachment = deleteAttachmentModal?.attachment;
+  const remove = deleteAttachmentModal?.remove;
 
+  const toggleHandler = useCallback(() => {
+    closeDeleteAttachmentModal();
+    setDeleting(false);
+    setDeleteError('');
+  }, [closeDeleteAttachmentModal]);
 
-type Props = {
-  isOpen: boolean,
-  toggle: () => void,
-  attachmentToDelete: IAttachmentHasId | null,
-  deleting: boolean,
-  deleteError: string,
-  onAttachmentDeleteClickedConfirm?: (attachment: IAttachmentHasId) => Promise<void>,
-}
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    if (remove == null || attachment == null) {
+      return;
+    }
 
-export const DeleteAttachmentModal = (props: Props): JSX.Element => {
+    setDeleting(true);
 
-  const {
-    isOpen, toggle,
-    attachmentToDelete, deleting, deleteError,
-    onAttachmentDeleteClickedConfirm,
-  } = props;
+    try {
+      await remove({ attachment_id: attachment._id });
+      setDeleting(false);
+      closeDeleteAttachmentModal();
+      toastSuccess(`Delete ${attachment.originalName}`);
+    }
+    catch (err) {
+      setDeleting(false);
+      setDeleteError('Attachment could not be deleted.');
+      toastError(err);
+      logger.error(err);
+    }
+  }, [attachment, closeDeleteAttachmentModal, remove]);
 
-  const onDeleteConfirm = useCallback(() => {
-    if (attachmentToDelete == null || onAttachmentDeleteClickedConfirm == null) {
+  const attachmentFileFormat = useMemo(() => {
+    if (attachment == null) {
       return;
     }
-    onAttachmentDeleteClickedConfirm(attachmentToDelete);
-  }, [attachmentToDelete, onAttachmentDeleteClickedConfirm]);
 
-  const renderByFileFormat = useCallback((attachment) => {
     const content = (attachment.fileFormat.match(/image\/.+/i))
       // eslint-disable-next-line @next/next/no-img-element
       ? <img src={attachment.filePathProxied} alt="deleting image" />
       : '';
 
-
     return (
       <div className="attachment-delete-image">
         <p>
-          <i className={iconNameByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
         </p>
         <p>
-          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator}></Username>
+          uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>
         </p>
         {content}
       </div>
     );
-  }, []);
-
-  let deletingIndicator = <></>;
-  if (deleting) {
-    deletingIndicator = <div className="speeding-wheel-sm"></div>;
-  }
-  if (deleteError) {
-    deletingIndicator = <span>{deleteError}</span>;
-  }
+  }, [attachment]);
 
+  const deletingIndicator = useMemo(() => {
+    if (deleting) {
+      return <div className="speeding-wheel-sm"></div>;
+    }
+    if (deleteError) {
+      return <span>{deleteError}</span>;
+    }
+    return <></>;
+  }, [deleting, deleteError]);
 
   return (
     <Modal
@@ -83,11 +101,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
       aria-labelledby="contained-modal-title-lg"
       fade={false}
     >
-      <ModalHeader tag="h4" toggle={toggle} className="bg-danger text-light">
-        <span id="contained-modal-title-lg">Delete attachment?</span>
+      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light">
+        <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
       </ModalHeader>
       <ModalBody>
-        {renderByFileFormat(attachmentToDelete)}
+        {attachmentFileFormat}
       </ModalBody>
       <ModalFooter>
         <div className="mr-3 d-inline-block">
@@ -95,12 +113,11 @@ export const DeleteAttachmentModal = (props: Props): JSX.Element => {
         </div>
         <Button
           color="danger"
-          onClick={onDeleteConfirm}
+          onClick={onClickDeleteButtonHandler}
           disabled={deleting}
-        >Delete!
+        >{t('commons:Delete')}
         </Button>
       </ModalFooter>
     </Modal>
   );
-
 };

+ 1 - 1
apps/app/src/components/PageAttachment/PageAttachmentList.tsx

@@ -7,7 +7,7 @@ import { useTranslation } from 'next-i18next';
 type Props = {
   attachments: (IAttachmentHasId)[],
   inUse: { [id: string]: boolean },
-  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
+  onAttachmentDeleteClicked: (attachment: IAttachmentHasId) => void,
   isUserLoggedIn?: boolean,
 }
 

+ 31 - 31
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState } from 'react';
 
 import { HotTable } from '@handsontable/react';
 import Handsontable from 'handsontable';
@@ -7,7 +7,6 @@ import {
   Collapse,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
 import MarkdownTable from '~/client/models/MarkdownTable';
 import mtu from '~/components/PageEditor/MarkdownTableUtil';
@@ -19,6 +18,7 @@ import { MarkdownTableDataImportForm } from './MarkdownTableDataImportForm';
 
 import styles from './HandsontableModal.module.scss';
 import 'handsontable/dist/handsontable.full.min.css';
+import { debounce } from 'throttle-debounce';
 
 const DEFAULT_HOT_HEIGHT = 300;
 const MARKDOWNTABLE_TO_HANDSONTABLE_ALIGNMENT_SYMBOL_MAPPING = {
@@ -88,13 +88,37 @@ export const HandsontableModal = (): JSX.Element => {
   const [markdownTable, setMarkdownTable] = useState<MarkdownTable>(defaultMarkdownTable);
   const [markdownTableOnInit, setMarkdownTableOnInit] = useState<MarkdownTable>(defaultMarkdownTable);
   const [handsontableHeight, setHandsontableHeight] = useState<number>(DEFAULT_HOT_HEIGHT);
+  const [handsontableWidth, setHandsontableWidth] = useState<number>(0);
 
-  useEffect(() => {
+  const handleWindowExpandedChange = () => {
+    if (hotTableContainer != null) {
+      // Get the width and height of hotTableContainer
+      const { width, height } = hotTableContainer.getBoundingClientRect();
+      setHandsontableWidth(width);
+      setHandsontableHeight(height);
+    }
+  };
+
+  const debouncedHandleWindowExpandedChange = debounce(100, handleWindowExpandedChange);
+
+  const handleModalOpen = () => {
     const initTableInstance = table == null ? defaultMarkdownTable : table.clone();
     setMarkdownTable(table ?? defaultMarkdownTable);
     setMarkdownTableOnInit(initTableInstance);
-  // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [isOpened]);
+    debouncedHandleWindowExpandedChange();
+  };
+
+  const expandWindow = () => {
+    setIsWindowExpanded(true);
+    debouncedHandleWindowExpandedChange();
+  };
+
+  const contractWindow = () => {
+    setIsWindowExpanded(false);
+    // Set the height to the default value
+    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
+    debouncedHandleWindowExpandedChange();
+  };
 
   const markdownTableOption = {
     get latest() {
@@ -363,32 +387,6 @@ export const HandsontableModal = (): JSX.Element => {
     toggleDataImportArea();
   };
 
-  /**
-   * Expand the height of the Handsontable
-   *  by updating 'handsontableHeight' state
-   *  according to the height of this.refs.hotTableContainer
-   */
-  const expandHotTableHeight = () => {
-    if (isWindowExpanded && hotTableContainer != null) {
-      const height = hotTableContainer.getBoundingClientRect().height;
-      setHandsontableHeight(height);
-    }
-  };
-
-  const expandWindow = () => {
-    setIsWindowExpanded(true);
-
-    // create debounced method for expanding HotTable
-    // invoke updateHotTableHeight method with delay
-    // cz. Resizing this.refs.hotTableContainer is completed after a little delay after 'isWindowExpanded' set with 'true'
-    debounce(100, expandHotTableHeight);
-  };
-
-  const contractWindow = () => {
-    setIsWindowExpanded(false);
-    setHandsontableHeight(DEFAULT_HOT_HEIGHT);
-  };
-
   const createCustomizedContextMenu = () => {
     return {
       items: {
@@ -453,6 +451,7 @@ export const HandsontableModal = (): JSX.Element => {
       size="lg"
       wrapClassName={`${styles['grw-handsontable']}`}
       className={`handsontable-modal ${isWindowExpanded && 'grw-modal-expanded'}`}
+      onOpened={handleModalOpen}
     >
       <ModalHeader tag="h4" toggle={cancel} close={closeButton} className="bg-primary text-light">
         {t('handsontable_modal.title')}
@@ -493,6 +492,7 @@ export const HandsontableModal = (): JSX.Element => {
             data={markdownTable.table}
             settings={handsontableSettings as Handsontable.DefaultSettings}
             height={handsontableHeight}
+            width={handsontableWidth}
             afterLoadData={afterLoadDataHandler}
             modifyColWidth={modifyColWidthHandler}
             beforeColumnMove={beforeColumnMoveHandler}

+ 0 - 17
apps/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -22,10 +22,6 @@ const isExternalLink = (href: string, siteUrl: string | undefined): boolean => {
   }
 };
 
-const isAttached = (href: string): boolean => {
-  return href.startsWith('/attachment/');
-};
-
 type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   id?: string,
@@ -64,19 +60,6 @@ export const NextLink = (props: Props): JSX.Element => {
     );
   }
 
-  // when href is an attachment file
-  if (isAttached(href)) {
-    const dlhref = href.replace('/attachment/', '/download/');
-    return (
-      <span>
-        <a id={id} href={href} className={className} target="_blank" rel="noopener noreferrer" {...dataAttributes}>
-          {children}
-        </a>&nbsp;
-        <a href={dlhref} className="attachment-download"><i className='icon-cloud-download'></i></a>
-      </span>
-    );
-  }
-
   return (
     <Link {...rest} href={href} prefetch={false} legacyBehavior>
       <a href={href} className={className} {...dataAttributes}>{children}</a>

+ 7 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.module.scss

@@ -0,0 +1,7 @@
+.attachment :global {
+  .attachment-icon {
+    flex-shrink: 0;
+    width: 35px;
+    height: 35px;
+  }
+}

+ 84 - 0
apps/app/src/components/ReactMarkdownComponents/RichAttachment.tsx

@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react';
+
+import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
+import { useTranslation } from 'next-i18next';
+import prettyBytes from 'pretty-bytes';
+
+import { useSWRxAttachment } from '~/stores/attachment';
+import { useDeleteAttachmentModal } from '~/stores/modal';
+
+import styles from './RichAttachment.module.scss';
+
+export const RichAttachment: React.FC<{
+  attachmentId: string,
+  url: string,
+  attachmentName: string
+}> = React.memo(({ attachmentId, url, attachmentName }) => {
+  const { t } = useTranslation();
+  const { data: attachment, remove } = useSWRxAttachment(attachmentId);
+  const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
+
+  const onClickTrashButtonHandler = useCallback(() => {
+    if (attachment == null) {
+      return;
+    }
+    openDeleteAttachmentModal(attachment, remove);
+  }, [attachment, openDeleteAttachmentModal, remove]);
+
+  if (attachment == null) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  const {
+    filePathProxied,
+    originalName,
+    downloadPathProxied,
+    creator,
+    createdAt,
+    fileSize,
+  } = attachment;
+
+  // Guard here because attachment properties might be deleted in turn when an attachment is removed
+  if (filePathProxied == null
+    || originalName == null
+    || downloadPathProxied == null
+    || creator == null
+    || createdAt == null
+    || fileSize == null
+  ) {
+    return <span className='text-muted'>{t('rich_attachment.attachment_not_be_found')}</span>;
+  }
+
+  return (
+    <div className={`${styles.attachment} d-inline-block`}>
+      <div className="my-2 p-2 card">
+        <div className="p-1 card-body d-flex align-items-center">
+          <div className='mr-2 px-0 d-flex align-items-center justify-content-center'>
+            <img src='/images/icons/editor/attachment.svg' className="attachment-icon" alt='attachment icon'/>
+          </div>
+          <div className='pl-0'>
+            <div className='d-inline-block'>
+              <a target="_blank" rel="noopener noreferrer" href={filePathProxied}>
+                {attachmentName || originalName}
+              </a>
+              <a className="ml-2 attachment-download" href={downloadPathProxied}>
+                <i className="icon-cloud-download"/>
+              </a>
+              <a className="ml-2 text-danger attachment-delete" onClick={onClickTrashButtonHandler}>
+                <i className="icon-trash"/>
+              </a>
+            </div>
+            <div className='d-flex align-items-center'>
+              <UserPicture user={creator} size="sm"/>
+              <span className='ml-2 text-muted'>
+                {new Date(createdAt).toLocaleString('en-US')}
+              </span>
+              <span className='ml-2 pl-2 border-left text-muted'>{prettyBytes(fileSize)}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+});
+RichAttachment.displayName = 'RichAttachment';

+ 1 - 1
apps/app/src/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -46,7 +46,7 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
           <i className="icon-note"></i>
         </button>
       )}
-      <table className={`${className}`}>
+      <table className={className}>
         {children}
       </table>
     </div>

+ 20 - 4
apps/app/src/components/SearchPage.tsx

@@ -92,7 +92,10 @@ SearchResultListHead.displayName = 'SearchResultListHead';
 export const SearchPage = (): JSX.Element => {
   const { t } = useTranslation();
   const { data: showPageLimitationL } = useShowPageLimitationL();
+
+  // routerRef solve the problem of infinite redrawing that occurs with routers
   const router = useRouter();
+  const routerRef = useRef(router);
 
   // parse URL Query
   const queries = router.query.q;
@@ -165,10 +168,10 @@ export const SearchPage = (): JSX.Element => {
 
   const initialSearchConditions: Partial<ISearchConditions> = useMemo(() => {
     return {
-      keyword: initQ,
+      keyword,
       limit: INITIAL_PAGIONG_SIZE,
     };
-  }, [initQ]);
+  }, [keyword]);
 
   // for bulk deletion
   const deleteAllButtonClickedHandler = usePageDeleteModalForBulkDeletion(data, searchPageBaseRef, () => mutate());
@@ -177,8 +180,21 @@ export const SearchPage = (): JSX.Element => {
   useEffect(() => {
     const newUrl = new URL('/_search', 'http://example.com');
     newUrl.searchParams.append('q', keyword);
-    window.history.pushState('', `Search - ${keyword}`, `${newUrl.pathname}${newUrl.search}`);
-  }, [keyword]);
+    routerRef.current.push(`${newUrl.pathname}${newUrl.search}`, '', { shallow: true });
+  }, [keyword, routerRef]);
+
+  // browser back and forward
+  useEffect(() => {
+    routerRef.current.beforePopState(({ url }) => {
+      const newUrl = new URL(url, 'https://exmple.com');
+      const newKeyword = newUrl.searchParams.get('q');
+      if (newKeyword != null) {
+        setKeyword(newKeyword);
+      }
+      return true;
+    });
+  }, [setKeyword, routerRef]);
+
   const hitsCount = data?.meta.hitsCount;
 
   const allControl = useMemo(() => {

+ 14 - 5
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -43,8 +43,13 @@ type Props = {
   searchPager: React.ReactNode,
 }
 
+
+const SearchResultContent = dynamic(() => import('./SearchResultContent').then(mod => mod.SearchResultContent), {
+  ssr: false,
+  loading: () => <></>,
+});
 const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturnSelectedPageIds, Props> = (props:Props, ref) => {
-  const SearchResultContent = dynamic(import('./SearchResultContent').then(mod => mod.SearchResultContent), { ssr: false });
+
   const {
     pages,
     searchingKeyword,
@@ -62,6 +67,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
   const [selectedPageIdsByCheckboxes] = useState<Set<string>>(new Set());
   // const [allPageIds] = useState<Set<string>>(new Set());
+
   const [selectedPageWithMeta, setSelectedPageWithMeta] = useState<IPageWithSearchMeta | undefined>();
 
   // publish selectAll()
@@ -108,10 +114,13 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
 
   // select first item on load
   useEffect(() => {
-    if (selectedPageWithMeta == null && pages != null && pages.length > 0) {
+    if ((pages == null || pages.length === 0)) {
+      setSelectedPageWithMeta(undefined);
+    }
+    else if ((pages != null && pages.length > 0)) {
       setSelectedPageWithMeta(pages[0]);
     }
-  }, [pages, selectedPageWithMeta]);
+  }, [pages, setSelectedPageWithMeta]);
 
   // reset selectedPageIdsByCheckboxes
   useEffect(() => {
@@ -189,7 +198,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
                       pages={pages}
                       selectedPageId={selectedPageWithMeta?.data._id}
                       forceHideMenuItems={forceHideMenuItems}
-                      onPageSelected={page => setSelectedPageWithMeta(page)}
+                      onPageSelected={page => (setSelectedPageWithMeta(page)) }
                       onCheckboxChanged={checkboxChangedHandler}
                     />
                   </div>
@@ -205,7 +214,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
         </div>
 
         <div className="mw-0 flex-grow-1 flex-basis-0 d-none d-lg-block search-result-content">
-          { selectedPageWithMeta != null && (
+          {pages != null && pages.length !== 0 && selectedPageWithMeta != null && (
             <SearchResultContent
               pageWithMeta={selectedPageWithMeta}
               highlightKeywords={highlightKeywords}

+ 3 - 2
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -6,7 +6,7 @@ import Link from 'next/link';
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 import styles from './SidebarNav.module.scss';
@@ -84,6 +84,7 @@ type Props = {
 export const SidebarNav: FC<Props> = (props: Props) => {
 
   const { data: currentUser } = useCurrentUser();
+  const { data: growiCloudUri } = useGrowiCloudUri();
 
   const [isAdmin, setAdmin] = useState(false);
 
@@ -110,7 +111,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
-        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Help" iconName="help" href={ growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org' } isBlank />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
       </div>
     </div>

+ 197 - 56
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -2,18 +2,28 @@ import React, {
   useCallback, useEffect, useState,
 } from 'react';
 
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import assert from 'assert';
+
+import { Lang } from '@growi/core';
+import {
+  extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
+} from '@growi/pluginkit/dist/v4';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
+  UncontrolledDropdown,
+  DropdownToggle,
+  DropdownMenu,
+  DropdownItem,
 } from 'reactstrap';
 
-import { useTemplateModal } from '~/stores/modal';
+import { useSWRxTemplate, useSWRxTemplates } from '~/features/templates/stores';
+import { useTemplateModal, type TemplateModalStatus } from '~/stores/modal';
+import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
-import { useTemplates } from '~/stores/template';
 import loggerFactory from '~/utils/logger';
 
 import Preview from '../PageEditor/Preview';
@@ -23,108 +33,239 @@ import { useFormatter } from './use-formatter';
 const logger = loggerFactory('growi:components:TemplateModal');
 
 
-type TemplateRadioButtonProps = {
-  template: ITemplate,
-  onChange: (selectedTemplate: ITemplate) => void,
+function constructTemplateId(templateSummary: TemplateSummary): string {
+  const defaultTemplate = templateSummary.default;
+
+  return `${defaultTemplate.pluginId ?? ''}_${defaultTemplate.id}`;
+}
+
+type TemplateItemProps = {
+  templateSummary: TemplateSummary,
+  selectedLocale?: string,
+  onClick?: () => void,
   isSelected?: boolean,
+  usersDefaultLang?: Lang,
 }
 
-const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
-  const radioButtonId = `rb-${template.id}`;
+const TemplateItem: React.FC<TemplateItemProps> = ({
+  templateSummary,
+  onClick,
+  isSelected,
+  usersDefaultLang,
+}) => {
+  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+  const templateLocales = extractSupportedLocales(templateSummary);
+
+  assert(localizedTemplate?.isValid);
 
   return (
-    <div key={template.id} className="custom-control custom-radio mb-2">
-      <input
-        id={radioButtonId}
-        type="radio"
-        className="custom-control-input"
-        checked={isSelected}
-        onChange={() => onChange(template)}
-      />
-      <label className="custom-control-label" htmlFor={radioButtonId}>
-        {template.name}
-      </label>
-    </div>
+    <a
+      className={`list-group-item list-group-item-action ${isSelected ? 'active' : ''}`}
+      onClick={onClick}
+      aria-current="true"
+    >
+      <h4 className="mb-1">{localizedTemplate.title}</h4>
+      <p className="mb-2">{localizedTemplate.desc}</p>
+      { templateLocales != null && Array.from(templateLocales).map(locale => (
+        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+      ))}
+    </a>
   );
 };
 
-export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation(['translation', 'commons']);
+type TemplateModalSubstanceProps = {
+  templateModalStatus: TemplateModalStatus,
+  close: () => void,
+}
 
+const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element => {
+  const { templateModalStatus, close } = props;
 
-  const { data: templateModalStatus, close } = useTemplateModal();
+  const { t } = useTranslation(['translation', 'commons']);
 
+  const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: rendererOptions } = usePreviewOptions();
-  const { data: templates } = useTemplates();
+  const { data: templateSummaries } = useSWRxTemplates();
+
+  const [selectedTemplateSummary, setSelectedTemplateSummary] = useState<TemplateSummary>();
+  const [selectedTemplateLocale, setSelectedTemplateLocale] = useState<string>();
 
-  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+  const { data: selectedTemplateMarkdown } = useSWRxTemplate(selectedTemplateSummary, selectedTemplateLocale);
 
   const { format } = useFormatter();
 
-  const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null || selectedTemplate == null) {
+  const usersDefaultLang = personalSettingsInfo?.lang;
+  const selectedLocalizedTemplate = getLocalizedTemplate(selectedTemplateSummary, usersDefaultLang);
+  const selectedTemplateLocales = extractSupportedLocales(selectedTemplateSummary);
+
+  const submitHandler = useCallback((markdown?: string) => {
+    if (markdown == null) {
       return;
     }
 
-    if (templateModalStatus.onSubmit == null || template == null) {
+    if (templateModalStatus.onSubmit == null) {
       close();
       return;
     }
 
-    templateModalStatus.onSubmit(format(selectedTemplate));
+    templateModalStatus.onSubmit(format(selectedTemplateMarkdown));
     close();
-  }, [close, format, selectedTemplate, templateModalStatus]);
+  }, [close, format, selectedTemplateMarkdown, templateModalStatus]);
+
+  const onClickHandler = useCallback((
+      templateSummary: TemplateSummary,
+  ) => {
+    let localeToSet: string | Lang | undefined;
+
+    if (selectedTemplateLocale != null && selectedTemplateLocale in templateSummary) {
+      localeToSet = selectedTemplateLocale;
+    }
+    else if (usersDefaultLang != null && usersDefaultLang in templateSummary) {
+      localeToSet = usersDefaultLang;
+    }
+    else {
+      localeToSet = undefined;
+    }
+
+    setSelectedTemplateLocale(localeToSet);
+    setSelectedTemplateSummary(templateSummary);
+  }, [selectedTemplateLocale, usersDefaultLang]);
 
   useEffect(() => {
-    if (!templateModalStatus?.isOpened) {
-      setSelectedTemplate(undefined);
+    if (!templateModalStatus.isOpened) {
+      setSelectedTemplateSummary(undefined);
+      setSelectedTemplateLocale(undefined);
     }
-  }, [templateModalStatus?.isOpened]);
+  }, [templateModalStatus.isOpened]);
 
-  if (templates == null || templateModalStatus == null) {
+  if (templateSummaries == null) {
     return <></>;
   }
 
   return (
-    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+    <>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
       </ModalHeader>
-
       <ModalBody className="container">
         <div className="row">
-          <div className="col-12">
-            { templates.map(template => (
-              <TemplateRadioButton
-                key={template.id}
-                template={template}
-                onChange={selected => setSelectedTemplate(selected)}
-                isSelected={template.id === selectedTemplate?.id}
-              />
-            )) }
+          {/* List Group */}
+          <div className="d-none d-lg-block col-lg-4">
+            <div className="list-group">
+              {templateSummaries.map((templateSummary) => {
+                const templateId = constructTemplateId(templateSummary);
+                const isSelected = selectedTemplateSummary != null && constructTemplateId(selectedTemplateSummary) === templateId;
+
+                return (
+                  <TemplateItem
+                    key={templateId}
+                    templateSummary={templateSummary}
+                    onClick={() => onClickHandler(templateSummary)}
+                    isSelected={isSelected}
+                    usersDefaultLang={usersDefaultLang}
+                  />
+                );
+              })}
+            </div>
           </div>
-        </div>
+          {/* Dropdown */}
+          <div className='d-lg-none col mb-3'>
+            <UncontrolledDropdown>
+              <DropdownToggle caret type="button" outline className='w-100 text-right'>
+                <span className="float-left">
+                  {selectedLocalizedTemplate != null && selectedLocalizedTemplate.isValid
+                    ? selectedLocalizedTemplate.title
+                    : t('Select template')}
+                </span>
+              </DropdownToggle>
+              <DropdownMenu role="menu" className='p-0'>
+                {templateSummaries.map((templateSummary, index) => {
+                  const templateId = constructTemplateId(templateSummary);
+                  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+                  const templateLocales = extractSupportedLocales(templateSummary);
 
-        <hr />
+                  assert(localizedTemplate?.isValid);
 
-        <h3>{t('Preview')}</h3>
-        <div className='card'>
-          <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
-            { rendererOptions != null && selectedTemplate != null && (
-              <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplate)}/>
-            ) }
+                  return (
+                    <DropdownItem
+                      key={templateId}
+                      onClick={() => onClickHandler(templateSummary)}
+                      className={`px-4 py-3 ${index === 0 ? '' : 'border-top'}`}
+                    >
+                      <h4 className="mb-1 text-wrap">{localizedTemplate.title}</h4>
+                      <p className="mb-1 text-wrap">{localizedTemplate.desc}</p>
+                      { templateLocales != null && Array.from(templateLocales).map(locale => (
+                        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+                      ))}
+                    </DropdownItem>
+                  );
+                })}
+              </DropdownMenu>
+            </UncontrolledDropdown>
+          </div>
+          <div className="col-12 col-lg-8">
+            <div className='row mb-2 mb-lg-0'>
+              <div className="col-6">
+                <h3>{t('preview')}</h3>
+              </div>
+              <div className="col-6 d-flex justify-content-end">
+                <UncontrolledDropdown>
+                  <DropdownToggle caret type="button" outline className='float-right' disabled={selectedTemplateSummary == null}>
+                    <span className="float-left">{selectedTemplateLocale != null ? selectedTemplateLocale : t('Language')}</span>
+                  </DropdownToggle>
+                  <DropdownMenu className="dropdown-menu" role="menu">
+                    { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
+                      return (
+                        <DropdownItem
+                          key={locale}
+                          onClick={() => setSelectedTemplateLocale(locale)}>
+                          <span>{locale}</span>
+                        </DropdownItem>
+                      );
+                    }) }
+                  </DropdownMenu>
+                </UncontrolledDropdown>
+              </div>
+            </div>
+            <div className='card'>
+              <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
+                { rendererOptions != null && selectedTemplateSummary != null && (
+                  <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplateMarkdown)}/>
+                ) }
+              </div>
+            </div>
           </div>
         </div>
-
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+        <button type="button" className="btn btn-outline-secondary mx-1" onClick={close}>
           {t('Cancel')}
         </button>
-        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
+        <button
+          type="submit"
+          className="btn btn-primary mx-1"
+          onClick={() => submitHandler(selectedTemplateMarkdown)}
+          disabled={selectedTemplateSummary == null}>
           {t('commons:Insert')}
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+
+export const TemplateModal = (): JSX.Element => {
+  const { data: templateModalStatus, close } = useTemplateModal();
+
+  if (templateModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="xl" autoFocus={false}>
+      { templateModalStatus.isOpened && (
+        <TemplateModalSubstance templateModalStatus={templateModalStatus} close={close} />
+      ) }
     </Modal>
   );
 };

+ 9 - 15
apps/app/src/components/TemplateModal/use-formatter.spec.tsx

@@ -1,6 +1,3 @@
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
-import { mock } from 'vitest-mock-extended';
-
 import { useFormatter } from './use-formatter';
 
 
@@ -47,26 +44,24 @@ describe('useFormatter', () => {
 
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = 'markdown body';
-    const markdown = format(template);
+    const markdown = 'markdown body';
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe('markdown body');
+    expect(formatted).toBe('markdown body');
   });
 
   it('returns markdown formatted when currentPagePath is undefined', () => {
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}{{^title}}(empty){{/title}}
 path: {{{path}}}
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: (empty)
 path: /
 `);
@@ -82,16 +77,15 @@ path: /
 
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}
 path: {{{path}}}
 date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: Sandbox
 path: /Sandbox
 date: 2023/05/31 15:01

+ 5 - 8
apps/app/src/components/TemplateModal/use-formatter.tsx

@@ -1,6 +1,5 @@
 import path from 'path';
 
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
 import dateFnsFormat from 'date-fns/format';
 import mustache from 'mustache';
 
@@ -10,7 +9,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
 
 
-type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatMethod = (markdown?: string) => string;
 type FormatterData = {
   format: FormatMethod,
 }
@@ -18,16 +17,15 @@ type FormatterData = {
 export const useFormatter = (): FormatterData => {
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const format: FormatMethod = (selectedTemplate) => {
-    if (selectedTemplate == null) {
+  const format: FormatMethod = (markdown) => {
+    if (markdown == null) {
       return '';
     }
 
     // replace placeholder
-    let markdown = selectedTemplate.markdown;
     const now = new Date();
     try {
-      markdown = mustache.render(selectedTemplate.markdown, {
+      return mustache.render(markdown, {
         title: path.basename(currentPagePath ?? '/'),
         path: currentPagePath ?? '/',
         yyyy: dateFnsFormat(now, 'yyyy'),
@@ -39,9 +37,8 @@ export const useFormatter = (): FormatterData => {
     }
     catch (err) {
       logger.warn('An error occured while ejs processing.', err);
+      return markdown;
     }
-
-    return markdown;
   };
 
   return { format };

+ 1 - 6
apps/app/src/components/User/Username.tsx

@@ -3,12 +3,7 @@ import React from 'react';
 import type { IUser } from '@growi/core';
 import Link from 'next/link';
 
-type UsernameProps = {
- user?: IUser,
-}
-
-export const Username = (props: UsernameProps): JSX.Element => {
-  const { user } = props;
+export const Username: React.FC<{ user?: IUser }> = ({ user }): JSX.Element => {
 
   if (user == null) {
     return <span>anyone</span>;

+ 0 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss


+ 0 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx


+ 4 - 4
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -5,11 +5,11 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
-import type { IGrowiPluginOrigin } from '../../../interfaces';
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import type { IGrowiPluginOrigin } from '../../../../interfaces';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 export const PluginInstallerForm = (): JSX.Element => {
-  const { mutate } = useSWRxPlugins();
+  const { mutate } = useSWRxAdminPlugins();
   const { t } = useTranslation('admin');
 
   const submitHandler = useCallback(async(e) => {
@@ -25,7 +25,7 @@ export const PluginInstallerForm = (): JSX.Element => {
 
     const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
-      ghBranch,
+      ghBranch: ghBranch || 'main',
       // ghTag,
     };
 

+ 2 - 2
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { Spinner } from 'reactstrap';
 
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
@@ -19,7 +19,7 @@ const Loading = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const { data, mutate } = useSWRxPlugins();
+  const { data, mutate } = useSWRxAdminPlugins();
 
   return (
     <div>

+ 0 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/index.ts


+ 1 - 0
apps/app/src/features/growi-plugin/client/components/Admin/index.ts

@@ -0,0 +1 @@
+export * from './PluginsExtensionPageContents';

+ 1 - 1
apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx → apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react';
 
-import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils.client';
+import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var

+ 1 - 0
apps/app/src/features/growi-plugin/client/components/index.ts

@@ -0,0 +1 @@
+export * from './GrowiPluginsActivator';

+ 24 - 0
apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx

@@ -0,0 +1,24 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { IGrowiPluginHasId } from '../../interfaces';
+
+type Plugins = {
+  plugins: IGrowiPluginHasId[]
+}
+
+export const useSWRxAdminPlugins = (): SWRResponse<Plugins, Error> => {
+  return useSWR(
+    '/plugins',
+    async(endpoint) => {
+      try {
+        const res = await apiv3Get<Plugins>(endpoint);
+        return res.data;
+      }
+      catch (err) {
+        throw new Error(err);
+      }
+    },
+  );
+};

+ 0 - 0
apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts → apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts


+ 0 - 1
apps/app/src/features/growi-plugin/components/index.ts

@@ -1 +0,0 @@
-export * from './GrowiPluginsActivator.client';

+ 14 - 11
apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -1,12 +1,5 @@
-import { GrowiThemeMetadata, HasObjectId } from '@growi/core';
-
-export const GrowiPluginResourceType = {
-  Template: 'template',
-  Style: 'style',
-  Theme: 'theme',
-  Script: 'script',
-} as const;
-export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
+import type { GrowiPluginType, GrowiThemeMetadata, HasObjectId } from '@growi/core';
+import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
 
 export type IGrowiPluginOrigin = {
   url: string,
@@ -24,13 +17,23 @@ export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
 
 export type IGrowiPluginMeta = {
   name: string,
-  types: GrowiPluginResourceType[],
+  types: GrowiPluginType[],
   desc?: string,
   author?: string,
 }
 
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
-  themes: GrowiThemeMetadata[]
+  themes: GrowiThemeMetadata[],
+}
+
+export type IGrowiTemplatePluginMeta = IGrowiPluginMeta & {
+  templateSummaries: TemplateSummary[],
 }
 
+export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> = T extends 'theme'
+  ? IGrowiThemePluginMeta
+  : T extends 'template'
+    ? IGrowiTemplatePluginMeta
+    : IGrowiPluginMeta;
+
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 5 - 0
apps/app/src/features/growi-plugin/server/consts/index.ts

@@ -0,0 +1,5 @@
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+export const PLUGIN_STORING_PATH = resolveFromRoot('tmp/plugins');
+
+export const PLUGIN_EXPRESS_STATIC_DIR = '/static/plugins';

+ 157 - 0
apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts

@@ -0,0 +1,157 @@
+import { GrowiPluginType } from '@growi/core';
+
+import { GrowiPlugin } from './growi-plugin';
+
+describe('GrowiPlugin find methods', () => {
+
+  beforeAll(async() => {
+    await GrowiPlugin.insertMany([
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-unenabled1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-unenabled1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-unenabled1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-unenabled2',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-unenabled2',
+        },
+        meta: {
+          name: '@growi/growi-plugin-unenabled2',
+          types: [GrowiPluginType.Template],
+        },
+      },
+      {
+        isEnabled: true,
+        installedPath: 'weseek/growi-plugin-example1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+      {
+        isEnabled: true,
+        installedPath: 'weseek/growi-plugin-example2',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example2',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example2',
+          types: [GrowiPluginType.Template],
+        },
+      },
+    ]);
+  });
+
+  afterAll(async() => {
+    await GrowiPlugin.deleteMany({});
+  });
+
+  describe.concurrent('.findEnabledPlugins', () => {
+    it('shoud returns documents which isEnabled is true', async() => {
+      // when
+      const results = await GrowiPlugin.findEnabledPlugins();
+
+      const pluginNames = results.map(p => p.meta.name);
+
+      // then
+      expect(results.length === 2).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example1')).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
+    });
+  });
+
+  describe.concurrent('.findEnabledPluginsByType', () => {
+    it("shoud returns documents which type is 'template'", async() => {
+      // when
+      const results = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+
+      const pluginNames = results.map(p => p.meta.name);
+
+      // then
+      expect(results.length === 1).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
+    });
+  });
+
+});
+
+
+describe('GrowiPlugin activate/deactivate', () => {
+
+  beforeAll(async() => {
+    await GrowiPlugin.insertMany([
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-example1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+    ]);
+  });
+
+  afterAll(async() => {
+    await GrowiPlugin.deleteMany({});
+  });
+
+  describe('.activatePlugin', () => {
+    it('shoud update the property "isEnabled" to true', async() => {
+      // setup
+      const plugin = await GrowiPlugin.findOne({});
+      assert(plugin != null);
+
+      expect(plugin.isEnabled).toBeFalsy(); // isEnabled: false
+
+      // when
+      const result = await GrowiPlugin.activatePlugin(plugin._id);
+      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+
+      // then
+      expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
+      expect(pluginAfterActivated).not.toBeNull();
+      assert(pluginAfterActivated != null);
+      expect(pluginAfterActivated.isEnabled).toBeTruthy(); // isEnabled: true
+    });
+  });
+
+  describe('.deactivatePlugin', () => {
+    it('shoud update the property "isEnabled" to true', async() => {
+      // setup
+      const plugin = await GrowiPlugin.findOne({});
+      assert(plugin != null);
+
+      expect(plugin.isEnabled).toBeTruthy(); // isEnabled: true
+
+      // when
+      const result = await GrowiPlugin.deactivatePlugin(plugin._id);
+      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+
+      // then
+      expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
+      expect(pluginAfterActivated).not.toBeNull();
+      assert(pluginAfterActivated != null);
+      expect(pluginAfterActivated.isEnabled).toBeFalsy(); // isEnabled: false
+    });
+  });
+
+});

+ 17 - 28
apps/app/src/features/growi-plugin/models/growi-plugin.ts → apps/app/src/features/growi-plugin/server/models/growi-plugin.ts

@@ -1,48 +1,35 @@
-import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
+import { GrowiPluginType } from '@growi/core';
 import {
   Schema, type Model, type Document, type Types,
 } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { GrowiPluginResourceType } from '../interfaces';
 import type {
-  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginOrigin, IGrowiThemePluginMeta,
-} from '../interfaces';
+  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginMetaByType, IGrowiPluginOrigin, IGrowiTemplatePluginMeta, IGrowiThemePluginMeta,
+} from '../../interfaces';
 
-export interface IGrowiPluginDocument extends IGrowiPlugin, Document {
+export interface IGrowiPluginDocument<M extends IGrowiPluginMeta = IGrowiPluginMeta> extends IGrowiPlugin<M>, Document {
+  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta,
 }
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
-  findEnabledPlugins(): Promise<IGrowiPlugin[]>
-  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]>
+  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>
+  findEnabledPluginsByType<T extends GrowiPluginType>(type: T): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>
   activatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
 
-const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
-  name: { type: String, required: true },
-  manifestKey: { type: String, required: true },
-  schemeType: {
-    type: String,
-    enum: GrowiThemeSchemeType,
-    require: true,
-  },
-  bg: { type: String, required: true },
-  topbar: { type: String, required: true },
-  sidebar: { type: String, required: true },
-  accent: { type: String, required: true },
-});
-
-const growiPluginMetaSchema = new Schema<IGrowiPluginMeta|IGrowiThemePluginMeta>({
+const growiPluginMetaSchema = new Schema<IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
-    enum: GrowiPluginResourceType,
+    enum: GrowiPluginType,
     require: true,
   },
   desc: { type: String },
   author: { type: String },
-  themes: [growiThemeMetadataSchema],
+  themes: [Map],
+  templateSummaries: [Map],
 });
 
 const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
@@ -60,14 +47,16 @@ const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
 });
 
 growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
-  return this.find({ isEnabled: true });
+  return this.find({ isEnabled: true }).lean();
 };
 
-growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPluginsByType = async function<T extends GrowiPluginType>(
+    type: T,
+): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
   return this.find({
     isEnabled: true,
-    'meta.types': { $in: types },
-  });
+    'meta.types': { $in: type },
+  }).lean();
 };
 
 growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {

+ 0 - 0
apps/app/src/features/growi-plugin/models/index.ts → apps/app/src/features/growi-plugin/server/models/index.ts


+ 0 - 0
apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts → apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts


+ 4 - 2
apps/app/src/features/growi-plugin/models/vo/github-url.ts → apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -1,3 +1,5 @@
+import sanitize from 'sanitize-filename';
+
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
@@ -44,8 +46,8 @@ export class GitHubUrl {
 
     this._branchName = branchName;
 
-    this._organizationName = matched[1];
-    this._reposName = matched[2];
+    this._organizationName = sanitize(matched[1]);
+    this._reposName = sanitize(matched[2]);
   }
 
 }

+ 2 - 2
apps/app/src/features/growi-plugin/routes/growi-plugins.ts → apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -5,8 +5,8 @@ import mongoose from 'mongoose';
 import Crowi from '~/server/crowi';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
-import { GrowiPlugin } from '../models';
-import { growiPluginService } from '../services';
+import { GrowiPlugin } from '../../../models';
+import { growiPluginService } from '../../../services';
 
 
 const ObjectID = mongoose.Types.ObjectId;

Некоторые файлы не были показаны из-за большого количества измененных файлов