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

Merge branch 'master' into fix/vrt-30-search-all-pages-1-2-3

Taichi Masuyama 3 лет назад
Родитель
Сommit
d1b31f34d6
82 измененных файлов с 1702 добавлено и 471 удалено
  1. 16 14
      packages/app/_obsolete/src/client/services/PageContainer.js
  2. 0 17
      packages/app/bin/templates/plugin-definitions.js.swig
  3. 0 1
      packages/app/package.json
  4. 0 1
      packages/app/public/static/locales/en_US/admin.json
  5. 0 1
      packages/app/public/static/locales/ja_JP/admin.json
  6. 0 1
      packages/app/public/static/locales/zh_CN/admin.json
  7. 0 2
      packages/app/src/client/services/AdminHomeContainer.js
  8. 51 0
      packages/app/src/client/services/activate-plugin.ts
  9. 0 8
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  10. 0 55
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  11. 21 0
      packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx
  12. 3 0
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  13. 13 0
      packages/app/src/components/Admin/PluginsExtension/Loading.js
  14. 68 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss
  15. 85 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx
  16. 91 0
      packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx
  17. 58 0
      packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx
  18. 2 2
      packages/app/src/components/CreateTemplateModal.jsx
  19. 1 1
      packages/app/src/components/EmptyTrashModal.tsx
  20. 1 1
      packages/app/src/components/Layout/AdminLayout.tsx
  21. 8 2
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  22. 14 1
      packages/app/src/components/Page.tsx
  23. 28 1
      packages/app/src/components/Page/DisplaySwitcher.tsx
  24. 1 1
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  25. 4 2
      packages/app/src/components/PageComment/CommentEditor.tsx
  26. 3 2
      packages/app/src/components/PageCreateModal.jsx
  27. 1 1
      packages/app/src/components/PageCreateModal.module.scss
  28. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  29. 59 1
      packages/app/src/components/PageEditor.tsx
  30. 22 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  31. 8 0
      packages/app/src/components/PageEditor/EditorIcon.jsx
  32. 2 2
      packages/app/src/components/PageEditor/LinkEditModal.jsx
  33. 10 7
      packages/app/src/components/PageEditorByHackmd.tsx
  34. 0 177
      packages/app/src/components/PageStatusAlert.jsx
  35. 40 0
      packages/app/src/components/PageStatusAlert.module.scss
  36. 166 0
      packages/app/src/components/PageStatusAlert.tsx
  37. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  38. 1 1
      packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx
  39. 1 1
      packages/app/src/components/PutbackPageModal.jsx
  40. 117 0
      packages/app/src/components/TemplateModal.tsx
  41. 30 0
      packages/app/src/components/TemplateTab.tsx
  42. 2 2
      packages/app/src/components/Theme/ThemeDefault.global.scss
  43. 1 1
      packages/app/src/components/Theme/ThemeDefault.tsx
  44. 12 5
      packages/app/src/components/Theme/utils/ThemeInjector.tsx
  45. 21 0
      packages/app/src/interfaces/github-api.ts
  46. 26 0
      packages/app/src/interfaces/plugin.ts
  47. 5 0
      packages/app/src/interfaces/websocket.ts
  48. 6 1
      packages/app/src/pages/[[...path]].page.tsx
  49. 5 0
      packages/app/src/pages/_app.page.tsx
  50. 49 6
      packages/app/src/pages/_document.page.tsx
  51. 0 3
      packages/app/src/pages/admin/index.page.tsx
  52. 53 0
      packages/app/src/pages/admin/plugins.page.tsx
  53. 12 0
      packages/app/src/pages/invited.page.tsx
  54. 23 3
      packages/app/src/pages/me/[[...path]].page.tsx
  55. 22 1
      packages/app/src/pages/tags.page.tsx
  56. 22 1
      packages/app/src/pages/trash.page.tsx
  57. 3 0
      packages/app/src/server/crowi/express-init.js
  58. 21 0
      packages/app/src/server/crowi/index.js
  59. 1 2
      packages/app/src/server/middlewares/application-not-installed.js
  60. 40 0
      packages/app/src/server/models/growi-plugin.ts
  61. 0 76
      packages/app/src/server/plugins/plugin-utils.js
  62. 0 4
      packages/app/src/server/routes/apiv3/admin-home.js
  63. 4 1
      packages/app/src/server/routes/apiv3/index.js
  64. 29 0
      packages/app/src/server/routes/apiv3/plugins-extention.ts
  65. 2 6
      packages/app/src/server/routes/index.js
  66. 139 0
      packages/app/src/server/service/plugin.ts
  67. 15 0
      packages/app/src/services/renderer/renderer.tsx
  68. 4 0
      packages/app/src/stores/editor.tsx
  69. 3 2
      packages/app/src/stores/hackmd.ts
  70. 10 5
      packages/app/src/stores/page-listing.tsx
  71. 1 1
      packages/app/src/stores/page.tsx
  72. 18 0
      packages/app/src/stores/remote-latest-page.ts
  73. 14 5
      packages/app/src/stores/renderer.tsx
  74. 61 0
      packages/app/src/stores/template.tsx
  75. 27 0
      packages/app/src/stores/useInstalledPlugins.ts
  76. 28 9
      packages/app/src/stores/websocket.tsx
  77. 34 31
      packages/app/src/styles/_page.scss
  78. 39 0
      packages/app/src/utils/growi-facade.ts
  79. 0 1
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  80. 2 0
      packages/core/src/index.ts
  81. 16 0
      packages/core/src/interfaces/growi-facade.ts
  82. 5 0
      packages/core/src/interfaces/template.ts

+ 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() {
     // 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) => {
       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 %}
-
-]

+ 0 - 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\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
     "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",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },

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

@@ -285,7 +285,6 @@
     "system_information": "System information",
     "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",
-    "list_of_installed_plugins": "List of installed plugins",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "installed_version": "Installed version",

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

@@ -311,7 +311,6 @@
     "system_information": "システム情報",
     "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
     "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
-    "list_of_installed_plugins": "インストールされているプラグイン一覧",
     "package_name": "パッケージ名",
     "specified_version": "指定バージョン",
     "installed_version": "インストールされているバージョン",

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

@@ -316,7 +316,6 @@
     "system_information": "系统信息",
     "wiki_administrator": "只有wiki管理员可以访问此页",
     "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-    "list_of_installed_plugins": "已安装插件列表",
     "package_name": "包名称",
     "specified_version": "指定版本",
     "installed_version": "已安装版本",

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

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

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

@@ -15,7 +15,6 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 import EnvVarsTable from './EnvVarsTable';
-import InstalledPluginTable from './InstalledPluginTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 const logger = loggerFactory('growi:admin');
@@ -85,13 +84,6 @@ const AdminHome = (props) => {
         </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="col-md-12">
           <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 '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 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{           'Plugins Extention'}</>;
       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') }</>;
       /* eslint-enable no-multi-spaces, max-len */
@@ -91,6 +92,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
@@ -140,6 +142,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 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-extention', { 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>
+  );
+};

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

@@ -1,8 +1,8 @@
 import React from 'react';
 
 import { pathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 
@@ -42,7 +42,7 @@ const CreateTemplateModal = (props) => {
   }
 
   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">
         {t('template.modal_label.Create/Edit Template Page')}
       </ModalHeader>

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

@@ -59,7 +59,7 @@ const EmptyTrashModal: FC = () => {
   };
 
   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">
         <i className="icon-fw icon-fire"></i>
         {t('modal_empty.empty_the_trash')}

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

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

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

@@ -136,7 +136,13 @@ const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
 
 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 });
 
@@ -169,7 +175,7 @@ export const GrowiNavbar = (): JSX.Element => {
       </ul>
 
       <div className="grw-global-search-container position-absolute">
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
+        { !isGlobalSearchHidden && isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
           <GlobalSearch />
         ) }
       </div>

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

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

+ 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 { useTranslation } from 'next-i18next';
@@ -6,12 +6,15 @@ import dynamic from 'next/dynamic';
 import { Link } from 'react-scroll';
 
 import { DEFAULT_AUTO_SCROLL_OPTS } from '~/client/util/smooth-scroll';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
 
 import CountBadge from '../Common/CountBadge';
 import { ContentLinkButtonsProps } from '../ContentLinkButtons';
@@ -44,10 +47,34 @@ const PageView = React.memo((): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
+  const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
+  const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
 
   const isTopPagePath = isTopPage(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 (
     <div className="d-flex flex-column flex-lg-row">
 

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

@@ -224,7 +224,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   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">
         { t('fix_page_grant.modal.title') }
       </ModalHeader>

+ 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 commentPreview = getCommentHtml();
 
@@ -269,10 +271,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <TabPane tabId="comment_editor">
               <Editor
                 ref={editorRef}
-                value={comment}
+                value={commentBody ?? ''} // DO NOT use state
                 isUploadable={isUploadable}
                 isUploadableFile={isUploadableFile}
-                onChange={setComment}
+                onChange={onChangeHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment

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

@@ -11,7 +11,6 @@ import {
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-
 import { toastError } from '~/client/util/apiNotification';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
@@ -19,6 +18,8 @@ import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
+import styles from './PageCreateModal.module.scss';
+
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
 } = pagePathUtils;
@@ -315,7 +316,7 @@ const PageCreateModal = () => {
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
       data-testid="page-create-modal"
-      className="grw-create-page"
+      className={`grw-create-page ${styles['grw-create-page']}`}
       autoFocus={false}
     >
       <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 {
     width: 60px;
   }

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

@@ -281,7 +281,7 @@ const PageDeleteModal: FC = () => {
   };
 
   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`}>
         {headerContent()}
       </ModalHeader>

+ 59 - 1
packages/app/src/components/PageEditor.tsx

@@ -17,6 +17,7 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { IEditorMethods } from '~/interfaces/editor-methods';
 import { OptionsToSave } from '~/interfaces/page-operation';
+import { SocketEventName } from '~/interfaces/websocket';
 import {
   useCurrentPathname, useCurrentPageId, useIsEnabledAttachTitleHeader, useTemplateBodyData,
   useIsEditable, useIsUploadableFile, useIsUploadableImage, useIsNotFound, useIsIndentSizeForced,
@@ -24,6 +25,7 @@ import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
+  useIsConflict,
   useEditingMarkdown,
 } from '~/stores/editor';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
@@ -32,6 +34,8 @@ import {
   EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
+import { useGlobalSocket } from '~/stores/websocket';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 
@@ -80,7 +84,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
-  const { data: rendererOptions } = usePreviewOptions();
+  const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
 
@@ -107,9 +111,63 @@ const PageEditor = React.memo((): JSX.Element => {
 
   const slackChannels = useMemo(() => (slackChannelsData ? slackChannelsData.toString() : ''), [slackChannelsData]);
 
+  const { data: socket } = useGlobalSocket();
+
+  const { mutate: mutateIsConflict } = useIsConflict();
+
+
   const editorRef = useRef<IEditorMethods>(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) => {
     markdownToSave.current = value;
     setMarkdownToPreview(value);

+ 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 loggerFactory from '~/utils/logger';
 
+import { TemplateModal } from '../TemplateModal';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
@@ -110,6 +111,7 @@ class CodeMirrorEditor extends AbstractEditor {
       emojiSearchText: '',
       startPosWithEmojiPickerModeTurnedOn: null,
       isEmojiPickerMode: false,
+      isTemplateModalOpened: false,
     };
 
     this.cm = React.createRef();
@@ -161,6 +163,8 @@ class CodeMirrorEditor extends AbstractEditor {
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
+    this.showTemplateModal = this.showTemplateModal.bind(this);
+
   }
 
   init() {
@@ -869,6 +873,10 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
+  showTemplateModal() {
+    this.setState({ isTemplateModalOpened: true });
+  }
+
   // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
@@ -1049,6 +1057,15 @@ class CodeMirrorEditor extends AbstractEditor {
       >
         <EditorIcon icon="Emoji" />
       </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}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
+        <TemplateModal
+          isOpen={this.state.isTemplateModalOpened}
+          onClose={() => this.setState({ isTemplateModalOpened: false })}
+          onSubmit={templateText => this.setValue(templateText) }
+        />
       </div>
     );
   }

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

@@ -1,5 +1,6 @@
 /* eslint-disable max-len */
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
 const EditorIcon = (props) => {
@@ -139,6 +140,13 @@ const EditorIcon = (props) => {
           </g>
         </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 { data: currentPath } = useCurrentPagePath();
   return <LinkEditModal t={t} ref={ref} pagePath={currentPath} {...props} />;
-});
+}));
 
 LinkEditModal.propTypes = {
   t: PropTypes.func.isRequired,

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

@@ -19,12 +19,13 @@ import {
   useCurrentPageId, useCurrentPathname, useHackmdUri, useIsNotFound,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors,
+  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 import {
-  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useRemoteRevisionId,
+  usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -83,7 +84,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: pageIdOnHackmd, mutate: mutatePageIdOnHackmd } = usePageIdOnHackmd();
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   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 hackmdEditorRef = useRef<HackEditorRef>(null);
@@ -211,7 +213,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
         throw new Error(res.error);
       }
 
-      setIsHackmdDraftUpdatingInRealtime(false);
+      mutateIsHackmdDraftUpdatingInRealtime(false);
       mutateHasDraftOnHackmd(false);
       mutatePageIdOnHackmd(res.pageIdOnHackmd);
       mutateRemoteRevisionId(res.revisionIdHackmdSynced);
@@ -223,7 +225,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error(err);
       toastError(err.message);
     }
-  }, [pageId, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced]);
+  }, [mutateIsHackmdDraftUpdatingInRealtime, mutateHasDraftOnHackmd, mutatePageIdOnHackmd, mutateRevisionIdHackmdSynced, mutateRemoteRevisionId, pageId]);
 
   /**
    * save and update state of containers
@@ -264,8 +266,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       logger.error('failed to save', error);
       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

+ 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]);
 
   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">
         { t('private_legacy_pages.by_path_modal.title') }
       </ModalHeader>

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

@@ -73,7 +73,7 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
   };
 
   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">
         { t('private_legacy_pages.modal.title') }
       </ModalHeader>

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

@@ -116,7 +116,7 @@ const PutBackPageModal = () => {
   }, [closePutBackPageModal]);
 
   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">
         <HeaderContent/>
       </ModalHeader>

+ 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
 //
-:root[data-theme='light'] .theme-default {
+:root[data-theme='light'] {
   $primary: #122c55;
   $accent: #209fd8;
 
@@ -116,7 +116,7 @@
 
 //== Dark Mode
 //
-:root[data-theme='dark'] .theme-default {
+:root[data-theme='dark'] {
   $primary: #115cd3;
   $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';
 
 const ThemeDefault = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector className="theme-default">{children}</ThemeInjector>;
+  return <ThemeInjector>{children}</ThemeInjector>;
 };
 export default ThemeDefault;

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

@@ -5,20 +5,27 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 type Props = {
   children: JSX.Element,
-  className: string,
+  bodyTagClassName?: string,
+  className?: string,
   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>
   useIsomorphicLayoutEffect(() => {
-    document.body.classList.add(themeClassName);
+    if (bodyTagClassName != null) {
+      document.body.classList.add(bodyTagClassName);
+    }
 
     // clean up
     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,
+}

+ 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',
   RebuildingFailed: 'rebuildingFailed',
 
+  // Page Operation
+  PageCreated: 'page:create',
+  PageUpdated: 'page:update',
+  PageDeleted: 'page:delete',
+
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

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

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

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

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

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

@@ -1,18 +1,57 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
+import mongoose from 'mongoose';
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
 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> {
 
@@ -22,12 +61,15 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const { customizeService } = crowi;
     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 {
-    const { customCss } = this.props;
+    const { customCss, pluginManifestEntries } = this.props;
 
     return (
       <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-Bold-latin.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>
         <body>
           <Main />

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

@@ -9,7 +9,6 @@ import { Container, Provider } from 'unstated';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import PluginUtils from '~/server/plugins/plugin-utils';
 import { useCurrentUser, useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -63,12 +62,10 @@ const AdminHomePage: NextPage<Props> = (props) => {
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const pluginUtils = new PluginUtils();
 
   props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
-  props.installedPlugins = pluginUtils.listPlugins();
   props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
   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 Extention';
+  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 type { IUserHasId, IUser } from '@growi/core';
+import { USER_STATUS } from '@growi/core';
 import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
@@ -76,6 +77,17 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
 
   if (user != null) {
     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);

+ 23 - 3
packages/app/src/pages/me/[[...path]].page.tsx

@@ -12,6 +12,7 @@ import { useRouter } from 'next/router';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
@@ -19,7 +20,7 @@ import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhiteList, useShowPageLimitationXL,
+  useRegistrationWhiteList, useShowPageLimitationXL, useRendererConfig,
 } from '~/stores/context';
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -39,6 +40,7 @@ type Props = CommonProps & {
   isSearchScopeChildrenAsDefault: boolean,
   userUISettings?: IUserUISettings
   sidebarConfig: ISidebarConfig,
+  rendererConfig: RendererConfig,
   showPageLimitationXL: number,
 
   // config
@@ -93,18 +95,20 @@ const MePage: NextPage<Props> = (props: Props) => {
   // commons
   useCsrfToken(props.csrfToken);
 
-  // // UserUISettings
+  // UserUISettings
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
   useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
-  // // page
+  // page
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
+  useRendererConfig(props.rendererConfig);
+
   return (
     <>
       <BasicLayout title={useCustomTitle(props, 'GROWI')}>
@@ -159,6 +163,22 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     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 type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -21,7 +22,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault,
+  useIsSearchScopeChildrenAsDefault, useRendererConfig,
 } from '../stores/context';
 
 import {
@@ -41,6 +42,8 @@ type Props = CommonProps & {
 
   // sidebar
   sidebarConfig: ISidebarConfig,
+
+  rendererConfig: RendererConfig,
 };
 
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
@@ -74,6 +77,8 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
+  useRendererConfig(props.rendererConfig);
+
   return (
     <>
       <Head>
@@ -138,6 +143,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     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

@@ -7,6 +7,7 @@ import dynamic from 'next/dynamic';
 
 import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
@@ -18,7 +19,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useCurrentPageId, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useRendererConfig,
 } from '../stores/context';
 
 import {
@@ -40,6 +41,8 @@ type Props = CommonProps & {
   userUISettings?: IUserUISettings
   // Sidebar
   sidebarConfig: ISidebarConfig,
+
+  rendererConfig: RendererConfig,
 };
 
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
@@ -62,6 +65,8 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
 
   useShowPageLimitationXL(props.showPageLimitationXL);
 
+  useRendererConfig(props.rendererConfig);
+
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
 
@@ -121,6 +126,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     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);
+
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   app.use(express.static(crowi.publicDir, staticOption));
+  app.use('/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
+
   app.engine('html', swig.renderFile);
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   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 Activity from '../models/activity';
+import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
@@ -26,6 +27,8 @@ import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+// eslint-disable-next-line import/no-cycle
+import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -65,6 +68,7 @@ function Crowi() {
   this.growiBridgeService = null;
   this.exportService = null;
   this.importService = null;
+  this.pluginService = null;
   this.searchService = null;
   this.socketIoService = null;
   this.pageService = null;
@@ -119,6 +123,7 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupSearcher(),
+    this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setUpFileUpload(),
@@ -130,6 +135,7 @@ Crowi.prototype.init = async function() {
     this.setupUserGroupService(),
     this.setupExport(),
     this.setupImport(),
+    this.setupPluginService(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
     this.setupActivityService(),
@@ -291,6 +297,7 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
+  allModels.growiPlugin = GrowiPlugin;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
@@ -368,6 +375,13 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
 };
 
+/**
+ * setup PluginService
+ */
+Crowi.prototype.setupPluginer = async function() {
+  this.pluginService = new PluginService(this);
+};
+
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   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() {
   if (this.pageService == null) {
     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);
 
     if (isDBInitialized) {
-      req.flash('errorMessage', req.t('message.application_already_installed'));
-      return res.redirect('admin');
+      return res.redirect('/');
     }
 
     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';
 
 const express = require('express');
-const PluginUtils = require('../../plugins/plugin-utils');
-
-const pluginUtils = new PluginUtils();
 
 const router = express.Router();
 
@@ -71,7 +68,6 @@ module.exports = (crowi) => {
       nodeVersion: crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : '-',
       npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
-      installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       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 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
   require('./response')(express, crowi);
@@ -103,6 +104,8 @@ module.exports = (crowi, app, isInstalled) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
+  router.use('/plugins-extention', require('./plugins-extention')(crowi));
+
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 

+ 29 - 0
packages/app/src/server/routes/apiv3/plugins-extention.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 - 6
packages/app/src/server/routes/index.js

@@ -56,11 +56,10 @@ module.exports = function(crowi, app) {
   const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
   const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
 
-  const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
   /* 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));
 
@@ -89,10 +88,7 @@ module.exports = function(crowi, app) {
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
 
   // installer
-  if (!isInstalled) {
-    app.get('/installer'              , applicationNotInstalled, next.delegateToNext);
-    return;
-  }
+  app.get('/installer'                , applicationNotInstalled, next.delegateToNext);
 
   // OAuth
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);

+ 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
 import { ComponentType } from 'react';
 
+import { isClient } from '@growi/core';
 import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 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 { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { RendererConfig } from '~/interfaces/services/renderer';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 import * as addClass from './rehype-plugins/add-class';
@@ -499,3 +501,16 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   verifySanitizePlugin(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> => {
   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 { useStaticSWR } from './use-static-swr';
 
 type Nullable<T> = T | null;
@@ -16,6 +17,6 @@ export const useRevisionIdHackmdSynced = (initialData?: Nullable<any>): SWRRespo
   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);
 };

+ 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 => {
-  return useTermNumberManager(isDisabled === true ? null : 'fullTextSearchTermNumber');
+  return useTermNumberManager(isDisabled === true ? null : 'pageTreeTermManager');
 };
 
 export const useSWRxRootPage = (): SWRResponse<RootPageResult, Error> => {
-  return useSWR(
+  return useSWRImmutable(
     '/page-listing/root',
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
         rootPage: response.data.rootPage,
       };
     }),
-    { revalidateOnFocus: false },
   );
 };
 
@@ -154,14 +153,20 @@ export const useSWRxPageAncestorsChildren = (
 ): SWRResponse<AncestorsChildrenResult, Error> => {
   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,
     endpoint => apiv3Get(endpoint).then((response) => {
       return {
         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,
     initialData?: IPagePopulatedToShowRevision|null,
 ): SWRResponse<IPagePopulatedToShowRevision|null, Error> => {
-  const swrResponse = useSWR<IPagePopulatedToShowRevision|null, Error>(
+  const swrResponse = useSWRImmutable<IPagePopulatedToShowRevision|null, Error>(
     pageId != null ? ['/page', pageId, shareLinkId, revisionId] : null,
     (endpoint, pageId, shareLinkId, revisionId) => apiv3Get<{ page: IPagePopulatedToShowRevision }>(endpoint, { pageId, shareLinkId, revisionId })
       .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 { Key, SWRResponse } from 'swr';
+import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -8,6 +8,7 @@ import {
   generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
+import { getGrowiFacade } from '~/utils/growi-facade';
 
 
 import {
@@ -52,9 +53,13 @@ export const useViewOptions = (storeTocNodeHandler: (toc: HtmlElementNode) => vo
     ? ['viewOptions', currentPagePath, rendererConfig]
     : null;
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     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,
     },
@@ -88,9 +93,13 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
     ? ['previewOptions', rendererConfig, currentPagePath]
     : null;
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     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,
     },

+ 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,
+    },
+  );
+};

+ 27 - 0
packages/app/src/stores/useInstalledPlugins.ts

@@ -0,0 +1,27 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import type { SearchResult, SearchResultItem } from '../interfaces/github-api';
+
+const pluginFetcher = (owner: string, repo: string) => {
+  return async() => {
+    const reqUrl = `/api/fetch_repository?owner=${owner}&repo=${repo}`;
+    const data = await fetch(reqUrl).then(res => res.json());
+    return data.searchResultItem;
+  };
+};
+
+export const useInstalledPlugin = (owner: string, repo: string): SWRResponse<SearchResultItem | null, Error> => {
+  return useSWR(`${owner}/{repo}`, pluginFetcher(owner, repo));
+};
+
+const pluginsFetcher = () => {
+  return async() => {
+    const reqUrl = '/api/fetch_repositories';
+    const data = await fetch(reqUrl).then(res => res.json());
+    return data.searchResult;
+  };
+};
+
+export const useInstalledPlugins = (): SWRResponse<SearchResult | null, Error> => {
+  return useSWR('/api/fetch_repositories', pluginsFetcher());
+};

+ 28 - 9
packages/app/src/stores/websocket.tsx

@@ -1,9 +1,12 @@
-import { SWRResponse } from 'swr';
+import { useEffect } from 'react';
+
 import io, { Socket } from 'socket.io-client';
+import { SWRResponse } from 'swr';
 
-import { useStaticSWR } from './use-static-swr';
 import loggerFactory from '~/utils/logger';
 
+import { useStaticSWR } from './use-static-swr';
+
 const logger = loggerFactory('growi:stores:ui');
 
 export const GLOBAL_SOCKET_NS = '/';
@@ -15,15 +18,21 @@ export const GLOBAL_ADMIN_SOCKET_KEY = 'globalAdminSocket';
 /*
  * Global Socket
  */
-export const useSetupGlobalSocket = (): SWRResponse<Socket, Error> => {
-  const socket = io(GLOBAL_SOCKET_NS, {
-    transports: ['websocket'],
-  });
+export const useSetupGlobalSocket = (): void => {
+
+  const { mutate } = useStaticSWR(GLOBAL_SOCKET_KEY);
 
-  socket.on('error', (err) => { logger.error(err) });
-  socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+  useEffect(() => {
+    const socket = io(GLOBAL_SOCKET_NS, {
+      transports: ['websocket'],
+    });
 
-  return useStaticSWR(GLOBAL_SOCKET_KEY, socket);
+    socket.on('error', (err) => { logger.error(err) });
+    socket.on('connect_error', (err) => { logger.error('Failed to connect with websocket.', err) });
+
+    mutate(socket);
+
+  }, [mutate]);
 };
 
 export const useGlobalSocket = (): SWRResponse<Socket, Error> => {
@@ -51,3 +60,13 @@ export const useSetupGlobalAdminSocket = (shouldInit: boolean): SWRResponse<Sock
 export const useGlobalAdminSocket = (): SWRResponse<Socket, Error> => {
   return useStaticSWR(GLOBAL_ADMIN_SOCKET_KEY);
 };
+
+export const useSetupGlobalSocketForPage = (pageId: string | undefined): void => {
+  const { data: socket } = useGlobalSocket();
+
+  useEffect(() => {
+    if (socket == null || pageId == null) { return }
+
+    socket.emit('join:page', { socketId: socket.id, pageId });
+  }, [pageId, socket]);
+};

+ 34 - 31
packages/app/src/styles/_page.scss

@@ -1,40 +1,43 @@
 // // import diff2html styles
 // @import '~/diff2html/bundles/css/diff2html.min.css';
 
-.card.grw-page-status-alert {
-  $margin-bottom: $grw-navbar-bottom-height + 10px;
-
-  box-shadow: 0px 2px 4px #0000004d;
-  opacity: 0.9;
-
-  @include 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 button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-line-height-lg, $btn-border-radius-lg);
-      }
-    }
+/**
+ * for table with handsontable modal button
+ */
+.editable-with-handsontable {
+  position: relative;
+
+  .handsontable-modal-trigger {
+    position: absolute;
+    top: 11px;
+    right: 10px;
+    padding: 0;
+    font-size: 16px;
+    line-height: 1;
+    vertical-align: bottom;
+    background-color: transparent;
+    border: none;
+    opacity: 0;
   }
 
-  @include media-breakpoint-up(md) {
-    width: 700px;
-    margin: 0 auto $margin-bottom;
+  .page-mobile & .handsontable-modal-trigger {
+    opacity: 0.3;
+  }
 
-    .card-body {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-    }
+  &:hover .handsontable-modal-trigger {
+    opacity: 1;
+  }
+}
 
-    .grw-card-label-container,
-    .grw-card-btn-container {
-      margin: 0;
-    }
+/**
+ * for drawio with drawio iframe button
+ */
+.editable-with-drawio {
+  .drawio-iframe-trigger {
+    top: 11px;
+    right: 10px;
+    z-index: 14;
+    font-size: 12px;
+    line-height: 1;
   }
 }

+ 39 - 0
packages/app/src/utils/growi-facade.ts

@@ -0,0 +1,39 @@
+import { GrowiFacade, isServer } from '@growi/core';
+import deepmerge from 'ts-deepmerge';
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var growiFacade: GrowiFacade;
+}
+
+
+export const initializeGrowiFacade = (): void => {
+  if (isServer()) {
+    return;
+  }
+
+  if (window.growiFacade == null) {
+    window.growiFacade = {};
+  }
+};
+
+export const getGrowiFacade = (): GrowiFacade => {
+  if (isServer()) {
+    return {};
+  }
+
+  initializeGrowiFacade();
+
+  return window.growiFacade;
+};
+
+export const registerGrowiFacade = (addedFacade: GrowiFacade): void => {
+  if (isServer()) {
+    throw new Error('This method is available only in client.');
+  }
+
+  window.growiFacade = deepmerge(
+    getGrowiFacade(),
+    addedFacade,
+  );
+};

+ 0 - 1
packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts

@@ -26,7 +26,6 @@ context('Access to Admin page', () => {
     cy.visit('/admin');
     cy.getByTestid('admin-home').should('be.visible');
     cy.getByTestid('admin-system-information-table').should('be.visible');
-    cy.getByTestid('admin-installed-plugin-table').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin`);
   });
 

+ 2 - 0
packages/core/src/index.ts

@@ -15,12 +15,14 @@ export * as pageUtils from './utils/page-utils';
 export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
+export * from './interfaces/growi-facade';
 export * from './interfaces/has-object-id';
 export * from './interfaces/lang';
 export * from './interfaces/page';
 export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
+export * from './interfaces/template';
 export * from './interfaces/user';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';

+ 16 - 0
packages/core/src/interfaces/growi-facade.ts

@@ -0,0 +1,16 @@
+import { ITemplate } from './template';
+
+export type GrowiFacade = {
+  markdownRenderer?: {
+    optionsGenerators?: {
+      generateViewOptions?: any;
+      customGenerateViewOptions?: any;
+      generatePreviewOptions?: any;
+      customGeneratePreviewOptions?: any;
+    },
+    optionsMutators?: any,
+  },
+  customTemplates?: {
+    [pluginName: string]: ITemplate,
+  }
+};

+ 5 - 0
packages/core/src/interfaces/template.ts

@@ -0,0 +1,5 @@
+export type ITemplate = {
+  id: string,
+  name: string,
+  markdown: string,
+}