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

resolve conflicts merging master

Taichi Masuyama 3 лет назад
Родитель
Сommit
094c5e3b0f
100 измененных файлов с 1861 добавлено и 640 удалено
  1. 1 1
      package.json
  2. 16 14
      packages/app/_obsolete/src/client/services/PageContainer.js
  3. 0 17
      packages/app/bin/templates/plugin-definitions.js.swig
  4. 29 0
      packages/app/cypress.config.ts
  5. 0 18
      packages/app/cypress.json
  6. 1 1
      packages/app/package.json
  7. 35 31
      packages/app/public/static/locales/en_US/admin.json
  8. 0 1
      packages/app/public/static/locales/en_US/translation.json
  9. 27 21
      packages/app/public/static/locales/ja_JP/admin.json
  10. 6 15
      packages/app/public/static/locales/zh_CN/admin.json
  11. 0 1
      packages/app/public/static/locales/zh_CN/translation.json
  12. 0 2
      packages/app/src/client/services/AdminHomeContainer.js
  13. 51 0
      packages/app/src/client/services/activate-plugin.ts
  14. 6 52
      packages/app/src/client/util/apiNotification.js
  15. 91 0
      packages/app/src/client/util/toastr.ts
  16. 0 8
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  17. 0 55
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  18. 21 0
      packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx
  19. 3 0
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  20. 4 1
      packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  21. 13 0
      packages/app/src/components/Admin/PluginsExtension/Loading.js
  22. 68 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss
  23. 85 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx
  24. 91 0
      packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx
  25. 58 0
      packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx
  26. 1 1
      packages/app/src/components/Common/Dropdown/PageItemControl.tsx
  27. 2 2
      packages/app/src/components/CreateTemplateModal.jsx
  28. 1 1
      packages/app/src/components/EmptyTrashModal.tsx
  29. 2 2
      packages/app/src/components/InAppNotification/InAppNotificationList.tsx
  30. 3 6
      packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx
  31. 1 1
      packages/app/src/components/Layout/AdminLayout.tsx
  32. 2 0
      packages/app/src/components/Layout/RawLayout.tsx
  33. 7 4
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  34. 8 2
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  35. 14 1
      packages/app/src/components/Page.tsx
  36. 1 1
      packages/app/src/components/Page/CopyDropdown.jsx
  37. 28 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  38. 9 11
      packages/app/src/components/Page/RenderTagLabels.tsx
  39. 1 1
      packages/app/src/components/Page/TagLabels.tsx
  40. 1 1
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  41. 13 11
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  42. 4 2
      packages/app/src/components/PageComment/CommentEditor.tsx
  43. 7 2
      packages/app/src/components/PageCreateModal.jsx
  44. 1 1
      packages/app/src/components/PageCreateModal.module.scss
  45. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  46. 4 5
      packages/app/src/components/PageDuplicateModal.tsx
  47. 66 7
      packages/app/src/components/PageEditor.tsx
  48. 22 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  49. 8 0
      packages/app/src/components/PageEditor/EditorIcon.jsx
  50. 2 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  51. 10 7
      packages/app/src/components/PageEditorByHackmd.tsx
  52. 9 2
      packages/app/src/components/PageEditorByHackmd/HackmdEditor.jsx
  53. 6 2
      packages/app/src/components/PageList/PageListItemL.tsx
  54. 0 177
      packages/app/src/components/PageStatusAlert.jsx
  55. 40 0
      packages/app/src/components/PageStatusAlert.module.scss
  56. 166 0
      packages/app/src/components/PageStatusAlert.tsx
  57. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  58. 1 1
      packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx
  59. 1 1
      packages/app/src/components/PutbackPageModal.jsx
  60. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  61. 1 0
      packages/app/src/components/TableOfContents.tsx
  62. 117 0
      packages/app/src/components/TemplateModal.tsx
  63. 30 0
      packages/app/src/components/TemplateTab.tsx
  64. 2 2
      packages/app/src/components/Theme/ThemeDefault.global.scss
  65. 1 1
      packages/app/src/components/Theme/ThemeDefault.tsx
  66. 12 5
      packages/app/src/components/Theme/utils/ThemeInjector.tsx
  67. 21 0
      packages/app/src/interfaces/github-api.ts
  68. 3 0
      packages/app/src/interfaces/in-app-notification.ts
  69. 26 0
      packages/app/src/interfaces/plugin.ts
  70. 5 0
      packages/app/src/interfaces/websocket.ts
  71. 2 2
      packages/app/src/models/serializers/in-app-notification-snapshot/page.ts
  72. 6 1
      packages/app/src/pages/[[...path]].page.tsx
  73. 5 0
      packages/app/src/pages/_app.page.tsx
  74. 49 6
      packages/app/src/pages/_document.page.tsx
  75. 0 3
      packages/app/src/pages/admin/index.page.tsx
  76. 53 0
      packages/app/src/pages/admin/plugins.page.tsx
  77. 12 0
      packages/app/src/pages/invited.page.tsx
  78. 24 4
      packages/app/src/pages/me/[[...path]].page.tsx
  79. 22 1
      packages/app/src/pages/tags.page.tsx
  80. 22 1
      packages/app/src/pages/trash.page.tsx
  81. 3 0
      packages/app/src/server/crowi/express-init.js
  82. 21 0
      packages/app/src/server/crowi/index.js
  83. 1 2
      packages/app/src/server/middlewares/application-not-installed.js
  84. 40 0
      packages/app/src/server/models/growi-plugin.ts
  85. 0 76
      packages/app/src/server/plugins/plugin-utils.js
  86. 0 4
      packages/app/src/server/routes/apiv3/admin-home.js
  87. 4 1
      packages/app/src/server/routes/apiv3/index.js
  88. 29 0
      packages/app/src/server/routes/apiv3/plugins-extension.ts
  89. 2 8
      packages/app/src/server/routes/index.js
  90. 12 12
      packages/app/src/server/routes/login.js
  91. 139 0
      packages/app/src/server/service/plugin.ts
  92. 15 0
      packages/app/src/services/renderer/renderer.tsx
  93. 4 0
      packages/app/src/stores/editor.tsx
  94. 3 2
      packages/app/src/stores/hackmd.ts
  95. 21 2
      packages/app/src/stores/in-app-notification.ts
  96. 10 5
      packages/app/src/stores/page-listing.tsx
  97. 1 1
      packages/app/src/stores/page.tsx
  98. 18 0
      packages/app/src/stores/remote-latest-page.ts
  99. 14 5
      packages/app/src/stores/renderer.tsx
  100. 61 0
      packages/app/src/stores/template.tsx

+ 1 - 1
package.json

@@ -61,7 +61,7 @@
     "@types/rewire": "^2.5.28",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^5.0.0",
     "@typescript-eslint/eslint-plugin": "^5.0.0",
     "@typescript-eslint/parser": "^5.0.0",
     "@typescript-eslint/parser": "^5.0.0",
-    "cypress": "^9.2.0",
+    "cypress": "^12.0.1",
     "eslint": "^8.18.0",
     "eslint": "^8.18.0",
     "eslint-config-next": "^12.1.6",
     "eslint-config-next": "^12.1.6",
     "eslint-config-weseek": "^2.1.0",
     "eslint-config-weseek": "^2.1.0",

+ 16 - 14
packages/app/_obsolete/src/client/services/PageContainer.js

@@ -277,12 +277,13 @@ export default class PageContainer extends Container {
     });
     });
   }
   }
 
 
-  // request to server so the client to join a room for each page
-  emitJoinPageRoomRequest() {
-    const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
-    const socket = socketIoContainer.getSocket();
-    socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
-  }
+  // replaced accompanied by omiting container: https://github.com/weseek/growi/pull/6968
+  // // request to server so the client to join a room for each page
+  // emitJoinPageRoomRequest() {
+  //   const socketIoContainer = this.appContainer.getContainer('SocketIoContainer');
+  //   const socket = socketIoContainer.getSocket();
+  //   socket.emit('join:page', { socketId: socket.id, pageId: this.state.pageId });
+  // }
 
 
   addWebSocketEventHandlers() {
   addWebSocketEventHandlers() {
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     // eslint-disable-next-line @typescript-eslint/no-this-alias
@@ -300,15 +301,16 @@ export default class PageContainer extends Container {
       }
       }
     });
     });
 
 
-    socket.on('page:update', (data) => {
-      logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
+    // replaced accompanied by omiting container: https://github.com/weseek/growi/pull/6968
+    // socket.on('page:update', (data) => {
+    //   logger.debug({ obj: data }, `websocket on 'page:update'`); // eslint-disable-line quotes
 
 
-      // update remote page data
-      const { s2cMessagePageUpdated } = data;
-      if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
-        pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
-      }
-    });
+    //   // update remote page data
+    //   const { s2cMessagePageUpdated } = data;
+    //   if (s2cMessagePageUpdated.pageId === pageContainer.state.pageId) {
+    //     pageContainer.setLatestRemotePageData(s2cMessagePageUpdated);
+    //   }
+    // });
 
 
     socket.on('page:delete', (data) => {
     socket.on('page:delete', (data) => {
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes
       logger.debug({ obj: data }, `websocket on 'page:delete'`); // eslint-disable-line quotes

+ 0 - 17
packages/app/bin/templates/plugin-definitions.js.swig

@@ -1,17 +0,0 @@
-/*
- * !! don't commit this file !!
- * !!      just revert       !!
- */
-module.exports = [
-  {% for definition in definitions %}{
-    name: '{{ definition.name }}',
-    meta: require('{{ definition.name }}'),
-    entries: [
-      {% for entryPath in definition.entries %}
-      require('{{ entryPath }}').default,
-      {% endfor %}
-    ]
-  },
-  {% endfor %}
-
-]

+ 29 - 0
packages/app/cypress.config.ts

@@ -0,0 +1,29 @@
+import { defineConfig } from 'cypress';
+
+export default defineConfig({
+  e2e: {
+    baseUrl: 'http://localhost:3000',
+    specPattern: 'test/cypress/integration/',
+    supportFile: 'test/cypress/support/index.ts',
+    setupNodeEvents: (on) => {
+      // change screen size
+      // see: https://docs.cypress.io/api/plugins/browser-launch-api#Set-screen-size-when-running-headless
+      on('before:browser:launch', (browser, launchOptions) => {
+        if (browser.name === 'chrome' && browser.isHeadless) {
+          launchOptions.args.push('--window-size=1400,1024');
+          launchOptions.args.push('--force-device-scale-factor=1');
+        }
+        return launchOptions;
+      });
+    },
+  },
+  fileServerFolder: 'test/cypress',
+  fixturesFolder: 'test/cypress/fixtures',
+  screenshotsFolder: 'test/cypress/screenshots',
+  videosFolder: 'test/cypress/videos',
+
+  viewportWidth: 1400,
+  viewportHeight: 1024,
+
+  defaultCommandTimeout: 30000,
+});

+ 0 - 18
packages/app/cypress.json

@@ -1,18 +0,0 @@
-{
-  "baseUrl": "http://localhost:3000",
-
-  "fileServerFolder": "test/cypress",
-  "fixturesFolder": "test/cypress/fixtures",
-  "integrationFolder": "test/cypress/integration",
-  "screenshotsFolder": "test/cypress/screenshots",
-  "videosFolder": "test/cypress/videos",
-  "supportFile": "test/cypress/support/index.ts",
-  "pluginsFile": "test/cypress/plugins/index.ts",
-  "testFiles": "**/*.spec.ts",
-
-  "viewportWidth": 1400,
-  "viewportHeight": 1024,
-
-  "experimentalSessionSupport": true,
-  "defaultCommandTimeout": 30000
-}

+ 1 - 1
packages/app/package.json

@@ -47,7 +47,6 @@
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
     "resources:dummy": "true",
     "resources:dummy": "true",
-    "// resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
   },
@@ -166,6 +165,7 @@
     "react-multiline-clamp": "^2.0.0",
     "react-multiline-clamp": "^2.0.0",
     "react-scroll": "^1.8.7",
     "react-scroll": "^1.8.7",
     "react-syntax-highlighter": "^15.5.0",
     "react-syntax-highlighter": "^15.5.0",
+    "react-toastify": "^9.1.1",
     "react-use-ripple": "^1.5.2",
     "react-use-ripple": "^1.5.2",
     "reactstrap": "^8.9.0",
     "reactstrap": "^8.9.0",
     "reconnecting-websocket": "^4.4.0",
     "reconnecting-websocket": "^4.4.0",

+ 35 - 31
packages/app/public/static/locales/en_US/admin.json

@@ -2,11 +2,13 @@
   "meta": {
   "meta": {
     "display_name": "English"
     "display_name": "English"
   },
   },
-  "wiki_management_home_page": "Wiki Management Home Page",
   "last_login": "Last login",
   "last_login": "Last login",
-  "anyone_with_the_link": "anyone with the link",
-  "only_me": "only me",
-  "only_inside_the_group": "only inside the group",
+  "wiki_management_home_page": "Wiki Management Home Page",
+  "public": "Public",
+  "anyone_with_the_link": "Anyone with the link",
+  "specified_users": "Specified users",
+  "only_me": "Only me",
+  "only_inside_the_group": "Only inside the group",
   "security_settings": {
   "security_settings": {
     "security_settings": "Security Settings",
     "security_settings": "Security Settings",
     "scope_of_page_disclosure": "Scope of page disclosure",
     "scope_of_page_disclosure": "Scope of page disclosure",
@@ -203,7 +205,7 @@
         "username_detail": "Specification of mappings for <code>username</code> when creating new users",
         "username_detail": "Specification of mappings for <code>username</code> when creating new users",
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "name_detail": "Specification of mappings for <code>name</code> when creating new users",
         "mapping_detail": "Specification of mappings for %s when creating new users",
         "mapping_detail": "Specification of mappings for %s when creating new users",
-        "register_1": "Contant to OIDC IdP Administrator",
+        "register_1": "Contact to OIDC IdP Administrator",
         "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
         "register_3": "Copy and paste your ClientID and Client Secret above",
         "register_3": "Copy and paste your ClientID and Client Secret above",
         "updated_oidc": "Succeeded to update OpenID Connect",
         "updated_oidc": "Succeeded to update OpenID Connect",
@@ -279,13 +281,35 @@
     "toggle_notification": "Updated setting of {{path}}",
     "toggle_notification": "Updated setting of {{path}}",
     "not_found_global_notification_triggerid": "Not found the global notification id"
     "not_found_global_notification_triggerid": "Not found the global notification id"
   },
   },
+  "full_text_search_management": {
+    "full_text_search_management": "Full Text Search Management",
+    "elasticsearch_management": "Elasticsearch management",
+    "connection_status": "Connection status",
+    "connection_status_label_unconfigured": "UNCONFIGURED",
+    "connection_status_label_connected": "CONNECTED",
+    "connection_status_label_disconnected": "DISCONNECTED",
+    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
+    "indices_status": "Indices Status",
+    "indices_status_label_normalized": "NORMALIZED",
+    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
+    "indices_summary": "Indices summary",
+    "reconnect": "Reconnect",
+    "reconnect_button": "Try to reconnect",
+    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
+    "normalize": "Normalize",
+    "normalize_button": "Normalize indices",
+    "normalize_description": "Click the button to repair broken indices.",
+    "rebuild": "Rebuild",
+    "rebuild_button": "Rebuild index",
+    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
+    "rebuild_description_2": "This may take a while."
+  },
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "mailer_setup_required":"<a href='/admin/app'>Email settings</a> are required to send.",
   "admin_top": {
   "admin_top": {
     "management_wiki": "Management Wiki",
     "management_wiki": "Management Wiki",
     "system_information": "System information",
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
     "wiki_administrator": "Only wiki administrator can access this page",
     "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
     "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
-    "list_of_installed_plugins": "List of installed plugins",
     "package_name": "Package name",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "specified_version": "Specified version",
     "installed_version": "Installed version",
     "installed_version": "Installed version",
@@ -498,6 +522,11 @@
   },
   },
   "importer_management": {
   "importer_management": {
     "import_data": "Import Data",
     "import_data": "Import Data",
+    "article": "Article",
+    "category": "Category",
+    "tag": "Tag",
+    "page": "Page",
+    "page_path": "Page Path",
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
     "import_growi_archive": "Import GROWI archive",
     "import_growi_archive": "Import GROWI archive",
@@ -636,7 +665,6 @@
     "integration_procedure": "Integration Procedure",
     "integration_procedure": "Integration Procedure",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy Settings",
     "custom_bot_without_proxy_settings": "Custom Bot without proxy Settings",
     "integration_failed":"Integration failed",
     "integration_failed":"Integration failed",
-    "official_bot_settings": "Official bot Settings",
     "reset": "Reset",
     "reset": "Reset",
     "reset_all_settings": "Reset all settings",
     "reset_all_settings": "Reset all settings",
     "delete_slackbot_settings": "Delete Slack Bot settings",
     "delete_slackbot_settings": "Delete Slack Bot settings",
@@ -709,7 +737,6 @@
   "slack_integration_legacy": {
   "slack_integration_legacy": {
     "slack_integration_legacy": "Legacy Slack Integration",
     "slack_integration_legacy": "Legacy Slack Integration",
     "alert_disabled": "This 'Slack Legacy Intenfation' has been currently disabled since <a href='/admin/slack-integration'>New settings</a> are enabled",
     "alert_disabled": "This 'Slack Legacy Intenfation' has been currently disabled since <a href='/admin/slack-integration'>New settings</a> are enabled",
-    "alert_Pd": "This 'Legacy Slack Integration' is currently disabled because <a href='/admin/slack-integration'>the new settings</a> is enabled.",
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
     "alert_deplicated": "This 'Legacy Slack Integration' is outdated and will be discontinued in the future. Use <a href='/admin/slack-integration'>the new settings</a> instead. "
   },
   },
   "user_management": {
   "user_management": {
@@ -819,29 +846,6 @@
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
       "force_update_parents_description": "Enable this option to force the addition of missing users to the ancestor groups if they exist after changing a parent group."
     }
     }
   },
   },
-  "full_text_search_management": {
-    "full_text_search_management": "Full Text Search Management",
-    "elasticsearch_management": "Elasticsearch management",
-    "connection_status": "Connection status",
-    "connection_status_label_unconfigured": "UNCONFIGURED",
-    "connection_status_label_connected": "CONNECTED",
-    "connection_status_label_disconnected": "DISCONNECTED",
-    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
-    "indices_status": "Indices Status",
-    "indices_status_label_normalized": "NORMALIZED",
-    "indices_status_label_unnormalized": "REBUILDING or BROKEN",
-    "indices_summary": "Indices summary",
-    "reconnect": "Reconnect",
-    "reconnect_button": "Try to reconnect",
-    "reconnect_description": "Click the button to try to reconnect to Elasticsearch.",
-    "normalize": "Normalize",
-    "normalize_button": "Normalize indices",
-    "normalize_description": "Click the button to repair broken indices.",
-    "rebuild": "Rebuild",
-    "rebuild_button": "Rebuild index",
-    "rebuild_description_1": "Click the button to rebuild index and add all page datas.",
-    "rebuild_description_2": "This may take a while."
-  },
   "audit_log_management": {
   "audit_log_management": {
     "audit_log": "Audit Log",
     "audit_log": "Audit Log",
     "audit_log_settings": "Audit Log Settings",
     "audit_log_settings": "Audit Log Settings",

+ 0 - 1
packages/app/public/static/locales/en_US/translation.json

@@ -35,7 +35,6 @@
   "add": "Add",
   "add": "Add",
   "Undo": "Undo",
   "Undo": "Undo",
   "Article": "Article",
   "Article": "Article",
-  "Page": "Page",
   "Page Path": "Page path",
   "Page Path": "Page path",
   "Category": "Category",
   "Category": "Category",
   "User": "User",
   "User": "User",

+ 27 - 21
packages/app/public/static/locales/ja_JP/admin.json

@@ -64,6 +64,9 @@
     "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting": "XSS(Cross Site Scripting)対策設定",
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "xss_prevent_setting_link": "マークダウン設定ページに移動",
     "callback_URL": "コールバックURL",
     "callback_URL": "コールバックURL",
+    "providerName": "プロバイダ名",
+    "issuerHost": "発行ホスト",
+    "scope": "範囲",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
     "desc_of_callback_URL": "{{AuthName}} プロバイダ側の設定で利用してください。",
     "authorization_endpoint": "認可エンドポイント",
     "authorization_endpoint": "認可エンドポイント",
     "token_endpoint": "トークンエンドポイント",
     "token_endpoint": "トークンエンドポイント",
@@ -210,13 +213,17 @@
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "name_detail": "新規ユーザー名(<code>name</code>)に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
         "mapping_detail": "新規ユーザーの{{target}}に関連付ける属性",
+        "register_1": "OIDC IdP Administrator へ接続します。",
+        "register_2": "OIDCアプリの認証コールバックURLを<code>%s</code>として登録します。",
+        "register_3": "上記のClientIDとClient Secretをコピー&ペーストしてください。",
         "updated_oidc": "OpenID Connect を更新しました",
         "updated_oidc": "OpenID Connect を更新しました",
         "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
         "Use discovered URL if empty": "データベース側の値が空の場合、\"Issuer Host\"から検出した値を利用します。"
       },
       },
       "how_to": {
       "how_to": {
         "google": "Google OAuth の設定方法",
         "google": "Google OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",
         "github": "GitHub OAuth の設定方法",
-        "twitter": "Twitter OAuth の設定方法"
+        "twitter": "Twitter OAuth の設定方法",
+        "oidc": "OIDC の設定方法"
       }
       }
     },
     },
     "form_item_name": {
     "form_item_name": {
@@ -311,7 +318,6 @@
     "system_information": "システム情報",
     "system_information": "システム情報",
     "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
     "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
     "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
     "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
-    "list_of_installed_plugins": "インストールされているプラグイン一覧",
     "package_name": "パッケージ名",
     "package_name": "パッケージ名",
     "specified_version": "指定バージョン",
     "specified_version": "指定バージョン",
     "installed_version": "インストールされているバージョン",
     "installed_version": "インストールされているバージョン",
@@ -522,25 +528,6 @@
     "upload_new_logo": "新しいロゴをアップロードする",
     "upload_new_logo": "新しいロゴをアップロードする",
     "delete_logo": "ロゴを削除"
     "delete_logo": "ロゴを削除"
   },
   },
-  "export_management": {
-    "export_archive_data": "データアーカイブ",
-    "exporting_collection_list": "エクスポート中のコレクション",
-    "exported_data_list": "エクスポートされたアーカイブリスト",
-    "export_collections": "コレクションのエクスポート",
-    "check_all": "全てにチェックを付ける",
-    "uncheck_all": "全てからチェックを外す",
-    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
-    "create_new_archive_data": "アーカイブデータの新規作成",
-    "export": "エクスポート",
-    "cancel": "キャンセル",
-    "file": "ファイル名",
-    "growi_version": "GROWI バージョン",
-    "collections": "コレクション",
-    "exported_at": "エクスポートされた時間",
-    "export_menu": "エクスポートメニュー",
-    "download": "ダウンロード",
-    "delete": "削除"
-  },
   "importer_management": {
   "importer_management": {
     "import_data": "データインポート",
     "import_data": "データインポート",
     "article": "記事",
     "article": "記事",
@@ -618,6 +605,25 @@
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
     "Directory_hierarchy_tag": "ディレクトリ階層タグ"
   },
   },
+  "export_management": {
+    "export_archive_data": "データアーカイブ",
+    "exporting_collection_list": "エクスポート中のコレクション",
+    "exported_data_list": "エクスポートされたアーカイブリスト",
+    "export_collections": "コレクションのエクスポート",
+    "check_all": "全てにチェックを付ける",
+    "uncheck_all": "全てからチェックを外す",
+    "desc_password_seed": "<p>ユーザーデータをバックアップ/リストアする場合、現在の <code>PASSWORD_SEED</code> を新しい GROWI システムにセットすることを忘れないでください。さもなくば、ユーザーがパスワードでログインできなくなります。<br><br><strong>ヒント:</strong><br>現在の <code>PASSWORD_SEED</code> は、エクスポートされる ZIP 中の <code>meta.json</code> に保存されます。</p>",
+    "create_new_archive_data": "アーカイブデータの新規作成",
+    "export": "エクスポート",
+    "cancel": "キャンセル",
+    "file": "ファイル名",
+    "growi_version": "GROWI バージョン",
+    "collections": "コレクション",
+    "exported_at": "エクスポートされた時間",
+    "export_menu": "エクスポートメニュー",
+    "download": "ダウンロード",
+    "delete": "削除"
+  },
   "external_notification": {
   "external_notification": {
     "external_notification": "外部ツールへの通知",
     "external_notification": "外部ツールへの通知",
     "enabled": "有効",
     "enabled": "有効",

+ 6 - 15
packages/app/public/static/locales/zh_CN/admin.json

@@ -7,6 +7,7 @@
   "User": "用户",
   "User": "用户",
   "Name": "姓名",
   "Name": "姓名",
   "Created": "创建",
   "Created": "创建",
+  "Page": "页面",
   "Edit": "编辑",
   "Edit": "编辑",
   "Description": "描述",
   "Description": "描述",
   "last_login": "上次登录",
   "last_login": "上次登录",
@@ -79,7 +80,7 @@
 		"client_secret": "客户机密",
 		"client_secret": "客户机密",
 		"updated_general_security_setting": "更新安全设置成功",
 		"updated_general_security_setting": "更新安全设置成功",
 		"setup_not_completed_yet": "安装尚未完成",
 		"setup_not_completed_yet": "安装尚未完成",
-		"guest_mode": {
+    "guest_mode": {
 			"deny": "拒绝(仅限注册用户)",
 			"deny": "拒绝(仅限注册用户)",
 			"readonly": "接受(来宾可以只读)"
 			"readonly": "接受(来宾可以只读)"
 		},
 		},
@@ -212,7 +213,7 @@
 				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
 				"username_detail": "Specification of mappings for <code>username</code> when creating new users",
 				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
 				"name_detail": "Specification of mappings for <code>name</code> when creating new users",
 				"mapping_detail": "Specification of mappings for %s when creating new users",
 				"mapping_detail": "Specification of mappings for %s when creating new users",
-				"register_1": "Contant to OIDC IdP Administrator",
+				"register_1": "Contact to OIDC IdP Administrator",
 				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
 				"register_2": "Register your OIDC App with \"Authorization callback URL\" as <code>%s</code>",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
 				"register_3": "Copy and paste your ClientID and Client Secret above",
 				"updated_oidc": "Succeeded to update OpenID Connect",
 				"updated_oidc": "Succeeded to update OpenID Connect",
@@ -238,6 +239,7 @@
 		}
 		}
   },
   },
   "notification_settings": {
   "notification_settings": {
+    "notification_settings": "通知设置",
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
 		"slack_incoming_configuration": "Slack Incoming Webhooks configuration",
 		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
 		"prioritize_webhook": "Prioritize incoming webhook than Slack App",
 		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
 		"prioritize_webhook_desc": "Check this option and GROWI use Incoming Webhooks even if Slack App settings are enabled.",
@@ -316,7 +318,6 @@
     "system_information": "系统信息",
     "system_information": "系统信息",
     "wiki_administrator": "只有wiki管理员可以访问此页",
     "wiki_administrator": "只有wiki管理员可以访问此页",
     "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
     "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-    "list_of_installed_plugins": "已安装插件列表",
     "package_name": "包名称",
     "package_name": "包名称",
     "specified_version": "指定版本",
     "specified_version": "指定版本",
     "installed_version": "已安装版本",
     "installed_version": "已安装版本",
@@ -400,9 +401,10 @@
     "aws_label": "AWS(S3)",
     "aws_label": "AWS(S3)",
     "local_label": "Local",
     "local_label": "Local",
     "gridfs_label": "MongoDB(GridFS)",
     "gridfs_label": "MongoDB(GridFS)",
+    "fixed_by_env_var": "这是由env var 修复的 <code>{{key}}={{value}}</code>.",
+    "file_upload": "This is for uploading file settings. If you complete file upload settings, file upload function, profile picture function etc will be enabled.",
     "ses_settings": "SES设置",
     "ses_settings": "SES设置",
     "test_connection": "测试邮件服务器连接",
     "test_connection": "测试邮件服务器连接",
-    "": "如果您没有SMTP设置,电子邮件将通过SES发送。您需要从电子邮件地址和生产设置进行验证。",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
     "change_setting": "注意:如果你更改此设置未完成,您将无法访问迄今为止上传的文件。",
     "region": "Region",
     "region": "Region",
     "bucket_name": "Bucket name",
     "bucket_name": "Bucket name",
@@ -475,17 +477,6 @@
       "expanded": "内容宽度100% "
       "expanded": "内容宽度100% "
     },
     },
     "theme": "主体",
     "theme": "主体",
-    "behavior": "行为",
-    "behavior_desc": {
-      "growi_text1": "<code>/page</code> and <code>/page/</code> 都显示同一页。",
-      "growi_text2": "<code>/nonexistent_page</code> 显示编辑表单",
-      "growi_text3": "如果使用GROWI增强布局,则所有页面都显示子页面列表",
-      "crowi_text1": "<code>/page</code> 显示页面",
-      "crowi_text2": "<code>/page/</code> 显示子页列表",
-      "crowi_text3": "如果portal应用于<code>/page/</code>,则会显示portal和子页面列表",
-      "crowi_text4": "<code>/nonexistent_page</code> 显示编辑表单<",
-      "crowi_text5": "<code>/nonexistent_page/</code> 子页列表"
-    },
     "theme_desc": {
     "theme_desc": {
       "light_and_dark": "明暗模式",
       "light_and_dark": "明暗模式",
       "unique": "只有一种模式"
       "unique": "只有一种模式"

+ 0 - 1
packages/app/public/static/locales/zh_CN/translation.json

@@ -117,7 +117,6 @@
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "GROWI.5.0_new_schema": "GROWI.5.0 new schema",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
   "See_more_detail_on_new_schema": "更多详情请见<a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'> {{title}}</a> <i class='icon-share-alt'></i> ",
 	"Markdown Settings": "Markdown设置",
 	"Markdown Settings": "Markdown设置",
-	"Notification Settings": "通知设置",
 	"external_account_management": "外部账户管理",
 	"external_account_management": "外部账户管理",
   "UserGroup": "用户组",
   "UserGroup": "用户组",
   "ChildUserGroup": "儿童用户组",
   "ChildUserGroup": "儿童用户组",

+ 0 - 2
packages/app/src/client/services/AdminHomeContainer.js

@@ -3,7 +3,6 @@ import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { toastError } from '../util/apiNotification';
 import { apiv3Get } from '../util/apiv3-client';
 import { apiv3Get } from '../util/apiv3-client';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
@@ -66,7 +65,6 @@ export default class AdminHomeContainer extends Container {
         nodeVersion: adminHomeParams.nodeVersion,
         nodeVersion: adminHomeParams.nodeVersion,
         npmVersion: adminHomeParams.npmVersion,
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
         yarnVersion: adminHomeParams.yarnVersion,
-        installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,

+ 51 - 0
packages/app/src/client/services/activate-plugin.ts

@@ -0,0 +1,51 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { GrowiPlugin } from '~/interfaces/plugin';
+import { initializeGrowiFacade } from '~/utils/growi-facade';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var pluginActivators: {
+    [key: string]: {
+      activate: () => void,
+      deactivate: () => void,
+    },
+  };
+}
+
+
+export type GrowiPluginManifestEntries = [growiPlugin: GrowiPlugin, manifest: any][];
+
+
+export class ActivatePluginService {
+
+  static async retrievePluginManifests(growiPlugins: GrowiPlugin[]): Promise<GrowiPluginManifestEntries> {
+    const entries: GrowiPluginManifestEntries = [];
+
+    growiPlugins.forEach(async(growiPlugin) => {
+      const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
+      const customManifestStr: string = await readFileSync(manifestPath, 'utf-8');
+      entries.push([growiPlugin, JSON.parse(customManifestStr)]);
+    });
+
+    return entries;
+  }
+
+  static activateAll(): void {
+    initializeGrowiFacade();
+
+    const { pluginActivators } = window;
+
+    if (pluginActivators == null) {
+      return;
+    }
+
+    Object.entries(pluginActivators).forEach(([, activator]) => {
+      activator.activate();
+    });
+  }
+
+}

+ 6 - 52
packages/app/src/client/util/apiNotification.js

@@ -1,53 +1,7 @@
-// show API error/sucess toastr
+import { legacy } from './toastr';
 
 
-import * as toastr from 'toastr';
-import { toArrayIfNot } from '~/utils/array-utils';
-
-const toastrOption = {
-  error: {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '0',
-  },
-  success: {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '3000',
-  },
-  warning: {
-    closeButton: true,
-    progressBar: true,
-    newestOnTop: false,
-    showDuration: '100',
-    hideDuration: '100',
-    timeOut: '6000',
-  },
-};
-
-// accepts both a single error and an array of errors
-export const toastError = (err, header = 'Error', option = toastrOption.error) => {
-  const errs = toArrayIfNot(err);
-
-  if (err.length === 0) {
-    toastr.error('', header);
-  }
-
-  for (const err of errs) {
-    toastr.error(err.message || err, header, option);
-  }
-};
-
-// only accepts a single item
-export const toastSuccess = (body, header = 'Success', option = toastrOption.success) => {
-  toastr.success(body, header, option);
-};
-
-export const toastWarning = (body, header = 'Warning', option = toastrOption.warning) => {
-  toastr.warning(body, header, option);
-};
+// DEPRECATED -- 2022.12.07 Yuki Takei
+// Use methods from './toastr.ts' instead
+export const toastError = legacy.toastError;
+export const toastSuccess = legacy.toastSuccess;
+export const toastWarning = legacy.toastWarning;

+ 91 - 0
packages/app/src/client/util/toastr.ts

@@ -0,0 +1,91 @@
+import { toast, ToastContent, ToastOptions } from 'react-toastify';
+import * as toastrLegacy from 'toastr';
+
+import { toArrayIfNot } from '~/utils/array-utils';
+
+
+export const toastErrorOption: ToastOptions = {
+  autoClose: 0,
+  closeButton: true,
+};
+export const toastError = (err: string | Error | Error[], option: ToastOptions = toastErrorOption): void => {
+  const errs = toArrayIfNot(err);
+
+  if (errs.length === 0) {
+    return;
+  }
+
+  for (const err of errs) {
+    const message = (typeof err === 'string') ? err : err.message;
+    toast.error(message || err, option);
+  }
+};
+
+export const toastSuccessOption: ToastOptions = {
+  autoClose: 2000,
+  closeButton: true,
+};
+export const toastSuccess = (content: ToastContent, option: ToastOptions = toastSuccessOption): void => {
+  toast.success(content, option);
+};
+
+export const toastWarningOption: ToastOptions = {
+  autoClose: 5000,
+  closeButton: true,
+};
+export const toastWarning = (content: ToastContent, option: ToastOptions = toastWarningOption): void => {
+  toastrLegacy.warning(content, option);
+};
+
+
+const toastrLegacyOption = {
+  error: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '0',
+  },
+  success: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '3000',
+  },
+  warning: {
+    closeButton: true,
+    progressBar: true,
+    newestOnTop: false,
+    showDuration: '100',
+    hideDuration: '100',
+    timeOut: '6000',
+  },
+};
+
+export const legacy = {
+  // accepts both a single error and an array of errors
+  toastError: (err: string | Error | Error[], header = 'Error', option = toastrLegacyOption.error): void => {
+    const errs = toArrayIfNot(err);
+
+    if (errs.length === 0) {
+      toastrLegacy.error('', header);
+    }
+
+    for (const err of errs) {
+      const message = (typeof err === 'string') ? err : err.message;
+      toastrLegacy.error(message || err, header, option);
+    }
+  },
+
+  // only accepts a single item
+  toastSuccess: (body: string, header = 'Success', option = toastrLegacyOption.success): void => {
+    toastrLegacy.success(body, header, option);
+  },
+
+  toastWarning: (body: string, header = 'Warning', option = toastrLegacyOption.warning): void => {
+    toastrLegacy.warning(body, header, option);
+  },
+};

+ 0 - 8
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -15,7 +15,6 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 
 
 import EnvVarsTable from './EnvVarsTable';
 import EnvVarsTable from './EnvVarsTable';
-import InstalledPluginTable from './InstalledPluginTable';
 import SystemInfomationTable from './SystemInfomationTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 
 const logger = loggerFactory('growi:admin');
 const logger = loggerFactory('growi:admin');
@@ -85,13 +84,6 @@ const AdminHome = (props) => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row mb-5">
-        <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
-          <InstalledPluginTable />
-        </div>
-      </div>
-
       <div className="row mb-5">
       <div className="row mb-5">
         <div className="col-md-12">
         <div className="col-md-12">
           <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>
           <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>

+ 0 - 55
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -1,55 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const InstalledPluginTable = (props) => {
-  const { t } = useTranslation();
-  const { adminHomeContainer } = props;
-
-  const { installedPlugins } = adminHomeContainer.state;
-
-  if (installedPlugins == null) {
-    return <></>;
-  }
-
-  return (
-    <table data-testid="admin-installed-plugin-table" className="table table-bordered">
-      <thead>
-        <tr>
-          <th className="text-center">{t('admin:admin_top.package_name')}</th>
-          <th className="text-center">{t('admin:admin_top.specified_version')}</th>
-          <th className="text-center">{t('admin:admin_top.installed_version')}</th>
-        </tr>
-      </thead>
-      <tbody>
-        {adminHomeContainer.state.installedPlugins.map((plugin) => {
-          return (
-            <tr key={plugin.name}>
-              <td>{plugin.name}</td>
-              <td data-hide-in-vrt className="text-center">{plugin.requiredVersion}</td>
-              <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
-  );
-
-};
-
-InstalledPluginTable.propTypes = {
-  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
-};
-
-
-/**
- * Wrapper component for using unstated
- */
-const InstalledPluginTableWrapper = withUnstatedContainers(InstalledPluginTable, [AdminHomeContainer]);
-
-export default InstalledPluginTableWrapper;

+ 21 - 0
packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+type Props = {
+  onClick: () => void,
+  disabled: boolean,
+
+}
+
+export const AdminInstallButtonRow = (props: Props): JSX.Element => {
+  // TODO: const { t } = useTranslation('admin');
+
+  return (
+    <div className="row my-3">
+      <div className="mx-auto">
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>Install</button>
+      </div>
+    </div>
+  );
+};

+ 3 - 0
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -36,6 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{           'Plugins Extension'}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */
       /* eslint-enable no-multi-spaces, max-len */
@@ -91,6 +92,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
           && (
@@ -140,6 +142,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
             {/* eslint-enable no-multi-spaces */}
             {/* eslint-enable no-multi-spaces */}
           </span>
           </span>
         </button>
         </button>

+ 4 - 1
packages/app/src/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -54,9 +54,12 @@ const ElasticsearchManagement = () => {
             setIsConfigured(false);
             setIsConfigured(false);
           }
           }
         }
         }
+        toastError(errors as Error[]);
+      }
+      else {
+        toastError(errors as Error);
       }
       }
 
 
-      toastError(errors);
     }
     }
     finally {
     finally {
       setIsInitialized(true);
       setIsInitialized(true);

+ 13 - 0
packages/app/src/components/Admin/PluginsExtension/Loading.js

@@ -0,0 +1,13 @@
+import {
+  Spinner,
+} from 'reactstrap';
+
+const Loading = () => {
+  return (
+    <Spinner className='d-flex justify-content-center aligh-items-center'>
+      Loading...
+    </Spinner>
+  );
+};
+
+export default Loading;

+ 68 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss

@@ -0,0 +1,68 @@
+// TODO: Rewrite according to guidelines
+.plugin_card :global {
+
+  .switch__label {
+    position: relative;
+    display: inline-block;
+    width: 50px;
+  }
+  .switch__content {
+    position: relative;
+    display: block;
+    height: 31px;
+    overflow: hidden;
+    cursor: pointer;
+    border-radius: 30px;
+  }
+  .switch__content:before {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: block;
+    width: calc(100% - 3px);
+    height: calc(100% - 3px);
+    content: '';
+    background-color: #fff;
+    border: 1.5px solid #E5E5EA;
+    border-radius: 30px;
+  }
+  .switch__content:after {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    display: block;
+    width: 0;
+    height: 0;
+    content: '';
+    background-color: transparent;
+    border-radius: 30px;
+    transition: all .5s;
+  }
+  .switch__input {
+    display: none;
+  }
+
+  .switch__circle {
+    position: absolute;
+    top: 2px;
+    left: 2px;
+    display: block;
+    width: 27px;
+    height: 27px;
+    background-color: #fff;
+    border-radius: 20px;
+    box-shadow: 0 2px 6px #999;
+    transition: all .5s;
+  }
+  .switch__input:checked ~ .switch__circle {
+    left: 21px;
+  }
+
+  .switch__input:checked ~ .switch__content:after {
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: #0078D7;
+  }
+}

+ 85 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx

@@ -0,0 +1,85 @@
+// import { faCircleArrowDown, faCircleCheck } from '@fortawesome/free-solid-svg-icons';
+// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import Link from 'next/link';
+
+import styles from './PluginCard.module.scss';
+
+
+type Props = {
+  name: string,
+  url: string,
+  description: string,
+}
+
+export const PluginCard = (props: Props): JSX.Element => {
+  const {
+    name, url, description,
+  } = props;
+  // const [isEnabled, setIsEnabled] = useState(true);
+
+  // const checkboxHandler = useCallback(() => {
+  //   setIsEnabled(false);
+  // }, []);
+
+  return (
+    <div className="card shadow border-0" key={name}>
+      <div className="card-body px-5 py-4 mt-3">
+        <div className="row mb-3">
+          <div className="col-9">
+            <h2 className="card-title h3 border-bottom pb-2 mb-3">
+              <Link href={`${url}`}>{name}</Link>
+            </h2>
+            <p className="card-text text-muted">{description}</p>
+          </div>
+          <div className='col-3'>
+            <div className={`${styles.plugin_card}`}>
+              <div className="switch">
+                <label className="switch__label">
+                  <input type="checkbox" className="switch__input" checked/>
+                  <span className="switch__content"></span>
+                  <span className="switch__circle"></span>
+                </label>
+              </div>
+            </div>
+            {/* <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+              <input
+                type="checkbox"
+                className="custom-control-input border-0"
+                checked={isEnabled}
+                onChange={checkboxHandler}
+              />
+              <label className="custom-control-label align-center"></label>
+            </div> */}
+            {/* <Image className="mx-auto" alt="GitHub avator image" src={owner.avatar_url} width={250} height={250} /> */}
+          </div>
+        </div>
+        <div className="row">
+          <div className="col-12 d-flex flex-wrap gap-2">
+            {/* {topics?.map((topic: string) => {
+              return (
+                <span key={`${name}-${topic}`} className="badge rounded-1 mp-bg-light-blue text-dark fw-normal">
+                  {topic}
+                </span>
+              );
+            })} */}
+          </div>
+        </div>
+      </div>
+      <div className="card-footer px-5 border-top-0 mp-bg-light-blue">
+        <p className="d-flex justify-content-between align-self-center mb-0">
+          <span>
+            {/* {owner.login === 'weseek' ? <FontAwesomeIcon icon={faCircleCheck} className="me-1 text-primary" /> : <></>}
+
+            <a href={owner.html_url} target="_blank" rel="noreferrer">
+              {owner.login}
+            </a> */}
+          </span>
+          {/* <span>
+            <FontAwesomeIcon icon={faCircleArrowDown} className="me-1" /> {stargazersCount}
+          </span> */}
+        </p>
+      </div>
+    </div>
+  );
+};

+ 91 - 0
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -0,0 +1,91 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import AdminInstallButtonRow from '../Common/AdminUpdateButtonRow';
+// TODO: error notification (toast, loggerFactory)
+// TODO: i18n
+
+export const PluginInstallerForm = (): JSX.Element => {
+  // const { t } = useTranslation('admin');
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'pluginInstallerForm[url]': { value: url },
+      // 'pluginInstallerForm[ghBranch]': { value: ghBranch },
+      // 'pluginInstallerForm[ghTag]': { value: ghTag },
+    } = formData;
+
+    const pluginInstallerForm = {
+      url,
+      // ghBranch,
+      // ghTag,
+    };
+
+    try {
+      await apiv3Post('/plugins-extension', { pluginInstallerForm });
+      toastSuccess('Plugin Install Successed!');
+    }
+    catch (err) {
+      toastError(err);
+      // logger.error(err);
+    }
+  }, []);
+
+  return (
+    <form role="form" onSubmit={submitHandler}>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">GitHub Repository URL</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            // defaultValue={adminAppContainer.state.title || ''}
+            name="pluginInstallerForm[url]"
+            placeholder="https://github.com/weseek/growi-plugin-lsx"
+            required
+          />
+          <p className="form-text text-muted">You can install plugins by inputting the GitHub URL.</p>
+          {/* <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p> */}
+        </div>
+      </div>
+      {/* <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">branch</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghBranch]"
+            placeholder="main"
+          />
+          <p className="form-text text-muted">branch name</p>
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">tag</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghTag]"
+            placeholder="tags"
+          />
+          <p className="form-text text-muted">tag name</p>
+        </div>
+      </div> */}
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="submit" className="btn btn-primary">Install</button>
+        </div>
+      </div>
+    </form>
+  );
+};

+ 58 - 0
packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import type { SearchResultItem } from '~/interfaces/github-api';
+import { useInstalledPlugins } from '~/stores/useInstalledPlugins';
+
+import Loading from './Loading';
+import { PluginCard } from './PluginCard';
+import { PluginInstallerForm } from './PluginInstallerForm';
+
+
+// TODO: i18n
+
+export const PluginsExtensionPageContents = (): JSX.Element => {
+  // const { data, error } = useInstalledPlugins();
+
+  // if (data == null) {
+  //   return <Loading />;
+  // }
+
+  return (
+    <div>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugin Installer</h2>
+          <PluginInstallerForm />
+        </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugins</h2>
+          <div className="d-grid gap-5">
+            <PluginCard
+              name={'growi-plugin-templates-for-office'}
+              url={'https://github.com/weseek/growi-plugin-templates-for-office'}
+              description={'GROWI markdown templates for office.'}
+            />
+            {/* <PluginCard
+              name={'growi-plugin-theme-welcome-to-fumiya-room'}
+              url={'https://github.com/weseek/growi-plugin-theme-welcome-to-fumiya-room'}
+              description={'Welcome to fumiya\'s room! This is very very "latest" design...'}
+            /> */}
+            <PluginCard
+              name={'growi-plugin-copy-code-to-clipboard'}
+              url={'https://github.com/weseek/growi-plugin-copy-code-to-clipboard'}
+              description={'Add copy button on code blocks.'}
+            />
+            {/* {data?.items.map((item: SearchResultItem) => {
+              return <PluginCard key={item.name} {...item} />;
+            })} */}
+          </div>
+        </div>
+      </div>
+
+    </div>
+  );
+};

+ 1 - 1
packages/app/src/components/Common/Dropdown/PageItemControl.tsx

@@ -248,7 +248,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     <DropdownMenu
     <DropdownMenu
       data-testid="page-item-control-menu"
       data-testid="page-item-control-menu"
       positionFixed
       positionFixed
-      modifiers={{ preventOverflow: { boundariesElement: undefined } }}
+      modifiers={{ preventOverflow: { boundariesElement: 'viewport' } }}
       right={alignRight}
       right={alignRight}
     >
     >
       {contents}
       {contents}

+ 2 - 2
packages/app/src/components/CreateTemplateModal.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import React from 'react';
 
 
 import { pathUtils } from '@growi/core';
 import { pathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -42,7 +42,7 @@ const CreateTemplateModal = (props) => {
   }
   }
 
 
   return (
   return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal" className="grw-create-page">
+    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         {t('template.modal_label.Create/Edit Template Page')}
         {t('template.modal_label.Create/Edit Template Page')}
       </ModalHeader>
       </ModalHeader>

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

@@ -59,7 +59,7 @@ const EmptyTrashModal: FC = () => {
   };
   };
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
       <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
       <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
         <i className="icon-fw icon-fire"></i>
         <i className="icon-fw icon-fire"></i>
         {t('modal_empty.empty_the_trash')}
         {t('modal_empty.empty_the_trash')}

+ 2 - 2
packages/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -2,7 +2,7 @@ import React, { FC } from 'react';
 
 
 import { HasObjectId } from '@growi/core';
 import { HasObjectId } from '@growi/core';
 
 
-import { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
 
 
 import InAppNotificationElm from './InAppNotificationElm';
 import InAppNotificationElm from './InAppNotificationElm';
 
 
@@ -26,7 +26,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     );
     );
   }
   }
 
 
-  const notifications = inAppNotificationData.docs;
+  const notifications = inAppNotificationData.docs.filter((notification) => { return notification.parsedSnapshot != null });
 
 
   return (
   return (
     <>
     <>

+ 3 - 6
packages/app/src/components/InAppNotification/PageNotification/PageModelNotification.tsx

@@ -6,10 +6,9 @@ import { HasObjectId } from '@growi/core';
 import { PagePathLabel } from '@growi/ui';
 import { PagePathLabel } from '@growi/ui';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
 
 
-import { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
-import { IInAppNotification } from '~/interfaces/in-app-notification';
+import type { IInAppNotificationOpenable } from '~/client/interfaces/in-app-notification-openable';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
 
 
-import { parseSnapshot } from '../../../models/serializers/in-app-notification-snapshot/page';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 import FormattedDistanceDate from '../../FormattedDistanceDate';
 
 
 interface Props {
 interface Props {
@@ -27,8 +26,6 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
 
 
   const router = useRouter();
   const router = useRouter();
 
 
-  const snapshot = parseSnapshot(notification.snapshot);
-
   // publish open()
   // publish open()
   useImperativeHandle(ref, () => ({
   useImperativeHandle(ref, () => ({
     open() {
     open() {
@@ -45,7 +42,7 @@ const PageModelNotification: ForwardRefRenderFunction<IInAppNotificationOpenable
   return (
   return (
     <div className="p-2 overflow-hidden">
     <div className="p-2 overflow-hidden">
       <div className="text-truncate">
       <div className="text-truncate">
-        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={snapshot.path} />
+        <b>{actionUsers}</b> {actionMsg} <PagePathLabel path={notification.parsedSnapshot?.path ?? ''} />
       </div>
       </div>
       <i className={`${actionIcon} mr-2`} />
       <i className={`${actionIcon} mr-2`} />
       <FormattedDistanceDate
       <FormattedDistanceDate

+ 1 - 1
packages/app/src/components/Layout/AdminLayout.tsx

@@ -27,7 +27,7 @@ const AdminLayout = ({
   return (
   return (
     <RawLayout>
     <RawLayout>
       <div className={`admin-page ${styles['admin-page']}`}>
       <div className={`admin-page ${styles['admin-page']}`}>
-        <GrowiNavbar />
+        <GrowiNavbar isGlobalSearchHidden={true} />
 
 
         <header className="py-0 container-fluid">
         <header className="py-0 container-fluid">
           <h1 className="title px-3">{componentTitle}</h1>
           <h1 className="title px-3">{componentTitle}</h1>

+ 2 - 0
packages/app/src/components/Layout/RawLayout.tsx

@@ -1,6 +1,7 @@
 import React, { ReactNode, useState } from 'react';
 import React, { ReactNode, useState } from 'react';
 
 
 import Head from 'next/head';
 import Head from 'next/head';
+import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 
 import { useGrowiTheme } from '~/stores/context';
 import { useGrowiTheme } from '~/stores/context';
@@ -46,6 +47,7 @@ export const RawLayout = ({ children, className }: Props): JSX.Element => {
         <GrowiThemeProvider theme={growiTheme} colorScheme={colorScheme}>
         <GrowiThemeProvider theme={growiTheme} colorScheme={colorScheme}>
           <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
           <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
             {children}
             {children}
+            <ToastContainer theme={colorScheme} />
           </div>
           </div>
         </GrowiThemeProvider>
         </GrowiThemeProvider>
       </NextThemesProvider>
       </NextThemesProvider>

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

@@ -1,6 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import React, { useState, useEffect, useCallback } from 'react';
 
 
-import { isPopulated, IUser } from '@growi/core';
+import { isPopulated, IUser, pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -43,6 +43,7 @@ import type { SubNavButtonsProps } from './SubNavButtons';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import AuthorInfoStyles from './AuthorInfo.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
+const { isUsersHomePage } = pagePathUtils;
 
 
 const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 const AuthorInfoSkeleton = () => <Skeleton additionalClass={`${AuthorInfoStyles['grw-author-info-skeleton']} py-1`} />;
 
 
@@ -304,11 +305,13 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         router.push(path);
         router.push(path);
       }
       }
       else {
       else {
-        reload();
+        // Do not use "router.push(currentPathname)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+        // See: https://github.com/weseek/growi/pull/7061
+        router.reload();
       }
       }
     };
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [openDeleteModal, reload, router]);
+  }, [openDeleteModal, router]);
 
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     await updateContentWidth(pageId, value);
     await updateContentWidth(pageId, value);
@@ -378,7 +381,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               />
               />
             )}
             )}
           </div>
           </div>
-          { (isAbleToShowPageAuthors && !isCompactMode) && (
+          { (isAbleToShowPageAuthors && !isCompactMode && !isUsersHomePage(path ?? '')) && (
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
             <ul className={`${AuthorInfoStyles['grw-author-info']} text-nowrap border-left d-none d-lg-block d-edit-none py-2 pl-4 mb-0 ml-3`}>
               <li className="pb-1">
               <li className="pb-1">
                 { currentPage != null
                 { currentPage != null

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

@@ -136,7 +136,13 @@ const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
 
 
 GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
 GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
 
 
-export const GrowiNavbar = (): JSX.Element => {
+type Props = {
+  isGlobalSearchHidden?: boolean
+}
+
+export const GrowiNavbar = (props: Props): JSX.Element => {
+
+  const { isGlobalSearchHidden } = props;
 
 
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
 
 
@@ -169,7 +175,7 @@ export const GrowiNavbar = (): JSX.Element => {
       </ul>
       </ul>
 
 
       <div className="grw-global-search-container position-absolute">
       <div className="grw-global-search-container position-absolute">
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
+        { !isGlobalSearchHidden && isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
           <GlobalSearch />
           <GlobalSearch />
         ) }
         ) }
       </div>
       </div>

+ 14 - 1
packages/app/src/components/Page.tsx

@@ -25,6 +25,7 @@ import {
   useCurrentPageTocNode,
   useCurrentPageTocNode,
   useIsMobile,
   useIsMobile,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import RevisionRenderer from './Page/RevisionRenderer';
 import RevisionRenderer from './Page/RevisionRenderer';
@@ -43,6 +44,7 @@ declare global {
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
 
+
 const logger = loggerFactory('growi:Page');
 const logger = loggerFactory('growi:Page');
 
 
 
 
@@ -63,7 +65,7 @@ export const Page = (props) => {
   const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
-  const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
   const { open: openHandsontableModal } = useHandsontableModal();
@@ -71,6 +73,17 @@ export const Page = (props) => {
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
 
 
 
 
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
   useEffect(() => {
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps
   // eslint-disable-next-line react-hooks/exhaustive-deps

+ 1 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -118,7 +118,7 @@ const CopyDropdown = (props) => {
           <span id={dropdownToggleId}>{children}</span>
           <span id={dropdownToggleId}>{children}</span>
         </DropdownToggle>
         </DropdownToggle>
 
 
-        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: undefined } }}>
+        <DropdownMenu positionFixed modifiers={{ preventOverflow: { boundariesElement: 'viewport' } }}>
 
 
           <div className="d-flex align-items-center justify-content-between">
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
             <DropdownItem header className="px-3">

+ 28 - 1
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useEffect, useMemo } from 'react';
 
 
 import { pagePathUtils } from '@growi/core';
 import { pagePathUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -6,12 +6,15 @@ import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 import { Link } from 'react-scroll';
 
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
 import {
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
 
 
 import CountBadge from '../Common/CountBadge';
 import CountBadge from '../Common/CountBadge';
 import { ContentLinkButtonsProps } from '../ContentLinkButtons';
 import { ContentLinkButtonsProps } from '../ContentLinkButtons';
@@ -44,10 +47,34 @@ const PageView = React.memo((): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+  const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
+  const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
 
 
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
   const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
 
 
+  const { data: socket } = useGlobalSocket();
+
+  const setLatestRemotePageData = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    mutateRemoteRevisionId(s2cMessagePageUpdated.revisionId);
+    mutateRemoteRevisionLastUpdateUser(s2cMessagePageUpdated.remoteLastUpdateUser);
+  }, [mutateRemoteRevisionId, mutateRemoteRevisionLastUpdateUser]);
+
+  // listen socket for someone updating this page
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, setLatestRemotePageData);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, setLatestRemotePageData);
+    };
+
+  }, [setLatestRemotePageData, socket]);
+
   return (
   return (
     <div className="d-flex flex-column flex-lg-row">
     <div className="d-flex flex-column flex-lg-row">
 
 

+ 9 - 11
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -21,25 +21,23 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
   }
   }
 
 
   const isTagsEmpty = tags.length === 0;
   const isTagsEmpty = tags.length === 0;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
-        {tag}
-      </a>
-    );
-  });
 
 
   return (
   return (
     <>
     <>
-      {tagElements}
-
+      {tags.map((tag) => {
+        return (
+          <a key={tag} href={`/_search?q=tag:${tag}`} className="grw-tag-label badge badge-secondary mr-2">
+            {tag}
+          </a>
+        );
+      })}
       <div id="edit-tags-btn-wrapper-for-tooltip">
       <div id="edit-tags-btn-wrapper-for-tooltip">
         <a
         <a
-          className={`btn btn-link btn-edit-tags p-0 text-muted d-flex ${isTagsEmpty ? 'no-tags' : ''} ${isGuestUser ? 'disabled' : ''}`}
+          className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isGuestUser && 'disabled'}`}
           onClick={openEditorHandler}
           onClick={openEditorHandler}
         >
         >
           { isTagsEmpty && <>{ t('Add tags for this page') }</>}
           { isTagsEmpty && <>{ t('Add tags for this page') }</>}
-          <i className="ml-1 icon-plus"></i>
+          <i className={`icon-plus ${isTagsEmpty && 'ml-1'}`}/>
         </a>
         </a>
       </div>
       </div>
       {isGuestUser && (
       {isGuestUser && (

+ 1 - 1
packages/app/src/components/Page/TagLabels.tsx

@@ -37,7 +37,7 @@ export const TagLabels:FC<Props> = (props: Props) => {
   return (
   return (
     <>
     <>
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
       <div className={`${styles['grw-tag-labels']} grw-tag-labels d-flex align-items-center`} data-testid="grw-tag-labels">
-        <i className="tag-icon icon-tag mr-2"></i>
+        <i className="tag-icon icon-tag mr-2"/>
         <RenderTagLabels
         <RenderTagLabels
           tags={tags}
           tags={tags}
           openEditorModal={openEditorModal}
           openEditorModal={openEditorModal}

+ 1 - 1
packages/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -224,7 +224,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
   };
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpen} toggle={close}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         { t('fix_page_grant.modal.title') }
         { t('fix_page_grant.modal.title') }
       </ModalHeader>
       </ModalHeader>

+ 13 - 11
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useCallback } from 'react';
 
 
 import { UserPicture } from '@growi/ui';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
 import { format } from 'date-fns';
@@ -32,27 +32,25 @@ export const TrashPageAlert = (): JSX.Element => {
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
 
 
-  if (!isTrashPage) {
-    return <></>;
-  }
-
 
 
   const deleteUser = pageData?.deleteUser;
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
   const revisionId = pageData?.revision?._id;
   const revisionId = pageData?.revision?._id;
 
 
 
 
-  function openPutbackPageModalHandler() {
+  const openPutbackPageModalHandler = useCallback(() => {
     if (pageId === undefined || pagePath === undefined) {
     if (pageId === undefined || pagePath === undefined) {
       return;
       return;
     }
     }
     const putBackedHandler = () => {
     const putBackedHandler = () => {
-      router.push(`/${pageId}`);
+      // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+      // See: https://github.com/weseek/growi/pull/7054
+      router.reload();
     };
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }
+  }, [openPutBackPageModal, pageId, pagePath, router]);
 
 
-  function openPageDeleteModalHandler() {
+  const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
       return;
       return;
     }
     }
@@ -65,9 +63,9 @@ export const TrashPageAlert = (): JSX.Element => {
       meta: pageInfo,
       meta: pageInfo,
     };
     };
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }
+  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId]);
 
 
-  function renderTrashPageManagementButtons() {
+  const renderTrashPageManagementButtons = useCallback(() => {
     return (
     return (
       <>
       <>
         <button
         <button
@@ -88,6 +86,10 @@ export const TrashPageAlert = (): JSX.Element => {
         </button>
         </button>
       </>
       </>
     );
     );
+  }, [openPageDeleteModalHandler, openPutbackPageModalHandler, pageInfo?.isAbleToDeleteCompletely, t]);
+
+  if (!isTrashPage) {
+    return <></>;
   }
   }
 
 
   return (
   return (

+ 4 - 2
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -233,6 +233,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     );
     );
   }, []);
   }, []);
 
 
+  const onChangeHandler = useCallback((newValue: string) => setComment(newValue), []);
+
   const renderReady = () => {
   const renderReady = () => {
     const commentPreview = getCommentHtml();
     const commentPreview = getCommentHtml();
 
 
@@ -269,10 +271,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <TabPane tabId="comment_editor">
             <TabPane tabId="comment_editor">
               <Editor
               <Editor
                 ref={editorRef}
                 ref={editorRef}
-                value={comment}
+                value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
                 isUploadable={isUploadable}
                 isUploadableFile={isUploadableFile}
                 isUploadableFile={isUploadableFile}
-                onChange={setComment}
+                onChange={onChangeHandler}
                 onUpload={uploadHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
                 isComment

+ 7 - 2
packages/app/src/components/PageCreateModal.jsx

@@ -11,7 +11,6 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { usePageCreateModal } from '~/stores/modal';
@@ -19,6 +18,8 @@ import { EditorMode, useEditorMode } from '~/stores/ui';
 
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 
+import styles from './PageCreateModal.module.scss';
+
 const {
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
 } = pagePathUtils;
 } = pagePathUtils;
@@ -54,6 +55,10 @@ const PageCreateModal = () => {
     }
     }
   }, [isOpened, pathname, isCreatable]);
   }, [isOpened, pathname, isCreatable]);
 
 
+  useEffect(() => {
+    setTodayInput1(t('Memo'));
+  }, [t]);
+
   const checkIsUsersHomePageDebounce = useMemo(() => {
   const checkIsUsersHomePageDebounce = useMemo(() => {
     const checkIsUsersHomePage = () => {
     const checkIsUsersHomePage = () => {
       setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
       setIsMatchedWithUserHomePagePath(isUsersHomePage(pageNameInput));
@@ -315,7 +320,7 @@ const PageCreateModal = () => {
       isOpen={isOpened}
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
       toggle={() => closeCreateModal()}
       data-testid="page-create-modal"
       data-testid="page-create-modal"
-      className="grw-create-page"
+      className={`grw-create-page ${styles['grw-create-page']}`}
       autoFocus={false}
       autoFocus={false}
     >
     >
       <ModalHeader tag="h4" toggle={() => closeCreateModal()} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={() => closeCreateModal()} className="bg-primary text-light">

+ 1 - 1
packages/app/src/styles/_create-page.scss → packages/app/src/components/PageCreateModal.module.scss

@@ -1,4 +1,4 @@
-.grw-create-page {
+.grw-create-page :global {
   .page-today-input1 {
   .page-today-input1 {
     width: 60px;
     width: 60px;
   }
   }

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

@@ -281,7 +281,7 @@ const PageDeleteModal: FC = () => {
   };
   };
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal">
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         {headerContent()}
         {headerContent()}
       </ModalHeader>
       </ModalHeader>

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

@@ -151,7 +151,7 @@ const PageDuplicateModal = (): JSX.Element => {
   }, [isOpened]);
   }, [isOpened]);
 
 
 
 
-  const bodyContent = () => {
+  const renderBodyContent = () => {
     if (!isOpened || page == null) {
     if (!isOpened || page == null) {
       return <></>;
       return <></>;
     }
     }
@@ -238,7 +238,7 @@ const PageDuplicateModal = (): JSX.Element => {
     );
     );
   };
   };
 
 
-  const footerContent = () => {
+  const renderFooterContent = () => {
     if (!isOpened || page == null) {
     if (!isOpened || page == null) {
       return <></>;
       return <></>;
     }
     }
@@ -268,11 +268,10 @@ const PageDuplicateModal = (): JSX.Element => {
         { t('modal_duplicate.label.Duplicate page') }
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
-        {bodyContent()}
+        {renderBodyContent()}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
-        {footerContent()}
+        {renderFooterContent()}
       </ModalFooter>
       </ModalFooter>
     </Modal>
     </Modal>
   );
   );

+ 66 - 7
packages/app/src/components/PageEditor.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useEffect, useMemo, useRef, useState,
   useCallback, useEffect, useMemo, useRef, useState,
 } from 'react';
 } from 'react';
 
 
+
 import EventEmitter from 'events';
 import EventEmitter from 'events';
 
 
 import {
 import {
@@ -13,10 +14,11 @@ import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
 import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { useSaveOrUpdate } from '~/client/services/page-operation';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import { OptionsToSave } from '~/interfaces/page-operation';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
 import {
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
@@ -24,6 +26,7 @@ import {
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsEnabledUnsavedWarning,
+  useIsConflict,
   useEditingMarkdown,
   useEditingMarkdown,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
@@ -32,6 +35,8 @@ import {
   EditorMode,
   EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -80,7 +85,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
   const { data: isUploadableImage } = useIsUploadableImage();
 
 
-  const { data: rendererOptions } = usePreviewOptions();
+  const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
   const saveOrUpdate = useSaveOrUpdate();
 
 
@@ -107,9 +112,63 @@ const PageEditor = React.memo((): JSX.Element => {
 
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
 
+  const { data: socket } = useGlobalSocket();
+
+  const { mutate: mutateIsConflict } = useIsConflict();
+
+
   const editorRef = useRef<IEditorMethods>(null);
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
 
+  const checkIsConflict = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+
+    const isConflict = markdownToPreview !== s2cMessagePageUpdated.revisionBody;
+
+    mutateIsConflict(isConflict);
+
+  }, [markdownToPreview, mutateIsConflict]);
+
+  useEffect(() => {
+    markdownToSave.current = initialValue;
+    setMarkdownToPreview(initialValue);
+  }, [initialValue]);
+
+  useEffect(() => {
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.PageUpdated, checkIsConflict);
+
+    return () => {
+      socket.off(SocketEventName.PageUpdated, checkIsConflict);
+    };
+
+  }, [socket, checkIsConflict]);
+
+  // const optionsToSave = useMemo(() => {
+  //   if (grantData == null) {
+  //     return;
+  //   }
+  //   const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
+  //   const optionsToSave = getOptionsToSave(
+  //     isSlackEnabled ?? false, slackChannels,
+  //     grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
+  //     pageTags || [],
+  //   );
+  //   return optionsToSave;
+  // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+  // register to facade
+  useEffect(() => {
+    // for markdownRenderer
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          previewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     markdownToSave.current = value;
     setMarkdownToPreview(value);
     setMarkdownToPreview(value);
@@ -123,7 +182,6 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
   }, [setMarkdownWithDebounce]);
 
 
-  // return true if the save succeeds, otherwise false.
   const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
   const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
@@ -194,12 +252,13 @@ const PageEditor = React.memo((): JSX.Element => {
       return;
       return;
     }
     }
 
 
-    const isSuccess = await save();
-    if (isSuccess) {
+    const page = await save();
+    if (page != null) {
       toastSuccess(t('toaster.save_succeeded'));
       toastSuccess(t('toaster.save_succeeded'));
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
     }
     }
-
-  }, [editorMode, save, t]);
+  }, [editorMode, mutateCurrentPage, mutateCurrentPageId, save, t]);
 
 
 
 
   /**
   /**

+ 22 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -14,6 +14,7 @@ import InterceptorManager from '~/services/interceptor-manager';
 import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
 import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { TemplateModal } from '../TemplateModal';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
@@ -110,6 +111,7 @@ class CodeMirrorEditor extends AbstractEditor {
       emojiSearchText: '',
       emojiSearchText: '',
       startPosWithEmojiPickerModeTurnedOn: null,
       startPosWithEmojiPickerModeTurnedOn: null,
       isEmojiPickerMode: false,
       isEmojiPickerMode: false,
+      isTemplateModalOpened: false,
     };
     };
 
 
     this.cm = React.createRef();
     this.cm = React.createRef();
@@ -161,6 +163,8 @@ class CodeMirrorEditor extends AbstractEditor {
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
 
+    this.showTemplateModal = this.showTemplateModal.bind(this);
+
   }
   }
 
 
   init() {
   init() {
@@ -869,6 +873,10 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
   }
 
 
+  showTemplateModal() {
+    this.setState({ isTemplateModalOpened: true });
+  }
+
   // fold draw.io section (``` drawio ~ ```)
   // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
   foldDrawioSection() {
     const editor = this.getCodeMirror();
     const editor = this.getCodeMirror();
@@ -1049,6 +1057,15 @@ class CodeMirrorEditor extends AbstractEditor {
       >
       >
         <EditorIcon icon="Emoji" />
         <EditorIcon icon="Emoji" />
       </Button>,
       </Button>,
+      <Button
+        key="nav-item-template"
+        color={null}
+        bssize="small"
+        title="Template"
+        onClick={() => this.showTemplateModal()}
+      >
+        <EditorIcon icon="Template" />
+      </Button>,
     ];
     ];
   }
   }
 
 
@@ -1142,6 +1159,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
         />
+        <TemplateModal
+          isOpen={this.state.isTemplateModalOpened}
+          onClose={() => this.setState({ isTemplateModalOpened: false })}
+          onSubmit={templateText => this.setValue(templateText) }
+        />
       </div>
       </div>
     );
     );
   }
   }

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

@@ -1,5 +1,6 @@
 /* eslint-disable max-len */
 /* eslint-disable max-len */
 import React from 'react';
 import React from 'react';
+
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 const EditorIcon = (props) => {
 const EditorIcon = (props) => {
@@ -139,6 +140,13 @@ const EditorIcon = (props) => {
           </g>
           </g>
         </svg>
         </svg>
       );
       );
+    case 'Template':
+      // TODO: fix
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" className="bi bi-filetype-md" viewBox="-2 -3 28 21">
+          <path fillRule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2H9v-1h3a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5L14 4.5ZM.706 13.189v2.66H0V11.85h.806l1.14 2.596h.026l1.14-2.596h.8v3.999h-.716v-2.66h-.038l-.946 2.159h-.516l-.952-2.16H.706Zm3.919 2.66V11.85h1.459c.406 0 .741.078 1.005.234.263.157.46.383.589.68.13.297.196.655.196 1.075 0 .422-.066.784-.196 1.084-.131.301-.33.53-.595.689-.264.158-.597.237-1 .237H4.626Zm1.353-3.354h-.562v2.707h.562c.186 0 .347-.028.484-.082a.8.8 0 0 0 .334-.252 1.14 1.14 0 0 0 .196-.422c.045-.168.067-.365.067-.592a2.1 2.1 0 0 0-.117-.753.89.89 0 0 0-.354-.454c-.159-.102-.362-.152-.61-.152Z"/>
+        </svg>
+      );
   }
   }
 
 
 
 

+ 2 - 2
packages/app/src/components/PageEditor/LinkEditModal.jsx

@@ -463,11 +463,11 @@ class LinkEditModal extends React.PureComponent {
 
 
 }
 }
 
 
-const LinkEditModalFc = React.forwardRef((props, ref) => {
+const LinkEditModalFc = React.memo(React.forwardRef((props, ref) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: currentPath } = useCurrentPagePath();
   const { data: currentPath } = useCurrentPagePath();
   return <LinkEditModal t={t} ref={ref} pagePath={currentPath} {...props} />;
   return <LinkEditModal t={t} ref={ref} pagePath={currentPath} {...props} />;
-});
+}));
 
 
 LinkEditModal.propTypes = {
 LinkEditModal.propTypes = {
   t: PropTypes.func.isRequired,
   t: PropTypes.func.isRequired,

+ 10 - 7
packages/app/src/components/PageEditorByHackmd.tsx

@@ -19,12 +19,13 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors,
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import {
 import {
-  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
+  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
 import {
   EditorMode,
   EditorMode,
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
@@ -83,7 +84,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
-  const [isHackmdDraftUpdatingInRealtime, setIsHackmdDraftUpdatingInRealtime] = useState(false);
+  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime(false);
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
   const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
 
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
   const hackmdEditorRef = useRef<HackEditorRef>(null);
@@ -211,7 +213,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         throw new Error(res.error);
         throw new Error(res.error);
       }
       }
 
 
-      setIsHackmdDraftUpdatingInRealtime(false);
+      mutateIsHackmdDraftUpdatingInRealtime(false);
       mutateHasDraftOnHackmd(false);
       mutateHasDraftOnHackmd(false);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
       mutateRemoteRevisionId(res.revisionIdHackmdSynced);
       mutateRemoteRevisionId(res.revisionIdHackmdSynced);
@@ -223,7 +225,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error(err);
       logger.error(err);
       toastError(err.message);
       toastError(err.message);
     }
     }
-  }, [pageId, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced]);
+  }, [mutateIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, mutateRemoteRevisionId, pageId]);
 
 
   /**
   /**
    * save and update state of containers
    * save and update state of containers
@@ -264,8 +266,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
       toastError(error.message);
       toastError(error.message);
     }
     }
-  // eslint-disable-next-line max-len
-  }, [currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags, saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
+  }, [
+    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags,
+    saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
 
 
   /**
   /**
    * onChange event of HackmdEditor handler
    * onChange event of HackmdEditor handler

+ 9 - 2
packages/app/src/components/PageEditorByHackmd/HackmdEditor.jsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
+
 import connectToChild from 'penpal/lib/connectToChild';
 import connectToChild from 'penpal/lib/connectToChild';
+import PropTypes from 'prop-types';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
@@ -27,11 +29,16 @@ export default class HackmdEditor extends React.PureComponent {
   }
   }
 
 
   async initHackmdWithPenpal() {
   async initHackmdWithPenpal() {
+    const shouldInit = document.getElementById('iframe-hackmd') != null;
+    if (shouldInit) {
+      return;
+    }
+
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     // eslint-disable-next-line @typescript-eslint/no-this-alias
     const _this = this; // for in methods scope
     const _this = this; // for in methods scope
-
     const iframe = document.createElement('iframe');
     const iframe = document.createElement('iframe');
     iframe.src = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}?both`;
     iframe.src = `${this.props.hackmdUri}/${this.props.pageIdOnHackmd}?both`;
+    iframe.id = 'iframe-hackmd';
     this.iframeContainer.appendChild(iframe);
     this.iframeContainer.appendChild(iframe);
 
 
     const connection = connectToChild({
     const connection = connectToChild({

+ 6 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -162,6 +162,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 
 
+  const hasBrowsingRights = canRenderESSnippet || canRenderRevisionSnippet;
+
   return (
   return (
     <li
     <li
       key={pageData._id}
       key={pageData._id}
@@ -228,7 +230,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </div>
               </div>
 
 
               {/* doropdown icon includes page control buttons */}
               {/* doropdown icon includes page control buttons */}
-              <div className="ml-auto">
+              {hasBrowsingRights
+              && <div className="ml-auto">
                 <PageItemControl
                 <PageItemControl
                   alignRight
                   alignRight
                   pageId={pageData._id}
                   pageId={pageData._id}
@@ -242,6 +245,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   onClickRevertMenuItem={revertMenuItemClickHandler}
                   onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
                 />
               </div>
               </div>
+              }
             </div>
             </div>
             <div className="page-list-snippet py-1">
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
               <Clamp lines={2}>
@@ -253,7 +257,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                 ) }
                 ) }
                 {
                 {
-                  !canRenderESSnippet && !canRenderRevisionSnippet && (
+                  !hasBrowsingRights && (
                     <>
                     <>
                       <i className="icon-exclamation p-1"></i>
                       <i className="icon-exclamation p-1"></i>
                       {t('not_allowed_to_see_this_page')}
                       {t('not_allowed_to_see_this_page')}

+ 0 - 177
packages/app/src/components/PageStatusAlert.jsx

@@ -1,177 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-// import AppContainer from '~/client/services/AppContainer';
-// import PageContainer from '~/client/services/PageContainer';
-// import Username from '~/components/User/Username';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageStatusAlert
- * @extends {React.Component}
- */
-
-class PageStatusAlert extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-    };
-
-    this.getContentsForSomeoneEditingAlert = this.getContentsForSomeoneEditingAlert.bind(this);
-    this.getContentsForDraftExistsAlert = this.getContentsForDraftExistsAlert.bind(this);
-    this.getContentsForUpdatedAlert = this.getContentsForUpdatedAlert.bind(this);
-    this.onClickResolveConflict = this.onClickResolveConflict.bind(this);
-  }
-
-  refreshPage() {
-    window.location.reload();
-  }
-
-  onClickResolveConflict() {
-    this.props.pageContainer.setState({
-      isConflictDiffModalOpen: true,
-    });
-  }
-
-  getContentsForSomeoneEditingAlert() {
-    const { t } = this.props;
-    return [
-      ['bg-success', 'd-hackmd-none'],
-      <>
-        <i className="icon-fw icon-people"></i>
-        {t('hackmd.someone_editing')}
-      </>,
-      <a href="#hackmd" key="btnOpenHackmdSomeoneEditing" className="btn btn-outline-white">
-        <i className="fa fa-fw fa-file-text-o mr-1"></i>
-        Open HackMD Editor
-      </a>,
-    ];
-  }
-
-  getContentsForDraftExistsAlert(isRealtime) {
-    const { t } = this.props;
-    return [
-      ['bg-success', 'd-hackmd-none'],
-      <>
-        <i className="icon-fw icon-pencil"></i>
-        {t('hackmd.this_page_has_draft')}
-      </>,
-      <a href="#hackmd" key="btnOpenHackmdPageHasDraft" className="btn btn-outline-white">
-        <i className="fa fa-fw fa-file-text-o mr-1"></i>
-        Open HackMD Editor
-      </a>,
-    ];
-  }
-
-  getContentsForUpdatedAlert() {
-    const { t } = this.props;
-    // const pageEditor = appContainer.getComponentInstance('PageEditor');
-
-    const isConflictOnEdit = false;
-
-    // if (pageEditor != null) {
-    //   const markdownOnEdit = pageEditor.getMarkdown();
-    //   isConflictOnEdit = markdownOnEdit !== pageContainer.state.markdown;
-    // }
-
-    // TODO: re-impl with Next.js way
-    // const usernameComponentToString = ReactDOMServer.renderToString(<Username user={pageContainer.state.lastUpdateUser} />);
-
-    // const label1 = isConflictOnEdit
-    //   ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
-    //   // eslint-disable-next-line react/no-danger
-    //   : <span dangerouslySetInnerHTML={{ __html: `${usernameComponentToString} ${t('edited this page')}` }} />;
-    const label1 = '(TBD -- 2022.09.13)';
-
-    return [
-      ['bg-warning'],
-      <>
-        <i className="icon-fw icon-bulb"></i>
-        {label1}
-      </>,
-      <>
-        <button type="button" onClick={() => this.refreshPage()} className="btn btn-outline-white mr-4">
-          <i className="icon-fw icon-reload mr-1"></i>
-          {t('Load latest')}
-        </button>
-        { isConflictOnEdit && (
-          <button
-            type="button"
-            onClick={this.onClickResolveConflict}
-            className="btn btn-outline-white"
-          >
-            <i className="fa fa-fw fa-file-text-o mr-1"></i>
-            {t('modal_resolve_conflict.resolve_conflict')}
-          </button>
-        )}
-      </>,
-    ];
-  }
-
-  render() {
-    const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
-    } = this.props.pageContainer.state;
-
-    const isRevisionOutdated = revisionId !== remoteRevisionId;
-    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
-
-    let getContentsFunc = null;
-
-    // when remote revision is newer than both
-    if (isHackmdDocumentOutdated && isRevisionOutdated) {
-      getContentsFunc = this.getContentsForUpdatedAlert;
-    }
-    // when someone editing with HackMD
-    else if (isHackmdDraftUpdatingInRealtime) {
-      getContentsFunc = this.getContentsForSomeoneEditingAlert;
-    }
-    // when the draft of HackMD is newest
-    else if (hasDraftOnHackmd) {
-      getContentsFunc = this.getContentsForDraftExistsAlert;
-    }
-    // do not render anything
-    else {
-      return null;
-    }
-
-    const [additionalClasses, label, btn] = getContentsFunc();
-
-    return (
-      <div className={`card grw-page-status-alert text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
-        <div className="card-body">
-          <p className="card-text grw-card-label-container">
-            {label}
-          </p>
-          <p className="card-text grw-card-btn-container">
-            {btn}
-          </p>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-PageStatusAlert.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  // appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-};
-
-const PageStatusAlertWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <PageStatusAlert t={t} {...props} />;
-};
-
-export default PageStatusAlertWrapperFC;

+ 40 - 0
packages/app/src/components/PageStatusAlert.module.scss

@@ -0,0 +1,40 @@
+@use '~/styles/variables' as var;
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-page-status-alert :global {
+  $margin-bottom: var.$grw-navbar-bottom-height + 10px;
+
+  box-shadow: 0px 2px 4px #0000004d;
+  opacity: 0.9;
+
+  @include bs.media-breakpoint-down(sm) {
+    margin: 0 10px $margin-bottom;
+
+    .grw-card-label-container {
+      text-align: center;
+    }
+    .grw-card-btn-container {
+      text-align: center;
+
+      .btn {
+        @include bs.button-size(bs.$btn-padding-y-lg, bs.$btn-padding-x-lg, bs.$btn-font-size-lg, bs.$btn-line-height-lg, bs.$btn-border-radius-lg);
+      }
+    }
+  }
+
+  @include bs.media-breakpoint-up(md) {
+    width: 700px;
+    margin: 0 auto $margin-bottom;
+
+    .card-body {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    .grw-card-label-container,
+    .grw-card-btn-container {
+      margin: 0;
+    }
+  }
+}

+ 166 - 0
packages/app/src/components/PageStatusAlert.tsx

@@ -0,0 +1,166 @@
+import React, { useCallback, useMemo } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import * as ReactDOMServer from 'react-dom/server';
+
+import { useEditingMarkdown, useIsConflict } from '~/stores/editor';
+import {
+  useHasDraftOnHackmd, useIsHackmdDraftUpdatingInRealtime, useRevisionIdHackmdSynced,
+} from '~/stores/hackmd';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
+
+import { Username } from './User/Username';
+
+import styles from './PageStatusAlert.module.scss';
+
+type AlertComponentContents = {
+  additionalClasses: string[],
+  label: JSX.Element,
+  btn: JSX.Element
+}
+
+export const PageStatusAlert = (): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { data: isHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
+  const { data: hasDraftOnHackmd } = useHasDraftOnHackmd();
+  const { data: isConflict } = useIsConflict();
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+
+  // store remote latest page data
+  const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const { data: remoteRevisionId } = useRemoteRevisionId();
+  const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
+
+  const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
+  const revision = pageData?.revision;
+
+  const refreshPage = useCallback(async() => {
+    const updatedPageData = await mutatePageData();
+    mutateEditingMarkdown(updatedPageData?.revision.body);
+  }, [mutateEditingMarkdown, mutatePageData]);
+
+  const onClickResolveConflict = useCallback(() => {
+    // this.props.pageContainer.setState({
+    //   isConflictDiffModalOpen: true,
+    // });
+  }, []);
+
+  const getContentsForSomeoneEditingAlert = useCallback((): AlertComponentContents => {
+    return {
+      additionalClasses: ['bg-success', 'd-hackmd-none'],
+      label:
+        <>
+          <i className="icon-fw icon-people"></i>
+          {t('hackmd.someone_editing')}
+        </>,
+      btn:
+        <a href="#hackmd" key="btnOpenHackmdSomeoneEditing" className="btn btn-outline-white">
+          <i className="fa fa-fw fa-file-text-o mr-1"></i>
+          Open HackMD Editor
+        </a>,
+    };
+  }, [t]);
+
+  const getContentsForDraftExistsAlert = useCallback((): AlertComponentContents => {
+    return {
+      additionalClasses: ['bg-success', 'd-hackmd-none'],
+      label:
+        <>
+          <i className="icon-fw icon-pencil"></i>
+          {t('hackmd.this_page_has_draft')}
+        </>,
+      btn:
+        <a href="#hackmd" key="btnOpenHackmdPageHasDraft" className="btn btn-outline-white">
+          <i className="fa fa-fw fa-file-text-o mr-1"></i>
+          Open HackMD Editor
+        </a>,
+    };
+  }, [t]);
+
+  const getContentsForUpdatedAlert = useCallback((): AlertComponentContents => {
+
+    const usernameComponentToString = ReactDOMServer.renderToString(<Username user={remoteRevisionLastUpdateUser} />);
+
+    const label1 = isConflict
+      ? t('modal_resolve_conflict.file_conflicting_with_newer_remote')
+      // eslint-disable-next-line react/no-danger
+      : <span dangerouslySetInnerHTML={{ __html: `${usernameComponentToString} ${t('edited this page')}` }} />;
+
+    return {
+      additionalClasses: ['bg-warning'],
+      label:
+        <>
+          <i className="icon-fw icon-bulb"></i>
+          {label1}
+        </>,
+      btn:
+        <>
+          <button type="button" onClick={() => refreshPage()} className="btn btn-outline-white mr-4">
+            <i className="icon-fw icon-reload mr-1"></i>
+            {t('Load latest')}
+          </button>
+          { isConflict && (
+            <button
+              type="button"
+              onClick={onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <i className="fa fa-fw fa-file-text-o mr-1"></i>
+              {t('modal_resolve_conflict.resolve_conflict')}
+            </button>
+          )}
+        </>,
+    };
+  }, [remoteRevisionLastUpdateUser, isConflict, t, onClickResolveConflict, refreshPage]);
+
+  const alertComponentContents = useMemo(() => {
+    const isRevisionOutdated = revision?._id !== remoteRevisionId;
+    const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
+
+    // when remote revision is newer than both
+    if (isHackmdDocumentOutdated && isRevisionOutdated) {
+      return getContentsForUpdatedAlert();
+    }
+
+    // when someone editing with HackMD
+    if (isHackmdDraftUpdatingInRealtime) {
+      return getContentsForSomeoneEditingAlert();
+    }
+
+    // when the draft of HackMD is newest
+    if (hasDraftOnHackmd) {
+      return getContentsForDraftExistsAlert();
+    }
+
+    return null;
+  }, [
+    revision?._id,
+    remoteRevisionId,
+    revisionIdHackmdSynced,
+    isHackmdDraftUpdatingInRealtime,
+    hasDraftOnHackmd,
+    getContentsForUpdatedAlert,
+    getContentsForSomeoneEditingAlert,
+    getContentsForDraftExistsAlert,
+  ]);
+
+  if (alertComponentContents == null) { return <></> }
+
+  const { additionalClasses, label, btn } = alertComponentContents;
+
+  return (
+    <div className={`${styles['grw-page-status-alert']} card text-white fixed-bottom animated fadeInUp faster ${additionalClasses.join(' ')}`}>
+      <div className="card-body">
+        <p className="card-text grw-card-label-container">
+          {label}
+        </p>
+        <p className="card-text grw-card-btn-container">
+          {btn}
+        </p>
+      </div>
+    </div>
+  );
+
+};

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

@@ -148,7 +148,7 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   }, [props.isOpen]);
   }, [props.isOpen]);
 
 
   return (
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.close}>
       <ModalHeader tag="h4" toggle={props.close} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={props.close} className="bg-primary text-light">
         { t('private_legacy_pages.by_path_modal.title') }
         { t('private_legacy_pages.by_path_modal.title') }
       </ModalHeader>
       </ModalHeader>

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

@@ -73,7 +73,7 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
   };
   };
 
 
   return (
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={close} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={close}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         { t('private_legacy_pages.modal.title') }
         { t('private_legacy_pages.modal.title') }
       </ModalHeader>
       </ModalHeader>

+ 1 - 1
packages/app/src/components/PutbackPageModal.jsx

@@ -116,7 +116,7 @@ const PutBackPageModal = () => {
   }, [closePutBackPageModal]);
   }, [closePutBackPageModal]);
 
 
   return (
   return (
-    <Modal isOpen={isOpened} toggle={closeModalHandler} className="grw-create-page">
+    <Modal isOpen={isOpened} toggle={closeModalHandler}>
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-info text-light">
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-info text-light">
         <HeaderContent/>
         <HeaderContent/>
       </ModalHeader>
       </ModalHeader>

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

@@ -112,7 +112,7 @@ const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
   }
   }
 
 
   return (
   return (
-    <li className="list-group-item py-2 px-0">
+    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">
         <div className="flex-grow-1 ml-2">

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

@@ -60,6 +60,7 @@ const TableOfContents = (): JSX.Element => {
       >
       >
         <div
         <div
           id="revision-toc-content"
           id="revision-toc-content"
+          data-testid="revision-toc-content"
           className="revision-toc-content mb-3"
           className="revision-toc-content mb-3"
         >
         >
           {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}
           {/* parse blank to show toc (https://github.com/weseek/growi/pull/6277) */}

+ 117 - 0
packages/app/src/components/TemplateModal.tsx

@@ -0,0 +1,117 @@
+import React, { useCallback, useState } from 'react';
+
+import { ITemplate } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { usePreviewOptions } from '~/stores/renderer';
+import { useTemplates } from '~/stores/template';
+
+import Preview from './PageEditor/Preview';
+
+
+type TemplateRadioButtonProps = {
+  template: ITemplate,
+  onChange: (selectedTemplate: ITemplate) => void,
+  isSelected?: boolean,
+}
+
+const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
+  const radioButtonId = `rb-${template.id}`;
+
+  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>
+  );
+};
+
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+  onSubmit?: (markdown: string) => void,
+}
+
+export const TemplateModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { isOpen, onClose, onSubmit } = props;
+
+  const { data: rendererOptions } = usePreviewOptions();
+  const { data: templates } = useTemplates();
+
+  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+
+  const submitHandler = useCallback((template?: ITemplate) => {
+    if (onSubmit == null || template == null) {
+      onClose();
+      return;
+    }
+
+    onSubmit(template.markdown);
+    onClose();
+  }, [onClose, onSubmit]);
+
+  if (templates == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={isOpen} toggle={onClose} size="lg" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+        Template
+      </ModalHeader>
+
+      <ModalBody className="container">
+        <div className="row">
+          <div className="col-12">
+            { templates.map(template => (
+              <TemplateRadioButton
+                key={template.id}
+                template={template}
+                onChange={t => setSelectedTemplate(t)}
+                isSelected={template.id === selectedTemplate?.id}
+              />
+            )) }
+          </div>
+        </div>
+
+        { rendererOptions != null && (
+          <>
+            <hr />
+            <h3>Preview</h3>
+            <div className='card'>
+              <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
+                <Preview rendererOptions={rendererOptions} markdown={selectedTemplate?.markdown}/>
+              </div>
+            </div>
+          </>
+        ) }
+
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={onClose}>
+          {t('Cancel')}
+        </button>
+        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
+          {t('Update')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 30 - 0
packages/app/src/components/TemplateTab.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+
+type Props = {
+  template: any,
+  onChangeHandler: any,
+}
+
+// const onChangeHandler = () => {
+
+// }
+
+export const TemplateTab = (props: Props): JSX.Element => {
+  const { template, onChangeHandler } = props;
+
+  return (
+    <div key={template.name} className="custom-control custom-radio">
+      <input
+        type="radio"
+        className="custom-control-input"
+        id="string"
+        value={template.value}
+        // checked={this.state.linkerType === template.value}
+        onChange={onChangeHandler}
+      />
+      <label className="custom-control-label" htmlFor="string">
+        {template.name}
+      </label>
+    </div>
+  );
+};

+ 2 - 2
packages/app/src/components/Theme/ThemeDefault.global.scss

@@ -16,7 +16,7 @@
 
 
 //== Light Mode
 //== Light Mode
 //
 //
-:root[data-theme='light'] .theme-default {
+:root[data-theme='light'] {
   $primary: #122c55;
   $primary: #122c55;
   $accent: #209fd8;
   $accent: #209fd8;
 
 
@@ -116,7 +116,7 @@
 
 
 //== Dark Mode
 //== Dark Mode
 //
 //
-:root[data-theme='dark'] .theme-default {
+:root[data-theme='dark'] {
   $primary: #115cd3;
   $primary: #115cd3;
   $accent: #db00c2;
   $accent: #db00c2;
 
 

+ 1 - 1
packages/app/src/components/Theme/ThemeDefault.tsx

@@ -3,6 +3,6 @@ import { ThemeInjector } from './utils/ThemeInjector';
 // import styles from './ThemeDefault.module.scss';
 // import styles from './ThemeDefault.module.scss';
 
 
 const ThemeDefault = ({ children }: { children: JSX.Element }): JSX.Element => {
 const ThemeDefault = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector className="theme-default">{children}</ThemeInjector>;
+  return <ThemeInjector>{children}</ThemeInjector>;
 };
 };
 export default ThemeDefault;
 export default ThemeDefault;

+ 12 - 5
packages/app/src/components/Theme/utils/ThemeInjector.tsx

@@ -5,20 +5,27 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 
 type Props = {
 type Props = {
   children: JSX.Element,
   children: JSX.Element,
-  className: string,
+  bodyTagClassName?: string,
+  className?: string,
   bgImageNode?: React.ReactNode,
   bgImageNode?: React.ReactNode,
 }
 }
 
 
-export const ThemeInjector = ({ children, className: themeClassName, bgImageNode }: Props): JSX.Element => {
-  const className = `${children.props.className ?? ''} ${themeClassName}`;
+export const ThemeInjector = ({
+  children, bodyTagClassName, className: childrenClassName, bgImageNode,
+}: Props): JSX.Element => {
+  const className = `${children.props.className ?? ''} ${childrenClassName ?? ''}`;
 
 
   // add class name to <body>
   // add class name to <body>
   useIsomorphicLayoutEffect(() => {
   useIsomorphicLayoutEffect(() => {
-    document.body.classList.add(themeClassName);
+    if (bodyTagClassName != null) {
+      document.body.classList.add(bodyTagClassName);
+    }
 
 
     // clean up
     // clean up
     return () => {
     return () => {
-      document.body.classList.remove(themeClassName);
+      if (bodyTagClassName != null) {
+        document.body.classList.remove(bodyTagClassName);
+      }
     };
     };
   });
   });
 
 

+ 21 - 0
packages/app/src/interfaces/github-api.ts

@@ -0,0 +1,21 @@
+export type SearchResult = {
+  total_count: number,
+  imcomplete_results: boolean,
+  items: SearchResultItem[];
+}
+
+export type SearchResultItem = {
+  id: number,
+  name: string,
+  owner: {
+    login: string,
+    html_url: string,
+    avatar_url: string,
+  },
+  fullName: string,
+  htmlUrl: string,
+  description: string,
+  topics: string[],
+  homepage: string,
+  stargazersCount: number,
+}

+ 3 - 0
packages/app/src/interfaces/in-app-notification.ts

@@ -1,3 +1,5 @@
+import type { IPageSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+
 import { IPage } from './page';
 import { IPage } from './page';
 import { IUser } from './user';
 import { IUser } from './user';
 
 
@@ -16,6 +18,7 @@ export interface IInAppNotification {
   actionUsers: IUser[]
   actionUsers: IUser[]
   createdAt: Date
   createdAt: Date
   snapshot: string
   snapshot: string
+  parsedSnapshot?: IPageSnapshot
 }
 }
 
 
 /*
 /*

+ 26 - 0
packages/app/src/interfaces/plugin.ts

@@ -0,0 +1,26 @@
+export const GrowiPluginResourceType = {
+  Template: 'template',
+  Style: 'style',
+  Script: 'script',
+} as const;
+export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
+
+export type GrowiPluginOrigin = {
+  url: string,
+  ghBranch?: string,
+  ghTag?: string,
+}
+
+export type GrowiPlugin = {
+  isEnabled: boolean,
+  installedPath: string,
+  origin: GrowiPluginOrigin,
+  meta: GrowiPluginMeta,
+}
+
+export type GrowiPluginMeta = {
+  name: string,
+  types: GrowiPluginResourceType[],
+  desc?: string,
+  author?: string,
+}

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

@@ -17,6 +17,11 @@ export const SocketEventName = {
   FinishAddPage: 'finishAddPage',
   FinishAddPage: 'finishAddPage',
   RebuildingFailed: 'rebuildingFailed',
   RebuildingFailed: 'rebuildingFailed',
 
 
+  // Page Operation
+  PageCreated: 'page:create',
+  PageUpdated: 'page:update',
+  PageDeleted: 'page:delete',
+
 } as const;
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 
 

+ 2 - 2
packages/app/src/models/serializers/in-app-notification-snapshot/page.ts

@@ -1,5 +1,5 @@
-import { IUser } from '~/interfaces/user';
-import { IPage } from '~/interfaces/page';
+import type { IPage } from '~/interfaces/page';
+import type { IUser } from '~/interfaces/user';
 
 
 export interface IPageSnapshot {
 export interface IPageSnapshot {
   path: string
   path: string

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

@@ -43,6 +43,7 @@ import {
   useEditorMode, useSelectedGrant,
   useEditorMode, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
 } from '~/stores/ui';
 } from '~/stores/ui';
+import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
@@ -88,6 +89,7 @@ const UsersHomePageFooter = dynamic<UsersHomePageFooterProps>(() => import('../c
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
   .then(mod => mod.UsersHomePageFooter), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const DrawioModal = dynamic(() => import('../components/PageEditor/DrawioModal').then(mod => mod.DrawioModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
 const HandsontableModal = dynamic(() => import('../components/PageEditor/HandsontableModal').then(mod => mod.HandsontableModal), { ssr: false });
+const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 
 
 const logger = loggerFactory('growi:pages:all');
 const logger = loggerFactory('growi:pages:all');
 
 
@@ -267,6 +269,9 @@ const Page: NextPage<Props> = (props: Props) => {
 
 
   const { getClassNamesByEditorMode } = useEditorMode();
   const { getClassNamesByEditorMode } = useEditorMode();
 
 
+  useSetupGlobalSocket();
+  useSetupGlobalSocketForPage(pageId);
+
   const shouldRenderPutbackPageModal = pageWithMeta != null
   const shouldRenderPutbackPageModal = pageWithMeta != null
     ? _isTrashPage(pageWithMeta.data.path)
     ? _isTrashPage(pageWithMeta.data.path)
     : false;
     : false;
@@ -333,7 +338,7 @@ const Page: NextPage<Props> = (props: Props) => {
                     { props.isNotCreatablePage && <NotCreatablePage />}
                     { props.isNotCreatablePage && <NotCreatablePage />}
                     { !props.isForbidden && !props.isNotCreatablePage && <DisplaySwitcher />}
                     { !props.isForbidden && !props.isNotCreatablePage && <DisplaySwitcher />}
                     {/* <DisplaySwitcher /> */}
                     {/* <DisplaySwitcher /> */}
-                    {/* <PageStatusAlert /> */}
+                    <PageStatusAlert />
                   </>
                   </>
                 ) }
                 ) }
 
 

+ 5 - 0
packages/app/src/pages/_app.page.tsx

@@ -8,6 +8,7 @@ import { SWRConfig } from 'swr';
 
 
 import * as nextI18nConfig from '^/config/next-i18next.config';
 import * as nextI18nConfig from '^/config/next-i18next.config';
 
 
+import { ActivatePluginService } from '~/client/services/activate-plugin';
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
 import {
   useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
   useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
@@ -53,6 +54,10 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
     import('bootstrap/dist/js/bootstrap');
     import('bootstrap/dist/js/bootstrap');
   }, []);
   }, []);
 
 
+  useEffect(() => {
+    ActivatePluginService.activateAll();
+  }, []);
+
 
 
   const commonPageProps = pageProps as CommonProps;
   const commonPageProps = pageProps as CommonProps;
   // useInterceptorManager(new InterceptorManager());
   // useInterceptorManager(new InterceptorManager());

+ 49 - 6
packages/app/src/pages/_document.page.tsx

@@ -1,18 +1,57 @@
 /* eslint-disable @next/next/google-font-display */
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 import React from 'react';
 
 
+import mongoose from 'mongoose';
 import Document, {
 import Document, {
   DocumentContext, DocumentInitialProps,
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
   Html, Head, Main, NextScript,
 } from 'next/document';
 } from 'next/document';
 
 
+import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
+import { GrowiPlugin, GrowiPluginResourceType } from '~/interfaces/plugin';
 
 
+type HeadersForGrowiPluginProps = {
+  pluginManifestEntries: GrowiPluginManifestEntries;
+}
+
+const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element => {
+  const { pluginManifestEntries } = props;
+
+  return (
+    <>
+      { pluginManifestEntries.map(([growiPlugin, manifest]) => {
+        const { types } = growiPlugin.meta;
+
+        const elements: JSX.Element[] = [];
 
 
-// type GrowiDocumentProps = {};
-// declare type GrowiDocumentInitialProps = GrowiDocumentProps & DocumentInitialProps;
-declare type GrowiDocumentInitialProps = DocumentInitialProps & { customCss: string };
+        // add script
+        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
+          elements.push(<>
+            {/* eslint-disable-next-line @next/next/no-sync-scripts */ }
+            <script type="module" key={`script_${growiPlugin.installedPath}`}
+              src={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
+          </>);
+        }
+        // add link
+        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
+          elements.push(<>
+            <link rel="stylesheet" key={`link_${growiPlugin.installedPath}`}
+              href={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
+          </>);
+        }
 
 
+        return elements;
+      }) }
+    </>
+  );
+};
+
+interface GrowiDocumentProps {
+  customCss: string;
+  pluginManifestEntries: GrowiPluginManifestEntries;
+}
+declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
 
 class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
 
@@ -22,12 +61,15 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const { customizeService } = crowi;
     const { customizeService } = crowi;
     const customCss: string = customizeService.getCustomCss();
     const customCss: string = customizeService.getCustomCss();
 
 
-    const props = { ...initialProps, customCss };
-    return props;
+    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
+    const growiPlugins = await GrowiPlugin.find({ isEnabled: true });
+    const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests(growiPlugins);
+
+    return { ...initialProps, customCss, pluginManifestEntries };
   }
   }
 
 
   override render(): JSX.Element {
   override render(): JSX.Element {
-    const { customCss } = this.props;
+    const { customCss, pluginManifestEntries } = this.props;
 
 
     return (
     return (
       <Html>
       <Html>
@@ -45,6 +87,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
+          <HeadersForGrowiPlugin pluginManifestEntries={pluginManifestEntries} />
         </Head>
         </Head>
         <body>
         <body>
           <Main />
           <Main />

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

@@ -10,7 +10,6 @@ import { Container, Provider } from 'unstated';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import PluginUtils from '~/server/plugins/plugin-utils';
 import { useCurrentUser, useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores/context';
 import { useCurrentUser, useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores/context';
 
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -67,12 +66,10 @@ const AdminHomePage: NextPage<Props> = (props) => {
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const { crowi } = req;
-  const pluginUtils = new PluginUtils();
 
 
   props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
   props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
-  props.installedPlugins = pluginUtils.listPlugins();
   props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
   props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
   props.growiAppIdForGrowiCloud = await crowi.configManager.getConfig('crowi', 'app:growiAppIdForCloud');
   props.growiAppIdForGrowiCloud = await crowi.configManager.getConfig('crowi', 'app:growiAppIdForCloud');
 };
 };

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

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

+ 12 - 0
packages/app/src/pages/invited.page.tsx

@@ -1,6 +1,7 @@
 import React from 'react';
 import React from 'react';
 
 
 import type { IUserHasId, IUser } from '@growi/core';
 import type { IUserHasId, IUser } from '@growi/core';
+import { USER_STATUS } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
@@ -81,6 +82,17 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
 
   if (user != null) {
   if (user != null) {
     props.currentUser = user.toObject();
     props.currentUser = user.toObject();
+
+    // Only invited user can access to /invited page
+    if (props.currentUser.status !== USER_STATUS.INVITED) {
+      return {
+        redirect: {
+          permanent: false,
+          destination: '/',
+        },
+      };
+    }
+
   }
   }
 
 
   await injectServerConfigurations(context, props);
   await injectServerConfigurations(context, props);

+ 24 - 4
packages/app/src/pages/me/[[...path]].page.tsx

@@ -13,6 +13,7 @@ import { useRouter } from 'next/router';
 
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
@@ -20,7 +21,7 @@ import {
   useCurrentUser, useIsSearchPage,
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhiteList, useShowPageLimitationXL,
+  useRegistrationWhiteList, useShowPageLimitationXL, useRendererConfig,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -40,6 +41,7 @@ type Props = CommonProps & {
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   userUISettings?: IUserUISettings
   userUISettings?: IUserUISettings
   sidebarConfig: ISidebarConfig,
   sidebarConfig: ISidebarConfig,
+  rendererConfig: RendererConfig,
   showPageLimitationXL: number,
   showPageLimitationXL: number,
 
 
   // config
   // config
@@ -94,22 +96,24 @@ const MePage: NextPage<Props> = (props: Props) => {
   // commons
   // commons
   useCsrfToken(props.csrfToken);
   useCsrfToken(props.csrfToken);
 
 
-  // // UserUISettings
+  // UserUISettings
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
   usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
   useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
   useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
 
-  // // page
+  // page
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
 
+  useRendererConfig(props.rendererConfig);
+
   const title = useCustomTitle(props, 'GROWI');
   const title = useCustomTitle(props, 'GROWI');
 
 
   return (
   return (
-    <BasicLayout title={useCustomTitle(props, 'GROWI')}>
+    <BasicLayout>
       <Head>
       <Head>
         <title>{title}</title>
         <title>{title}</title>
       </Head>
       </Head>
@@ -162,6 +166,22 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
   };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
 }
 }
 
 
 // /**
 // /**

+ 22 - 1
packages/app/src/pages/tags.page.tsx

@@ -8,6 +8,7 @@ import dynamic from 'next/dynamic';
 import Head from 'next/head';
 import Head from 'next/head';
 
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -21,7 +22,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
 import {
   useCurrentUser, useIsSearchPage,
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault,
+  useIsSearchScopeChildrenAsDefault, useRendererConfig,
 } from '../stores/context';
 } from '../stores/context';
 
 
 import {
 import {
@@ -41,6 +42,8 @@ type Props = CommonProps & {
 
 
   // sidebar
   // sidebar
   sidebarConfig: ISidebarConfig,
   sidebarConfig: ISidebarConfig,
+
+  rendererConfig: RendererConfig,
 };
 };
 
 
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
@@ -74,6 +77,8 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
 
+  useRendererConfig(props.rendererConfig);
+
   const title = useCustomTitle(props, 'GROWI');
   const title = useCustomTitle(props, 'GROWI');
   const classNames: string[] = [];
   const classNames: string[] = [];
 
 
@@ -140,6 +145,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
   };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
 }
 }
 
 
 /**
 /**

+ 22 - 1
packages/app/src/pages/trash.page.tsx

@@ -8,6 +8,7 @@ import Head from 'next/head';
 
 
 import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
@@ -19,7 +20,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
 import {
   useCurrentUser, useCurrentPageId, useCurrentPathname,
   useCurrentUser, useCurrentPageId, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useRendererConfig,
 } from '../stores/context';
 } from '../stores/context';
 
 
 import {
 import {
@@ -41,6 +42,8 @@ type Props = CommonProps & {
   userUISettings?: IUserUISettings
   userUISettings?: IUserUISettings
   // Sidebar
   // Sidebar
   sidebarConfig: ISidebarConfig,
   sidebarConfig: ISidebarConfig,
+
+  rendererConfig: RendererConfig,
 };
 };
 
 
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
@@ -63,6 +66,8 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
 
 
   useShowPageLimitationXL(props.showPageLimitationXL);
   useShowPageLimitationXL(props.showPageLimitationXL);
 
 
+  useRendererConfig(props.rendererConfig);
+
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
 
 
@@ -127,6 +132,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
   };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
 }
 }
 
 
 /**
 /**

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -112,8 +112,11 @@ module.exports = function(crowi, app) {
   });
   });
 
 
   app.set('port', crowi.port);
   app.set('port', crowi.port);
+
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   app.use(express.static(crowi.publicDir, staticOption));
   app.use(express.static(crowi.publicDir, staticOption));
+  app.use('/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
+
   app.engine('html', swig.renderFile);
   app.engine('html', swig.renderFile);
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   app.set('view engine', 'html');
   app.set('view engine', 'html');

+ 21 - 0
packages/app/src/server/crowi/index.js

@@ -15,6 +15,7 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import Activity from '../models/activity';
 import Activity from '../models/activity';
+import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
 import UserGroup from '../models/user-group';
@@ -26,6 +27,8 @@ import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
 import PageOperationService from '../service/page-operation';
+// eslint-disable-next-line import/no-cycle
+import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
 import { UserNotificationService } from '../service/user-notification';
@@ -65,6 +68,7 @@ function Crowi() {
   this.growiBridgeService = null;
   this.growiBridgeService = null;
   this.exportService = null;
   this.exportService = null;
   this.importService = null;
   this.importService = null;
+  this.pluginService = null;
   this.searchService = null;
   this.searchService = null;
   this.socketIoService = null;
   this.socketIoService = null;
   this.pageService = null;
   this.pageService = null;
@@ -119,6 +123,7 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupPassport(),
     this.setupSearcher(),
     this.setupSearcher(),
+    this.setupPluginer(),
     this.setupMailer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setupSlackIntegrationService(),
     this.setUpFileUpload(),
     this.setUpFileUpload(),
@@ -130,6 +135,7 @@ Crowi.prototype.init = async function() {
     this.setupUserGroupService(),
     this.setupUserGroupService(),
     this.setupExport(),
     this.setupExport(),
     this.setupImport(),
     this.setupImport(),
+    this.setupPluginService(),
     this.setupPageService(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
     this.setupInAppNotificationService(),
     this.setupActivityService(),
     this.setupActivityService(),
@@ -291,6 +297,7 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
   allModels.PageRedirect = PageRedirect;
+  allModels.growiPlugin = GrowiPlugin;
 
 
   Object.keys(allModels).forEach((key) => {
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
     return this.model(key, models[key](this));
@@ -368,6 +375,13 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
   this.searchService = new SearchService(this);
 };
 };
 
 
+/**
+ * setup PluginService
+ */
+Crowi.prototype.setupPluginer = async function() {
+  this.pluginService = new PluginService(this);
+};
+
 Crowi.prototype.setupMailer = async function() {
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   const MailService = require('~/server/service/mail');
   this.mailService = new MailService(this);
   this.mailService = new MailService(this);
@@ -684,6 +698,13 @@ Crowi.prototype.setupImport = async function() {
   }
   }
 };
 };
 
 
+Crowi.prototype.setupPluginService = async function() {
+  const { PluginService } = require('../service/plugin');
+  if (this.pluginService == null) {
+    this.pluginService = new PluginService(this);
+  }
+};
+
 Crowi.prototype.setupPageService = async function() {
 Crowi.prototype.setupPageService = async function() {
   if (this.pageService == null) {
   if (this.pageService == null) {
     this.pageService = new PageService(this);
     this.pageService = new PageService(this);

+ 1 - 2
packages/app/src/server/middlewares/application-not-installed.js

@@ -5,8 +5,7 @@ module.exports = (crowi) => {
     const isDBInitialized = await appService.isDBInitialized(true);
     const isDBInitialized = await appService.isDBInitialized(true);
 
 
     if (isDBInitialized) {
     if (isDBInitialized) {
-      req.flash('errorMessage', req.t('message.application_already_installed'));
-      return res.redirect('admin');
+      return res.redirect('/');
     }
     }
 
 
     return next();
     return next();

+ 40 - 0
packages/app/src/server/models/growi-plugin.ts

@@ -0,0 +1,40 @@
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import {
+  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType,
+} from '~/interfaces/plugin';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+export interface GrowiPluginDocument extends GrowiPlugin, Document {
+}
+export type GrowiPluginModel = Model<GrowiPluginDocument>
+
+const growiPluginMetaSchema = new Schema<GrowiPluginMeta>({
+  name: { type: String, required: true },
+  types: {
+    type: [String],
+    enum: GrowiPluginResourceType,
+    require: true,
+  },
+  desc: { type: String },
+  author: { type: String },
+});
+
+const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
+  url: { type: String },
+  ghBranch: { type: String },
+  ghTag: { type: String },
+});
+
+const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
+  isEnabled: { type: Boolean },
+  installedPath: { type: String },
+  origin: growiPluginOriginSchema,
+  meta: growiPluginMetaSchema,
+});
+
+
+export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 0 - 76
packages/app/src/server/plugins/plugin-utils.js

@@ -1,76 +0,0 @@
-import loggerFactory from '~/utils/logger';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
-
-// import { PluginUtilsV4 } from './plugin-utils-v4';
-
-const fs = require('graceful-fs');
-
-const logger = loggerFactory('growi:plugins:plugin-utils');
-
-class PluginUtils {
-
-  /**
-   * list plugin module objects
-   *  that starts with 'growi-plugin-' or 'crowi-plugin-'
-   * borrowing from: https://github.com/hexojs/hexo/blob/d1db459c92a4765620343b95789361cbbc6414c5/lib/hexo/load_plugins.js#L17
-   *
-   * @returns array of objects
-   *   [
-   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
-   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
-   *     ...
-   *   ]
-   *
-   * @memberOf PluginService
-   */
-  listPlugins() {
-    const packagePath = resolveFromRoot('package.json');
-
-    // Make sure package.json exists
-    if (!fs.existsSync(packagePath)) {
-      return [];
-    }
-
-    // Read package.json and find dependencies
-    const content = fs.readFileSync(packagePath);
-    const json = JSON.parse(content);
-    const deps = json.dependencies || {};
-
-    const pluginNames = Object.keys(deps).filter((name) => {
-      return /^@growi\/plugin-/.test(name);
-    });
-
-    return pluginNames.map((name) => {
-      return {
-        name,
-        requiredVersion: deps[name],
-        installedVersion: this.getVersion(name),
-      };
-    });
-  }
-
-  /**
-   * list plugin module names that starts with 'crowi-plugin-'
-   *
-   * @returns array of plugin names
-   *
-   * @memberOf PluginService
-   */
-  listPluginNames() {
-    const plugins = this.listPlugins();
-    return plugins.map((plugin) => { return plugin.name });
-  }
-
-  getVersion(packageName) {
-    const packagePath = resolveFromRoot(`../../node_modules/${packageName}/package.json`);
-
-    // Read package.json and find version
-    const content = fs.readFileSync(packagePath);
-    const json = JSON.parse(content);
-    return json.version || '';
-  }
-
-}
-
-module.exports = PluginUtils;
-export default PluginUtils;

+ 0 - 4
packages/app/src/server/routes/apiv3/admin-home.js

@@ -1,9 +1,6 @@
 import ConfigLoader from '../../service/config-loader';
 import ConfigLoader from '../../service/config-loader';
 
 
 const express = require('express');
 const express = require('express');
-const PluginUtils = require('../../plugins/plugin-utils');
-
-const pluginUtils = new PluginUtils();
 
 
 const router = express.Router();
 const router = express.Router();
 
 
@@ -71,7 +68,6 @@ module.exports = (crowi) => {
       nodeVersion: crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : '-',
       nodeVersion: crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : '-',
       npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
       npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
-      installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),
       isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),

+ 4 - 1
packages/app/src/server/routes/apiv3/index.js

@@ -16,7 +16,8 @@ const router = express.Router();
 const routerForAdmin = express.Router();
 const routerForAdmin = express.Router();
 const routerForAuth = express.Router();
 const routerForAuth = express.Router();
 
 
-module.exports = (crowi, app, isInstalled) => {
+module.exports = (crowi, app) => {
+  const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
 
   // add custom functions to express response
   // add custom functions to express response
   require('./response')(express, crowi);
   require('./response')(express, crowi);
@@ -103,6 +104,8 @@ module.exports = (crowi, app, isInstalled) => {
     userActivation.validateCompleteRegistration,
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
     userActivation.completeRegistrationAction(crowi));
 
 
+  router.use('/plugins-extension', require('./plugins-extension')(crowi));
+
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 
 
 

+ 29 - 0
packages/app/src/server/routes/apiv3/plugins-extension.ts

@@ -0,0 +1,29 @@
+import express, { Request } from 'express';
+
+import Crowi from '../../crowi';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+type PluginInstallerFormRequest = Request & { form: any };
+
+module.exports = (crowi: Crowi) => {
+  const router = express.Router();
+  const { pluginService } = crowi;
+
+  router.post('/', async(req: PluginInstallerFormRequest, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err(400);
+    }
+
+    try {
+      await pluginService.install(crowi, req.body.pluginInstallerForm);
+      return res.apiv3({});
+    }
+    catch (err) {
+      // TODO: error handling
+      return res.apiv3Err(err, 400);
+    }
+  });
+
+  return router;
+};

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

@@ -56,11 +56,10 @@ module.exports = function(crowi, app) {
   const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
   const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
   const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
   const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
 
 
-  const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
 
-  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app, isInstalled);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
 
 
   app.use('/api-docs', require('./apiv3/docs')(crowi, app));
   app.use('/api-docs', require('./apiv3/docs')(crowi, app));
 
 
@@ -82,8 +81,6 @@ module.exports = function(crowi, app) {
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
 
-  app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
-
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
   app.get('/admin/export/:fileName'   , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
   app.get('/admin/export/:fileName'   , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
 
@@ -91,10 +88,7 @@ module.exports = function(crowi, app) {
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
 
 
   // installer
   // installer
-  if (!isInstalled) {
-    app.get('/installer'              , applicationNotInstalled, next.delegateToNext);
-    return;
-  }
+  app.get('/installer'                , applicationNotInstalled, next.delegateToNext);
 
 
   // OAuth
   // OAuth
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);

+ 12 - 12
packages/app/src/server/routes/login.js

@@ -102,18 +102,18 @@ module.exports = function(crowi, app) {
 
 
   actions.preLogin = function(req, res, next) {
   actions.preLogin = function(req, res, next) {
     // user has already logged in
     // user has already logged in
-    // const { user } = req;
-    // if (user != null && user.status === User.STATUS_ACTIVE) {
-    //   const { redirectTo } = req.session;
-    //   // remove session.redirectTo
-    //   delete req.session.redirectTo;
-    //   return res.safeRedirect(redirectTo);
-    // }
-
-    // // set referer to 'redirectTo'
-    // if (req.session.redirectTo == null && req.headers.referer != null) {
-    //   req.session.redirectTo = req.headers.referer;
-    // }
+    const { user } = req;
+    if (user != null && user.status === User.STATUS_ACTIVE) {
+      const { redirectTo } = req.session;
+      // remove session.redirectTo
+      delete req.session.redirectTo;
+      return res.safeRedirect(redirectTo);
+    }
+
+    // set referer to 'redirectTo'
+    if (req.session.redirectTo == null && req.headers.referer != null) {
+      req.session.redirectTo = req.headers.referer;
+    }
 
 
     next();
     next();
   };
   };

+ 139 - 0
packages/app/src/server/service/plugin.ts

@@ -0,0 +1,139 @@
+import { execSync } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import mongoose from 'mongoose';
+
+import type { GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin } from '~/interfaces/plugin';
+import loggerFactory from '~/utils/logger';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+// eslint-disable-next-line import/no-cycle
+import Crowi from '../crowi';
+
+const logger = loggerFactory('growi:plugins:plugin-utils');
+
+const pluginStoringPath = resolveFromRoot('tmp/plugins');
+
+// https://regex101.com/r/fK2rV3/1
+const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
+
+
+export class PluginService {
+
+  crowi: any;
+
+  growiBridgeService: any;
+
+  baseDir: any;
+
+  getFile:any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.growiBridgeService = crowi.growiBridgeService;
+    this.baseDir = path.join(crowi.tmpDir, 'plugins');
+    this.getFile = this.growiBridgeService.getFile.bind(this);
+  }
+
+  async install(crowi: Crowi, origin: GrowiPluginOrigin): Promise<void> {
+    // download
+    const ghUrl = new URL(origin.url);
+    const ghPathname = ghUrl.pathname;
+
+    const match = ghPathname.match(githubReposIdPattern);
+    if (ghUrl.hostname !== 'github.com' || match == null) {
+      throw new Error('The GitHub Repository URL is invalid.');
+    }
+
+    const ghOrganizationName = match[1];
+    const ghReposName = match[2];
+
+    try {
+      await this.downloadZipFile(`${ghUrl.href}/archive/refs/heads/main.zip`, ghOrganizationName, ghReposName);
+    }
+    catch (err) {
+      console.log('downloadZipFile error', err);
+    }
+
+    // save plugin metadata
+    const installedPath = `${ghOrganizationName}/${ghReposName}`;
+    const plugins = await PluginService.detectPlugins(origin, installedPath);
+    await this.savePluginMetaData(plugins);
+
+    return;
+  }
+
+  async downloadZipFile(url: string, ghOrganizationName: string, ghReposName: string): Promise<void> {
+
+    const downloadTargetPath = pluginStoringPath;
+    const zipFilePath = path.join(downloadTargetPath, 'main.zip');
+    const unzipTargetPath = path.join(pluginStoringPath, ghOrganizationName);
+
+    const stdout1 = execSync(`wget ${url} -O ${zipFilePath}`);
+    const stdout2 = execSync(`mkdir -p ${ghOrganizationName}`);
+    const stdout3 = execSync(`rm -rf ${ghOrganizationName}/${ghReposName}`);
+    const stdout4 = execSync(`unzip ${zipFilePath} -d ${unzipTargetPath}`);
+    const stdout5 = execSync(`mv ${unzipTargetPath}/${ghReposName}-main ${unzipTargetPath}/${ghReposName}`);
+    const stdout6 = execSync(`rm ${zipFilePath}`);
+
+    return;
+  }
+
+  async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
+    const GrowiPlugin = mongoose.model('GrowiPlugin');
+    await GrowiPlugin.insertMany(plugins);
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+    const packageJsonPath = path.resolve(pluginStoringPath, installedPath, 'package.json');
+    const packageJson = await import(packageJsonPath);
+
+    const { growiPlugin } = packageJson;
+    const {
+      name: packageName, description: packageDesc, author: packageAuthor,
+    } = parentPackageJson ?? packageJson;
+
+
+    if (growiPlugin == null) {
+      throw new Error('This package does not include \'growiPlugin\' section.');
+    }
+
+    // detect sub plugins for monorepo
+    if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
+      const plugins = await Promise.all(
+        growiPlugin.packages.map(async(subPackagePath) => {
+          const subPackageInstalledPath = path.join(installedPath, subPackagePath);
+          return this.detectPlugins(origin, subPackageInstalledPath, packageJson);
+        }),
+      );
+      return plugins.flat();
+    }
+
+    if (growiPlugin.types == null) {
+      throw new Error('\'growiPlugin\' section must have a \'types\' property.');
+    }
+    const plugin = {
+      isEnabled: true,
+      installedPath,
+      origin,
+      meta: {
+        name: growiPlugin.name ?? packageName,
+        desc: growiPlugin.desc ?? packageDesc,
+        author: growiPlugin.author ?? packageAuthor,
+        types: growiPlugin.types,
+      },
+    };
+
+    logger.info('Plugin detected => ', plugin);
+
+    return [plugin];
+  }
+
+  async listPlugins(): Promise<GrowiPlugin[]> {
+    return [];
+  }
+
+
+}

+ 15 - 0
packages/app/src/services/renderer/renderer.tsx

@@ -1,6 +1,7 @@
 // allow only types to import from react
 // allow only types to import from react
 import { ComponentType } from 'react';
 import { ComponentType } from 'react';
 
 
+import { isClient } from '@growi/core';
 import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
 import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
@@ -29,6 +30,7 @@ import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { Table } from '~/components/ReactMarkdownComponents/Table';
 import { Table } from '~/components/ReactMarkdownComponents/Table';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { RendererConfig } from '~/interfaces/services/renderer';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import * as addClass from './rehype-plugins/add-class';
 import * as addClass from './rehype-plugins/add-class';
@@ -499,3 +501,16 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   verifySanitizePlugin(options);
   verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
+
+
+// register to facade
+if (isClient()) {
+  registerGrowiFacade({
+    markdownRenderer: {
+      optionsGenerators: {
+        generateViewOptions,
+        generatePreviewOptions,
+      },
+    },
+  });
+}

+ 4 - 0
packages/app/src/stores/editor.tsx

@@ -122,3 +122,7 @@ export const usePageTagsForEditors = (pageId: Nullable<string>): SWRResponse<str
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
 export const useIsEnabledUnsavedWarning = (): SWRResponse<boolean, Error> => {
   return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
   return useStaticSWR<boolean, Error>('isEnabledUnsavedWarning');
 };
 };
+
+export const useIsConflict = (): SWRResponse<boolean, Error> => {
+  return useStaticSWR<boolean, Error>('isConflict', undefined, { fallbackData: false });
+};

+ 3 - 2
packages/app/src/stores/hackmd.ts

@@ -1,4 +1,5 @@
 import { SWRResponse } from 'swr';
 import { SWRResponse } from 'swr';
+
 import { useStaticSWR } from './use-static-swr';
 import { useStaticSWR } from './use-static-swr';
 
 
 type Nullable<T> = T | null;
 type Nullable<T> = T | null;
@@ -16,6 +17,6 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
   return useStaticSWR<Nullable<any>, Error>('revisionIdHackmdSynced', initialData);
 };
 };
 
 
-export const useRemoteRevisionId = (initialData?: Nullable<any>): SWRResponse<Nullable<any>, Error> => {
-  return useStaticSWR<Nullable<any>, Error>('remoteRevisionId', initialData);
+export const useIsHackmdDraftUpdatingInRealtime = (initialData?: Nullable<boolean>): SWRResponse<Nullable<boolean>, Error> => {
+  return useStaticSWR<Nullable<boolean>, Error>('isHackmdDraftUpdatingInRealtime', initialData);
 };
 };

+ 21 - 2
packages/app/src/stores/in-app-notification.ts

@@ -1,7 +1,15 @@
 import useSWR, { SWRResponse } from 'swr';
 import useSWR, { SWRResponse } from 'swr';
-import { InAppNotificationStatuses, IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+
+import type { InAppNotificationStatuses, IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
+import { parseSnapshot } from '~/models/serializers/in-app-notification-snapshot/page';
+import loggerFactory from '~/utils/logger';
+
 import { apiv3Get } from '../client/util/apiv3-client';
 import { apiv3Get } from '../client/util/apiv3-client';
 
 
+const logger = loggerFactory('growi:cli:InAppNotification');
+
+type inAppNotificationPaginateResult = PaginateResult<IInAppNotification>
+
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 export const useSWRxInAppNotifications = <Data, Error>(
 export const useSWRxInAppNotifications = <Data, Error>(
   limit: number,
   limit: number,
@@ -10,7 +18,18 @@ export const useSWRxInAppNotifications = <Data, Error>(
 ): SWRResponse<PaginateResult<IInAppNotification>, Error> => {
 ): SWRResponse<PaginateResult<IInAppNotification>, Error> => {
   return useSWR(
   return useSWR(
     ['/in-app-notification/list', limit, offset, status],
     ['/in-app-notification/list', limit, offset, status],
-    endpoint => apiv3Get(endpoint, { limit, offset, status }).then(response => response.data),
+    endpoint => apiv3Get(endpoint, { limit, offset, status }).then((response) => {
+      const inAppNotificationPaginateResult = response.data as inAppNotificationPaginateResult;
+      inAppNotificationPaginateResult.docs.forEach((doc) => {
+        try {
+          doc.parsedSnapshot = parseSnapshot(doc.snapshot as string);
+        }
+        catch (err) {
+          logger.warn('Failed to parse snapshot', err);
+        }
+      });
+      return inAppNotificationPaginateResult;
+    }),
   );
   );
 };
 };
 
 

+ 10 - 5
packages/app/src/stores/page-listing.tsx

@@ -134,18 +134,17 @@ export const useSWRxPageInfoForList = (
 };
 };
 
 
 export const usePageTreeTermManager = (isDisabled?: boolean) : SWRResponse<number, Error> & ITermNumberManagerUtil => {
 export const usePageTreeTermManager = (isDisabled?: boolean) : SWRResponse<number, Error> & ITermNumberManagerUtil => {
-  return useTermNumberManager(isDisabled === true ? null : 'fullTextSearchTermNumber');
+  return useTermNumberManager(isDisabled === true ? null : 'pageTreeTermManager');
 };
 };
 
 
 export const useSWRxRootPage = (): SWRResponse<RootPageResult, Error> => {
 export const useSWRxRootPage = (): SWRResponse<RootPageResult, Error> => {
-  return useSWR(
+  return useSWRImmutable(
     '/page-listing/root',
     '/page-listing/root',
     endpoint => apiv3Get(endpoint).then((response) => {
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
       return {
         rootPage: response.data.rootPage,
         rootPage: response.data.rootPage,
       };
       };
     }),
     }),
-    { revalidateOnFocus: false },
   );
   );
 };
 };
 
 
@@ -154,14 +153,20 @@ export const useSWRxPageAncestorsChildren = (
 ): SWRResponse<AncestorsChildrenResult, Error> => {
 ): SWRResponse<AncestorsChildrenResult, Error> => {
   const { data: termNumber } = usePageTreeTermManager();
   const { data: termNumber } = usePageTreeTermManager();
 
 
-  return useSWR(
+  // HACKME: Consider using global mutation from useSWRConfig and not to use term number -- 2022/12/08 @hakumizuki
+  const prevTermNumber = termNumber ? termNumber - 1 : 0;
+  const prevSWRRes = useSWRImmutable(path ? [`/page-listing/ancestors-children?path=${path}`, prevTermNumber] : null);
+
+  return useSWRImmutable(
     path ? [`/page-listing/ancestors-children?path=${path}`, termNumber] : null,
     path ? [`/page-listing/ancestors-children?path=${path}`, termNumber] : null,
     endpoint => apiv3Get(endpoint).then((response) => {
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
       return {
         ancestorsChildren: response.data.ancestorsChildren,
         ancestorsChildren: response.data.ancestorsChildren,
       };
       };
     }),
     }),
-    { revalidateOnFocus: false },
+    {
+      fallbackData: prevSWRRes.data, // avoid data to be undefined due to the termNumber to change
+    },
   );
   );
 };
 };
 
 

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

@@ -29,7 +29,7 @@ export const useSWRxPage = (
     revisionId?: string,
     revisionId?: string,
     initialData?: IPagePopulatedToShowRevision|null,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
-  const swrResponse = useSWR<IPagePopulatedToShowRevision|null, Error>(
+  const swrResponse = useSWRImmutable<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .then(result => result.data.page)
       .then(result => result.data.page)

+ 18 - 0
packages/app/src/stores/remote-latest-page.ts

@@ -0,0 +1,18 @@
+import { SWRResponse } from 'swr';
+
+import { IUser } from '~/interfaces/user';
+
+import { useStaticSWR } from './use-static-swr';
+
+
+export const useRemoteRevisionId = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('remoteRevisionId', initialData);
+};
+
+export const useRemoteRevisionBody = (initialData?: string): SWRResponse<string, Error> => {
+  return useStaticSWR<string, Error>('remoteRevisionId', initialData);
+};
+
+export const useRemoteRevisionLastUpdatUser = (initialData?: IUser): SWRResponse<IUser, Error> => {
+  return useStaticSWR<IUser, Error>('remoteRevisionLastUpdatUser', initialData);
+};

+ 14 - 5
packages/app/src/stores/renderer.tsx

@@ -1,5 +1,5 @@
 import { HtmlElementNode } from 'rehype-toc';
 import { HtmlElementNode } from 'rehype-toc';
-import { Key, SWRResponse } from 'swr';
+import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -8,6 +8,7 @@ import {
   generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
+import { getGrowiFacade } from '~/utils/growi-facade';
 
 
 
 
 import {
 import {
@@ -52,9 +53,13 @@ export const useViewOptions = (storeTocNodeHandler: (toc: HtmlElementNode) => vo
     ? ['viewOptions', currentPagePath, rendererConfig]
     ? ['viewOptions', currentPagePath, rendererConfig]
     : null;
     : null;
 
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     key,
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
+    (rendererId, currentPagePath, rendererConfig) => {
+      // determine options generator
+      const optionsGenerator = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGenerateViewOptions ?? generateViewOptions;
+      return optionsGenerator(currentPagePath, rendererConfig, storeTocNodeHandler);
+    },
     {
     {
       fallbackData: isAllDataValid ? generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler) : undefined,
       fallbackData: isAllDataValid ? generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler) : undefined,
     },
     },
@@ -88,9 +93,13 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
     ? ['previewOptions', rendererConfig, currentPagePath]
     ? ['previewOptions', rendererConfig, currentPagePath]
     : null;
     : null;
 
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     key,
     key,
-    (rendererId, rendererConfig, currentPagePath) => generatePreviewOptions(rendererConfig, currentPagePath),
+    (rendererId, rendererConfig, pagePath, highlightKeywords) => {
+      // determine options generator
+      const optionsGenerator = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGeneratePreviewOptions ?? generatePreviewOptions;
+      return optionsGenerator(rendererConfig, pagePath, highlightKeywords);
+    },
     {
     {
       fallbackData: isAllDataValid ? generatePreviewOptions(rendererConfig, currentPagePath) : undefined,
       fallbackData: isAllDataValid ? generatePreviewOptions(rendererConfig, currentPagePath) : undefined,
     },
     },

+ 61 - 0
packages/app/src/stores/template.tsx

@@ -0,0 +1,61 @@
+import { ITemplate } from '@growi/core';
+import useSWR, { SWRResponse } from 'swr';
+
+import { getGrowiFacade } from '~/utils/growi-facade';
+
+const presetTemplates: ITemplate[] = [
+  // preset 1
+  {
+    id: '__preset1__',
+    name: '[Preset] WESEEK Inner Wiki Style',
+    markdown: `# 関連ページ
+
+$lsx()
+
+# `,
+  },
+
+  // preset 2
+  {
+    id: '__preset2__',
+    name: '[Preset] Qiita Style',
+    markdown: `# <会議体名>
+## 日時
+yyyy/mm/dd hh:mm〜hh:mm
+
+## 場所
+
+## 出席者
+-
+
+## 議題
+1. [議題1](#link)
+2.
+3.
+
+## 議事内容
+### <a name="link"></a>議題1
+
+## 決定事項
+- 決定事項1
+
+## アクション事項
+- [ ] アクション
+
+## 次回
+yyyy/mm/dd (予定、時間は追って連絡)`,
+  },
+];
+
+export const useTemplates = (): SWRResponse<ITemplate[], Error> => {
+  return useSWR<ITemplate[], Error>(
+    'templates',
+    () => [
+      ...presetTemplates,
+      ...Object.values(getGrowiFacade().customTemplates ?? {}),
+    ],
+    {
+      fallbackData: presetTemplates,
+    },
+  );
+};

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