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

Merge branch 'support/apply-nextjs-2' into imprv/101293-empty-NotFoundPage

yuken 3 лет назад
Родитель
Сommit
64ee7deb7f
80 измененных файлов с 1325 добавлено и 984 удалено
  1. 2 2
      .github/workflows/reusable-app-prod.yml
  2. 1 3
      packages/app/_obsolete/src/client/app.jsx
  3. 0 1
      packages/app/package.json
  4. 0 7
      packages/app/resource/cdn-manifests.js
  5. 5 0
      packages/app/src/client/services/AdminAppContainer.js
  6. 5 0
      packages/app/src/client/services/AdminBasicSecurityContainer.js
  7. 5 51
      packages/app/src/client/services/AdminCustomizeContainer.js
  8. 5 0
      packages/app/src/client/services/AdminExternalAccountsContainer.js
  9. 5 0
      packages/app/src/client/services/AdminGeneralSecurityContainer.js
  10. 5 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  11. 5 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  12. 5 0
      packages/app/src/client/services/AdminHomeContainer.js
  13. 5 0
      packages/app/src/client/services/AdminImportContainer.js
  14. 5 0
      packages/app/src/client/services/AdminLdapSecurityContainer.js
  15. 5 0
      packages/app/src/client/services/AdminLocalSecurityContainer.js
  16. 5 0
      packages/app/src/client/services/AdminMarkDownContainer.js
  17. 5 0
      packages/app/src/client/services/AdminNotificationContainer.js
  18. 5 1
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  19. 5 1
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  20. 5 0
      packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js
  21. 5 1
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  22. 8 3
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  23. 5 0
      packages/app/src/client/services/AdminUsersContainer.js
  24. 7 4
      packages/app/src/client/services/ContextExtractor.tsx
  25. 8 10
      packages/app/src/components/Admin/Customize/Customize.jsx
  26. 3 1
      packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx
  27. 2 2
      packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx
  28. 8 6
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  29. 21 10
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  30. 2 2
      packages/app/src/components/Admin/Security/SecurityManagementContents.jsx
  31. 2 3
      packages/app/src/components/Admin/UserManagement.jsx
  32. 6 6
      packages/app/src/components/Admin/Users/UserInviteModal.jsx
  33. 1 7
      packages/app/src/components/Layout/AdminLayout.tsx
  34. 7 2
      packages/app/src/components/Layout/BasicLayout.tsx
  35. 10 3
      packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  36. 23 5
      packages/app/src/components/Navbar/GrowiSubNavigation.module.scss
  37. 2 3
      packages/app/src/components/Navbar/GrowiSubNavigation.tsx
  38. 7 17
      packages/app/src/components/Navbar/SubNavButtons.tsx
  39. 2 2
      packages/app/src/components/Page/DisplaySwitcher.tsx
  40. 26 23
      packages/app/src/components/PageComment.tsx
  41. 0 259
      packages/app/src/components/PageComment/Comment.jsx
  42. 178 0
      packages/app/src/components/PageComment/Comment.tsx
  43. 16 29
      packages/app/src/components/PageComment/CommentEditor.tsx
  44. 2 7
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  45. 13 17
      packages/app/src/components/PageComment/ReplayComments.jsx
  46. 104 102
      packages/app/src/components/PageEditor.tsx
  47. 26 22
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  48. 93 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss
  49. 3 9
      packages/app/src/components/PageEditor/DrawioModal.jsx
  50. 1 8
      packages/app/src/components/PageEditor/EditorNavbarBottom.tsx
  51. 6 79
      packages/app/src/components/PageEditor/Preview.tsx
  52. 5 7
      packages/app/src/components/PageEditorByHackmd.jsx
  53. 35 32
      packages/app/src/components/SavePageControls.jsx
  54. 2 2
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  55. 6 2
      packages/app/src/components/Skelton.tsx
  56. 0 59
      packages/app/src/components/TagPage.tsx
  57. 1 1
      packages/app/src/components/UncontrolledCodeMirror.tsx
  58. 1 0
      packages/app/src/components/UnstatedUtils.tsx
  59. 21 0
      packages/app/src/interfaces/customize.ts
  60. 35 21
      packages/app/src/pages/[[...path]].page.tsx
  61. 98 7
      packages/app/src/pages/admin/[[...path]].page.tsx
  62. 20 9
      packages/app/src/pages/installer.page.tsx
  63. 137 0
      packages/app/src/pages/tags.page.tsx
  64. 2 0
      packages/app/src/pages/utils/commons.ts
  65. 133 4
      packages/app/src/server/models/page-redirect.ts
  66. 2 1
      packages/app/src/server/routes/index.js
  67. 1 1
      packages/app/src/server/routes/page.js
  68. 1 11
      packages/app/src/server/views/admin/customize.html
  69. 9 0
      packages/app/src/services/renderer/renderer.tsx
  70. 7 0
      packages/app/src/stores/context.tsx
  71. 3 11
      packages/app/src/stores/ui.tsx
  72. 5 5
      packages/app/src/styles/_layout.scss
  73. 6 33
      packages/app/src/styles/_on-edit.scss
  74. 0 53
      packages/app/src/styles/_override-codemirror.scss
  75. 1 1
      packages/app/src/styles/style-next.scss
  76. 1 1
      packages/app/src/styles/theme/_apply-colors-dark.scss
  77. 1 1
      packages/app/src/styles/theme/_apply-colors-light.scss
  78. 0 4
      packages/app/src/styles/theme/_apply-colors.scss
  79. 111 0
      packages/app/test/integration/models/page-redirect.test.js
  80. 1 8
      yarn.lock

+ 2 - 2
.github/workflows/reusable-app-prod.yml

@@ -60,7 +60,7 @@ jobs:
     - name: Archive production files
     - name: Archive production files
       id: archive-prod-files
       id: archive-prod-files
       run: |
       run: |
-        tar -cf production.tar \
+        tar -zcf production.tar.gz \
           package.json \
           package.json \
           packages/app/.next \
           packages/app/.next \
           packages/app/config \
           packages/app/config \
@@ -70,7 +70,7 @@ jobs:
           packages/app/.env.production* \
           packages/app/.env.production* \
           packages/*/package.json \
           packages/*/package.json \
           packages/*/dist
           packages/*/dist
-        echo ::set-output name=file::production.tar
+        echo ::set-output name=file::production.tar.gz
 
 
     - name: Upload production files as artifact
     - name: Upload production files as artifact
       uses: actions/upload-artifact@v3
       uses: actions/upload-artifact@v3

+ 1 - 3
packages/app/_obsolete/src/client/app.jsx

@@ -30,7 +30,7 @@ import { Page } from '../components/Page';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import DisplaySwitcher from '../components/Page/DisplaySwitcher';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import RedirectedAlert from '../components/Page/RedirectedAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
 import ShareLinkAlert from '../components/Page/ShareLinkAlert';
-import PageComment from '../components/PageComment';
+import { PageComment } from '../components/PageComment';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import CommentEditorLazyRenderer from '../components/PageComment/CommentEditorLazyRenderer';
 import PageContentFooter from '../components/PageContentFooter';
 import PageContentFooter from '../components/PageContentFooter';
 import BookmarkList from '../components/PageList/BookmarkList';
 import BookmarkList from '../components/PageList/BookmarkList';
@@ -39,7 +39,6 @@ import { PageTimeline } from '../components/PageTimeline';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import RecentCreated from '../components/RecentCreated/RecentCreated';
 import { SearchPage } from '../components/SearchPage';
 import { SearchPage } from '../components/SearchPage';
 import Sidebar from '../components/Sidebar';
 import Sidebar from '../components/Sidebar';
-import TagPage from '../components/TagPage';
 import TrashPageList from '../components/TrashPageList';
 import TrashPageList from '../components/TrashPageList';
 
 
 import { appContainer, componentMappings } from './base';
 import { appContainer, componentMappings } from './base';
@@ -75,7 +74,6 @@ Object.assign(componentMappings, {
   'identical-path-page': <IdenticalPathPage />,
   'identical-path-page': <IdenticalPathPage />,
 
 
   // 'revision-history': <PageHistory pageId={pageId} />,
   // 'revision-history': <PageHistory pageId={pageId} />,
-  'tags-page': <TagPage />,
 
 
   'grw-page-status-alert-container': <PageStatusAlert />,
   'grw-page-status-alert-container': <PageStatusAlert />,
 
 

+ 0 - 1
packages/app/package.json

@@ -212,7 +212,6 @@
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.7.7",
     "i18next-hmr": "^1.7.7",
     "jquery-slimscroll": "^1.3.8",
     "jquery-slimscroll": "^1.3.8",
-    "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
     "jquery.cookie": "~1.4.1",
     "jshint": "^2.13.0",
     "jshint": "^2.13.0",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",

+ 0 - 7
packages/app/resource/cdn-manifests.js

@@ -147,13 +147,6 @@ module.exports = {
         integrity: '',
         integrity: '',
       },
       },
     },
     },
-    {
-      name: 'jquery-ui',
-      url: 'https://cdn.jsdelivr.net/jquery.ui/1.11.4/jquery-ui.min.css',
-      args: {
-        integrity: '',
-      },
-    },
     {
     {
       name: 'highlight-theme-github',
       name: 'highlight-theme-github',
       url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.13.0/styles/github.css',
       url: 'https://cdn.jsdelivr.net/npm/highlight.js@9.13.0/styles/github.css',

+ 5 - 0
packages/app/src/client/services/AdminAppContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
 import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
@@ -11,6 +12,10 @@ export default class AdminAppContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
     this.state = {
       retrieveError: null,
       retrieveError: null,
       title: '',
       title: '',

+ 5 - 0
packages/app/src/client/services/AdminBasicSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -16,6 +17,10 @@ export default class AdminBasicSecurityContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
     this.state = {
       isSameUsernameTreatedAsIdenticalUser: false,
       isSameUsernameTreatedAsIdenticalUser: false,
     };
     };

+ 5 - 51
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -17,10 +18,12 @@ export default class AdminCustomizeContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
     this.state = {
       retrieveError: null,
       retrieveError: null,
-      // set dummy value tile for using suspense
-      currentTheme: 'default',
       isEnabledTimeline: false,
       isEnabledTimeline: false,
       isSavedStatesOfTabChanges: false,
       isSavedStatesOfTabChanges: false,
       isEnabledAttachTitleHeader: false,
       isEnabledAttachTitleHeader: false,
@@ -77,7 +80,6 @@ export default class AdminCustomizeContainer extends Container {
       const { customizeParams } = response.data;
       const { customizeParams } = response.data;
 
 
       this.setState({
       this.setState({
-        currentTheme: customizeParams.themeType,
         isEnabledTimeline: customizeParams.isEnabledTimeline,
         isEnabledTimeline: customizeParams.isEnabledTimeline,
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isSavedStatesOfTabChanges: customizeParams.isSavedStatesOfTabChanges,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
         isEnabledAttachTitleHeader: customizeParams.isEnabledAttachTitleHeader,
@@ -106,17 +108,6 @@ export default class AdminCustomizeContainer extends Container {
     }
     }
   }
   }
 
 
-  /**
-   * Switch themeType
-   */
-  switchThemeType(themeName) {
-    this.setState({ currentTheme: themeName });
-
-    // preview if production
-    if (process.env.NODE_ENV !== 'development') {
-      this.previewTheme(themeName);
-    }
-  }
 
 
   /**
   /**
    * Switch enabledTimeLine
    * Switch enabledTimeLine
@@ -236,24 +227,6 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ currentCustomizeScript: inpuValue });
     this.setState({ currentCustomizeScript: inpuValue });
   }
   }
 
 
-  /**
-   * Preview theme
-   * @param {string} themeName
-   */
-  async previewTheme(themeName) {
-    try {
-      // get theme asset path
-      const response = await apiv3Get('/customize-setting/theme/asset-path', { themeName });
-      const { assetPath } = response.data;
-
-      const themeLink = document.getElementById('grw-theme-link');
-      themeLink.setAttribute('href', assetPath);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
   /**
   /**
    * Preview hljs style
    * Preview hljs style
    * @param {string} styleId
    * @param {string} styleId
@@ -265,25 +238,6 @@ export default class AdminCustomizeContainer extends Container {
     styleLInk.href = styleLInk.href.replace(/[^/]+\.css$/, `${styleId}.css`);
     styleLInk.href = styleLInk.href.replace(/[^/]+\.css$/, `${styleId}.css`);
   }
   }
 
 
-  /**
-   * Update theme
-   * @memberOf AdminCustomizeContainer
-   */
-  async updateCustomizeTheme() {
-    try {
-      const response = await apiv3Put('/customize-setting/theme', {
-        themeType: this.state.currentTheme,
-      });
-      const { customizedParams } = response.data;
-      this.setState({
-        themeType: customizedParams.themeType,
-      });
-    }
-    catch (err) {
-      logger.error(err);
-      throw new Error('Failed to update data');
-    }
-  }
 
 
   /**
   /**
    * Update function
    * Update function

+ 5 - 0
packages/app/src/client/services/AdminExternalAccountsContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminExternalAccountsContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
     this.state = {
       externalAccounts: [],
       externalAccounts: [],
       totalAccounts: 0,
       totalAccounts: 0,

+ 5 - 0
packages/app/src/client/services/AdminGeneralSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import {
 import {
@@ -18,6 +19,10 @@ export default class AdminGeneralSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.state = {
     this.state = {
       retrieveError: null,
       retrieveError: null,
       sessionMaxAge: null,
       sessionMaxAge: null,

+ 5 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -18,6 +18,10 @@ export default class AdminGitHubSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.dummyGithubClientId = 0;
     this.dummyGithubClientId = 0;
     this.dummyGithubClientIdForError = 1;
     this.dummyGithubClientIdForError = 1;
 
 

+ 5 - 1
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -18,6 +18,10 @@ export default class AdminGoogleSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.dummyGoogleClientId = 0;
     this.dummyGoogleClientId = 0;
     this.dummyGoogleClientIdForError = 1;
     this.dummyGoogleClientIdForError = 1;
 
 

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

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminHomeContainer extends Container {
   constructor() {
   constructor() {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.copyStateValues = {
     this.copyStateValues = {
       DEFAULT: 'default',
       DEFAULT: 'default',
       DONE: 'done',
       DONE: 'done',

+ 5 - 0
packages/app/src/client/services/AdminImportContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -17,6 +18,10 @@ export default class AdminImportContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminLdapSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -16,6 +17,10 @@ export default class AdminLdapSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminLocalSecurityContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -15,6 +16,10 @@ export default class AdminLocalSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
     this.dummyRegistrationMode = 0;
     this.dummyRegistrationMode = 0;
     this.dummyRegistrationModeForError = 1;
     this.dummyRegistrationModeForError = 1;

+ 5 - 0
packages/app/src/client/services/AdminMarkDownContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
@@ -11,6 +12,10 @@ export default class AdminMarkDownContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminNotificationContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import {
 import {
@@ -13,6 +14,10 @@ export default class AdminNotificationContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 1
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -18,6 +18,10 @@ export default class AdminOidcSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 1
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -18,6 +18,10 @@ export default class AdminSamlSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 0
packages/app/src/client/services/AdminSlackIntegrationLegacyContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
 import { apiv3Get, apiv3Put } from '../util/apiv3-client';
@@ -11,6 +12,10 @@ export default class AdminSlackIntegrationLegacyContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 5 - 1
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -1,4 +1,4 @@
-import { pathUtils } from '@growi/core';
+import { isServer, pathUtils } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
@@ -18,6 +18,10 @@ export default class AdminTwitterSecurityContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

+ 8 - 3
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -2,15 +2,16 @@
  * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
  * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
  */
  */
 
 
+import { isServer } from '@growi/core';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
+import {
+  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
+} from '~/client/util/apiv3-client';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { toastError } from '../util/apiNotification';
 import { toastError } from '../util/apiNotification';
 
 
-import {
-  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
-} from '~/client/util/apiv3-client';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
@@ -24,6 +25,10 @@ export default class AdminUserGroupDetailContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     const rootElem = document.getElementById('admin-user-group-detail');
     const rootElem = document.getElementById('admin-user-group-detail');

+ 5 - 0
packages/app/src/client/services/AdminUsersContainer.js

@@ -1,3 +1,4 @@
+import { isServer } from '@growi/core';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
@@ -19,6 +20,10 @@ export default class AdminUsersContainer extends Container {
   constructor(appContainer) {
   constructor(appContainer) {
     super();
     super();
 
 
+    if (isServer()) {
+      return;
+    }
+
     this.appContainer = appContainer;
     this.appContainer = appContainer;
 
 
     this.state = {
     this.state = {

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

@@ -9,7 +9,7 @@ import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import {
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
-  useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
 import { useSetupGlobalSocket, useSetupGlobalAdminSocket } from '~/stores/websocket';
 
 
@@ -133,6 +133,9 @@ const ContextExtractorOnce: FC = () => {
     plantumlUri: configByContextHydrate.env.PLANTUML_URI,
     plantumlUri: configByContextHydrate.env.PLANTUML_URI,
     blockdiagUri: configByContextHydrate.env.BLOCKDIAG_URI,
     blockdiagUri: configByContextHydrate.env.BLOCKDIAG_URI,
   });
   });
+  // useNoCdn(configByContextHydrate.env.NO_CDN);
+  // useUploadableImage(configByContextHydrate.upload.image);
+  // useUploadableFile(configByContextHydrate.upload.file);
 
 
   // Page
   // Page
   useDeleteUsername(deleteUsername);
   useDeleteUsername(deleteUsername);
@@ -170,9 +173,9 @@ const ContextExtractorOnce: FC = () => {
   useIsDeviceSmallerThanMd();
   useIsDeviceSmallerThanMd();
 
 
   // Editor
   // Editor
-  useSelectedGrant(grant);
-  useSelectedGrantGroupId(grantGroupId);
-  useSelectedGrantGroupName(grantGroupName);
+  // useSelectedGrant(grant);
+  // useSelectedGrantGroupId(grantGroupId);
+  // useSelectedGrantGroupName(grantGroupName);
 
 
   // SearchResult
   // SearchResult
   useIsDeviceSmallerThanLg();
   useIsDeviceSmallerThanLg();

+ 8 - 10
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -1,5 +1,5 @@
 
 
-import React, { useEffect } from 'react';
+import React, { useEffect, useCallback } from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
@@ -26,13 +26,9 @@ const logger = loggerFactory('growi:services:AdminCustomizePage');
 function Customize(props) {
 function Customize(props) {
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
 
 
-  useEffect(() => {
-    async function fetchCustomizeSettingsData() {
-      await adminCustomizeContainer.retrieveCustomizeData();
-    }
-
+  const fetchCustomizeSettingsData = useCallback(async() => {
     try {
     try {
-      fetchCustomizeSettingsData();
+      await adminCustomizeContainer.retrieveCustomizeData();
     }
     }
     catch (err) {
     catch (err) {
       const errs = toArrayIfNot(err);
       const errs = toArrayIfNot(err);
@@ -41,6 +37,10 @@ function Customize(props) {
     }
     }
   }, [adminCustomizeContainer]);
   }, [adminCustomizeContainer]);
 
 
+  useEffect(() => {
+    fetchCustomizeSettingsData();
+  }, [fetchCustomizeSettingsData]);
+
 
 
   return (
   return (
     <div data-testid="admin-customize">
     <div data-testid="admin-customize">
@@ -51,9 +51,7 @@ function Customize(props) {
         <CustomizeThemeSetting />
         <CustomizeThemeSetting />
       </div>
       </div>
       <div className="mb-5">
       <div className="mb-5">
-        {/* TODO: [resolve browser err] A component is changing an uncontrolled input to be controlled. by https://redmine.weseek.co.jp/issues/101155
-          <CustomizeSidebarSetting />
-        */}
+        <CustomizeSidebarSetting />
       </div>
       </div>
       <div className="mb-5">
       <div className="mb-5">
         <CustomizeFunctionSetting />
         <CustomizeFunctionSetting />

+ 3 - 1
packages/app/src/components/Admin/Customize/CustomizeHighlightSetting.tsx

@@ -1,6 +1,7 @@
 /* eslint-disable no-useless-escape */
 /* eslint-disable no-useless-escape */
 import React, { useCallback, useState } from 'react';
 import React, { useCallback, useState } from 'react';
 
 
+
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
@@ -8,6 +9,7 @@ import {
 
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { IHighlightJsCssSelectorOptions } from '~/interfaces/customize';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -48,7 +50,7 @@ const CustomizeHighlightSetting = (props: Props): JSX.Element => {
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [isDropdownOpen, setIsDropdownOpen] = useState(false);
   const [isDropdownOpen, setIsDropdownOpen] = useState(false);
-  const options = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
+  const options: IHighlightJsCssSelectorOptions = adminCustomizeContainer.state.highlightJsCssSelectorOptions;
 
 
   const onToggleDropdown = useCallback(() => {
   const onToggleDropdown = useCallback(() => {
     setIsDropdownOpen(!isDropdownOpen);
     setIsDropdownOpen(!isDropdownOpen);

+ 2 - 2
packages/app/src/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -78,7 +78,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 id="is-open"
                 id="is-open"
                 className="custom-control-input"
                 className="custom-control-input"
                 name="mailVisibility"
                 name="mailVisibility"
-                checked={!isSidebarDrawerMode && !isSidebarClosedAtDockMode}
+                checked={isSidebarDrawerMode === false && isSidebarClosedAtDockMode === false}
                 disabled={isSidebarDrawerMode}
                 disabled={isSidebarDrawerMode}
                 onChange={() => setIsSidebarClosedAtDockMode(false)}
                 onChange={() => setIsSidebarClosedAtDockMode(false)}
               />
               />
@@ -92,7 +92,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                 id="is-closed"
                 id="is-closed"
                 className="custom-control-input"
                 className="custom-control-input"
                 name="mailVisibility"
                 name="mailVisibility"
-                checked={!isSidebarDrawerMode && isSidebarClosedAtDockMode}
+                checked={isSidebarDrawerMode === false && isSidebarClosedAtDockMode === true}
                 disabled={isSidebarDrawerMode}
                 disabled={isSidebarDrawerMode}
                 onChange={() => setIsSidebarClosedAtDockMode(true)}
                 onChange={() => setIsSidebarClosedAtDockMode(true)}
               />
               />

+ 8 - 6
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -6,7 +6,6 @@ import PropTypes from 'prop-types';
 
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { GrowiThemes } from '~/interfaces/theme';
 import { GrowiThemes } from '~/interfaces/theme';
-import { useGrowiTheme } from '~/stores/context';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -50,10 +49,11 @@ const uniqueTheme = [{
 
 
 const CustomizeThemeOptions = (props) => {
 const CustomizeThemeOptions = (props) => {
 
 
-  const { adminCustomizeContainer } = props;
+  const { adminCustomizeContainer, currentTheme } = props;
+  const { currentLayout } = adminCustomizeContainer.state;
+
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { mutate: mutateGrowiTheme } = useGrowiTheme();
-  const { currentLayout, currentTheme } = adminCustomizeContainer.state;
+
 
 
   return (
   return (
     <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
     <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
@@ -66,7 +66,7 @@ const CustomizeThemeOptions = (props) => {
               <ThemeColorBox
               <ThemeColorBox
                 key={theme.name}
                 key={theme.name}
                 isSelected={currentTheme === theme.name}
                 isSelected={currentTheme === theme.name}
-                onSelected={() => mutateGrowiTheme(theme.name)}
+                onSelected={() => props.onSelected(theme.name)}
                 {...theme}
                 {...theme}
               />
               />
             );
             );
@@ -82,7 +82,7 @@ const CustomizeThemeOptions = (props) => {
               <ThemeColorBox
               <ThemeColorBox
                 key={theme.name}
                 key={theme.name}
                 isSelected={currentTheme === theme.name}
                 isSelected={currentTheme === theme.name}
-                onSelected={() => mutateGrowiTheme(theme.name)}
+                onSelected={() => props.onSelected(theme.name)}
                 {...theme}
                 {...theme}
               />
               />
             );
             );
@@ -98,6 +98,8 @@ const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOption
 
 
 CustomizeThemeOptions.propTypes = {
 CustomizeThemeOptions.propTypes = {
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
   adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+  onSelected: PropTypes.func,
+  currentTheme: PropTypes.string,
 };
 };
 
 
 export default CustomizeThemeOptionsWrapper;
 export default CustomizeThemeOptionsWrapper;

+ 21 - 10
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -4,6 +4,9 @@ import { useTranslation } from 'next-i18next';
 
 
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { useGrowiTheme } from '~/stores/context';
+
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -17,28 +20,36 @@ type Props = {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
 
 
   const { adminCustomizeContainer } = props;
   const { adminCustomizeContainer } = props;
+  const { data: currentTheme, mutate: mutateGrowiTheme } = useGrowiTheme();
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const selectedHandler = useCallback((themeName) => {
+    mutateGrowiTheme(themeName);
+  }, [mutateGrowiTheme]);
+
   const submitHandler = useCallback(async() => {
   const submitHandler = useCallback(async() => {
     try {
     try {
-      await adminCustomizeContainer.updateCustomizeTheme();
+      if (currentTheme != null) {
+        await apiv3Put('/customize-setting/theme', {
+          themeType: currentTheme,
+        });
+      }
+
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.theme') }));
     }
     }
     catch (err) {
     catch (err) {
       toastError(err);
       toastError(err);
     }
     }
-  }, [t, adminCustomizeContainer]);
+  }, [currentTheme, t]);
 
 
   return (
   return (
-    <React.Fragment>
-      <div className="row">
-        <div className="col-12">
-          <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
-          <CustomizeThemeOptions />
-          <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
-        </div>
+    <div className="row">
+      <div className="col-12">
+        <h2 className="admin-setting-header">{t('admin:customize_setting.theme')}</h2>
+        <CustomizeThemeOptions onSelected={selectedHandler} currentTheme={currentTheme} />
+        <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
       </div>
       </div>
-    </React.Fragment>
+    </div>
   );
   );
 };
 };
 
 

+ 2 - 2
packages/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -82,12 +82,12 @@ const SecurityManagementContents = () => {
   return (
   return (
     <div data-testid="admin-security">
     <div data-testid="admin-security">
       <div className="mb-5">
       <div className="mb-5">
-        {/* <SecuritySetting /> */}
+        <SecuritySetting />
       </div>
       </div>
 
 
       {/* Shared Link List */}
       {/* Shared Link List */}
       <div className="mb-5">
       <div className="mb-5">
-        {/* <ShareLinkSetting /> */}
+        <ShareLinkSetting />
       </div>
       </div>
 
 
 
 

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

@@ -10,7 +10,7 @@ import PaginationWrapper from '../PaginationWrapper';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
-// import InviteUserControl from './Users/InviteUserControl';
+import InviteUserControl from './Users/InviteUserControl';
 import PasswordResetModal from './Users/PasswordResetModal';
 import PasswordResetModal from './Users/PasswordResetModal';
 import UserTable from './Users/UserTable';
 import UserTable from './Users/UserTable';
 
 
@@ -150,8 +150,7 @@ class UserManagement extends React.Component {
           />
           />
         )}
         )}
         <p>
         <p>
-          {/* show  */}
-          {/* <InviteUserControl /> */}
+          <InviteUserControl />
           <a className="btn btn-outline-secondary ml-2" href="/admin/users/external-accounts" role="button">
           <a className="btn btn-outline-secondary ml-2" href="/admin/users/external-accounts" role="button">
             <i className="icon-user-follow" aria-hidden="true"></i>
             <i className="icon-user-follow" aria-hidden="true"></i>
             {t('admin:user_management.external_account')}
             {t('admin:user_management.external_account')}

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

@@ -8,9 +8,10 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
+
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
-import AppContainer from '~/client/services/AppContainer';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/apiNotification';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/apiNotification';
+import { useIsMailerSetup } from '~/stores/context';
 
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
@@ -99,9 +100,8 @@ class UserInviteModal extends React.Component {
   }
   }
 
 
   renderModalFooter() {
   renderModalFooter() {
-    const { t, appContainer } = this.props;
+    const { t, isMailerSetup } = this.props;
     const { isCreateUserButtonPushed } = this.state;
     const { isCreateUserButtonPushed } = this.state;
-    const { isMailerSetup } = appContainer.config;
 
 
     return (
     return (
       <>
       <>
@@ -282,18 +282,18 @@ class UserInviteModal extends React.Component {
 
 
 const UserInviteModalWrapperFC = (props) => {
 const UserInviteModalWrapperFC = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  return <UserInviteModal t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <UserInviteModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 };
 
 
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const UserInviteModalWrapper = withUnstatedContainers(UserInviteModalWrapperFC, [AppContainer, AdminUsersContainer]);
+const UserInviteModalWrapper = withUnstatedContainers(UserInviteModalWrapperFC, [AdminUsersContainer]);
 
 
 
 
 UserInviteModal.propTypes = {
 UserInviteModal.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
   adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 };
 };
 
 

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

@@ -1,7 +1,6 @@
 import React, { ReactNode } from 'react';
 import React, { ReactNode } from 'react';
 
 
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
-import { Provider } from 'unstated';
 
 
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 import { GrowiNavbar } from '../Navbar/GrowiNavbar';
 
 
@@ -10,8 +9,6 @@ import { RawLayout } from './RawLayout';
 import styles from './Admin.module.scss';
 import styles from './Admin.module.scss';
 
 
 
 
-// import { injectableContainers } from '~/client/admin';
-
 type Props = {
 type Props = {
   title: string
   title: string
   /**
   /**
@@ -45,10 +42,7 @@ const AdminLayout = ({
               <AdminNavigation selected={selectedNavOpt} />
               <AdminNavigation selected={selectedNavOpt} />
             </div>
             </div>
             <div className="col-lg-9">
             <div className="col-lg-9">
-              {/* TODO: inject Admincontainer (injectableContainers & adminSecurityContainers) by https://redmine.weseek.co.jp/issues/100072 */}
-              <Provider>
-                {children}
-              </Provider>
+              {children}
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 7 - 2
packages/app/src/components/Layout/BasicLayout.tsx

@@ -11,10 +11,13 @@ import { RawLayout } from './RawLayout';
 type Props = {
 type Props = {
   title: string
   title: string
   className?: string,
   className?: string,
+  expandContainer?: boolean,
   children?: ReactNode
   children?: ReactNode
 }
 }
 
 
-export const BasicLayout = ({ children, title, className }: Props): JSX.Element => {
+export const BasicLayout = ({
+  children, title, className, expandContainer,
+}: Props): JSX.Element => {
 
 
   // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
   // const HotkeysManager = dynamic(() => import('../client/js/components/Hotkeys/HotkeysManager'), { ssr: false });
   // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
   // const PageCreateModal = dynamic(() => import('../client/js/components/PageCreateModal'), { ssr: false });
@@ -28,8 +31,10 @@ export const BasicLayout = ({ children, title, className }: Props): JSX.Element
   const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
   const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false });
   const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
   const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 
 
+  const myClassName = `${className ?? ''} ${expandContainer ? 'growi-layout-fluid' : ''}`;
+
   return (
   return (
-    <RawLayout title={title} className={className}>
+    <RawLayout title={title} className={myClassName}>
       <GrowiNavbar />
       <GrowiNavbar />
 
 
       <div className="page-wrapper d-flex d-print-block">
       <div className="page-wrapper d-flex d-print-block">

+ 10 - 3
packages/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -37,7 +37,7 @@ import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { Skelton } from '../Skelton';
 import { Skelton } from '../Skelton';
 
 
 import { GrowiSubNavigation } from './GrowiSubNavigation';
 import { GrowiSubNavigation } from './GrowiSubNavigation';
-import { SubNavButtons } from './SubNavButtons';
+import { SubNavButtonsProps } from './SubNavButtons';
 
 
 
 
 type AdditionalMenuItemsProps = {
 type AdditionalMenuItemsProps = {
@@ -153,7 +153,14 @@ type GrowiContextualSubNavigationProps = {
 
 
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
 
 
-  const PageEditorModeManager = dynamic(() => import('./PageEditorModeManager'), { ssr: false, loading: () => <Skelton width={208} height={32.49} /> });
+  const PageEditorModeManager = dynamic(
+    () => import('./PageEditorModeManager'),
+    { ssr: false, loading: () => <Skelton width={213} height={33.99} /> },
+  );
+  const SubNavButtons = dynamic<SubNavButtonsProps>(
+    () => import('./SubNavButtons').then(mod => mod.SubNavButtons),
+    { ssr: false, loading: () => <Skelton additionalClass='btn-skelton py-2' /> },
+  );
 
 
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const path = currentPage?.path;
   const path = currentPage?.path;
@@ -306,7 +313,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
         <div className="d-flex flex-column align-items-end justify-content-center py-md-2" style={{ gap: `${isCompactMode ? '5px' : '7px'}` }}>
 
 
           { isViewMode && (
           { isViewMode && (
-            <div className="h-50">
+            <div className="h-50 w-100">
               <SubNavButtons
               <SubNavButtons
                 isCompactMode={isCompactMode}
                 isCompactMode={isCompactMode}
                 pageId={pageId}
                 pageId={pageId}

+ 23 - 5
packages/app/src/components/Navbar/GrowiSubNavigation.module.scss

@@ -2,6 +2,14 @@
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/bootstrap/init' as bs;
 @use '~/styles/mixins';
 @use '~/styles/mixins';
 
 
+%subnav-buttons-height {
+  height: 40px;
+}
+
+%compact-subnav-buttons-height {
+  height: 32px;
+}
+
 // https://github.com/css-modules/css-modules/issues/295#issuecomment-404873976
 // https://github.com/css-modules/css-modules/issues/295#issuecomment-404873976
 // workaround to use '&' in global scope
 // workaround to use '&' in global scope
 .grw-subnav {
 .grw-subnav {
@@ -36,15 +44,20 @@
       }
       }
     }
     }
 
 
+    .btn-skelton {
+      @extend %subnav-buttons-height;
+      width: 100%;
+    }
+
     .btn-subscribe {
     .btn-subscribe {
-      height: 40px;
+      @extend %subnav-buttons-height;
       font-size: 20px;
       font-size: 20px;
     }
     }
 
 
     .btn-like,
     .btn-like,
     .btn-bookmark,
     .btn-bookmark,
     .btn-seen-user {
     .btn-seen-user {
-      height: 40px;
+      @extend %subnav-buttons-height;
       padding-right: 6px;
       padding-right: 6px;
       padding-left: 8px;
       padding-left: 8px;
       font-size: 20px;
       font-size: 20px;
@@ -135,17 +148,22 @@
         min-height: 90px;
         min-height: 90px;
       }
       }
 
 
+      .btn-skelton {
+        @extend %compact-subnav-buttons-height;
+        width: 100%;
+      }
+
       .btn-like,
       .btn-like,
       .btn-bookmark,
       .btn-bookmark,
       .btn-subscribe {
       .btn-subscribe {
         width: 32px;
         width: 32px;
-        height: 32px;
+        @extend %compact-subnav-buttons-height;
         padding: 4px;
         padding: 4px;
         font-size: 16px;
         font-size: 16px;
       }
       }
       .btn-seen-user {
       .btn-seen-user {
         width: 48px;
         width: 48px;
-        height: 32px;
+        @extend %compact-subnav-buttons-height;
         padding: 4px;
         padding: 4px;
         font-size: 16px;
         font-size: 16px;
 
 
@@ -156,7 +174,7 @@
       }
       }
       .btn-page-item-control {
       .btn-page-item-control {
         width: 32px;
         width: 32px;
-        height: 32px;
+        @extend %compact-subnav-buttons-height;
         font-size: 12px;
         font-size: 12px;
       }
       }
     }
     }

+ 2 - 3
packages/app/src/components/Navbar/GrowiSubNavigation.tsx

@@ -11,7 +11,6 @@ import {
 import PagePathNav from '../PagePathNav';
 import PagePathNav from '../PagePathNav';
 import { Skelton } from '../Skelton';
 import { Skelton } from '../Skelton';
 
 
-import AuthorInfo from './AuthorInfo';
 import DrawerToggler from './DrawerToggler';
 import DrawerToggler from './DrawerToggler';
 
 
 
 
@@ -38,8 +37,8 @@ type Props = {
 
 
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
 export const GrowiSubNavigation = (props: Props): JSX.Element => {
 
 
-  const TagLabels = dynamic(() => import('../Page/TagLabels'), { ssr: false, loading: () => <Skelton width={124.5} height={21.99} /> });
-  const AuthorInfo = dynamic(() => import('./AuthorInfo'), { ssr: false, loading: () => <Skelton width={148.32} height={32.84} /> });
+  const TagLabels = dynamic(() => import('../Page/TagLabels'), { ssr: false, loading: () => <Skelton width={137} height={21.99} additionalClass='py-1' /> });
+  const AuthorInfo = dynamic(() => import('./AuthorInfo'), { ssr: false, loading: () => <Skelton width={139} height={32.84} additionalClass='py-1' /> });
 
 
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
 
 

+ 7 - 17
packages/app/src/components/Navbar/SubNavButtons.tsx

@@ -12,10 +12,14 @@ import { IPageForPageDuplicateModal } from '~/stores/modal';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { useSWRxUsersList } from '../../stores/user';
+import BookmarkButtons from '../BookmarkButtons';
 import {
 import {
-  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType, PageItemControlProps,
+  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
+  PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
 } from '../Common/Dropdown/PageItemControl';
-import { Skelton } from '../Skelton';
+import LikeButtons from '../LikeButtons';
+import SubscribeButton from '../SubscribeButton';
+import SeenUserInfo from '../User/SeenUserInfo';
 
 
 
 
 type CommonProps = {
 type CommonProps = {
@@ -51,20 +55,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
 
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
   const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageId);
 
 
-  // dynamic import for show skelton
-  const SubscribeButton = dynamic(() => import('../SubscribeButton'), { ssr: false, loading: () => <Skelton width={37} additionalClass='btn-subscribe'/> });
-  const LikeButtons = dynamic(() => import('../LikeButtons'), { ssr: false, loading: () => <Skelton width={57.48} additionalClass='btn-like'/> });
-  const BookmarkButtons = dynamic(() => import('../BookmarkButtons'), {
-    ssr: false,
-    loading: () => <Skelton width={53.48} additionalClass='total-bookmarks'/>,
-  });
-  const SeenUserInfo = dynamic(() => import('../User/SeenUserInfo'), { ssr: false, loading: () => <Skelton width={58.98} additionalClass='btn-seen-user'/> });
-  const PageItemControl = dynamic<PageItemControlProps>(() => import('../Common/Dropdown/PageItemControl').then(mod => mod.PageItemControl), {
-    ssr: false,
-    loading: () => <Skelton width={37} additionalClass='btn-page-item-control'/>,
-  });
-
-
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
 
@@ -215,7 +205,7 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
   );
   );
 };
 };
 
 
-type SubNavButtonsProps= CommonProps & {
+export type SubNavButtonsProps = CommonProps & {
   pageId: string,
   pageId: string,
   shareLinkId?: string | null,
   shareLinkId?: string | null,
   revisionId?: string | null,
   revisionId?: string | null,

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

@@ -17,7 +17,6 @@ import CountBadge from '../Common/CountBadge';
 import PageListIcon from '../Icons/PageListIcon';
 import PageListIcon from '../Icons/PageListIcon';
 import NotFoundPage from '../NotFoundPage';
 import NotFoundPage from '../NotFoundPage';
 import { Page } from '../Page';
 import { Page } from '../Page';
-// import PageEditor from '../PageEditor';
 // import PageEditorByHackmd from '../PageEditorByHackmd';
 // import PageEditorByHackmd from '../PageEditorByHackmd';
 import TableOfContents from '../TableOfContents';
 import TableOfContents from '../TableOfContents';
 import UserInfo from '../User/UserInfo';
 import UserInfo from '../User/UserInfo';
@@ -33,6 +32,7 @@ const { isTopPage } = pagePathUtils;
 const DisplaySwitcher = (): JSX.Element => {
 const DisplaySwitcher = (): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
+  const PageEditor = dynamic(() => import('../PageEditor'), { ssr: false });
   const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
   const EditorNavbarBottom = dynamic(() => import('../PageEditor/EditorNavbarBottom'), { ssr: false });
   const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
   const HashChanged = dynamic(() => import('../EventListeneres/HashChanged'), { ssr: false });
   const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
   const ContentLinkButtons = dynamic(() => import('../ContentLinkButtons'), { ssr: false });
@@ -126,7 +126,7 @@ const DisplaySwitcher = (): JSX.Element => {
         { isEditable && (
         { isEditable && (
           <TabPane tabId={EditorMode.Editor}>
           <TabPane tabId={EditorMode.Editor}>
             <div data-testid="page-editor" id="page-editor">
             <div data-testid="page-editor" id="page-editor">
-              {/* <PageEditor /> */}
+              <PageEditor />
             </div>
             </div>
           </TabPane>
           </TabPane>
         ) }
         ) }

+ 26 - 23
packages/app/src/components/PageComment.tsx

@@ -2,41 +2,41 @@ import React, {
   FC, useEffect, useState, useMemo, memo, useCallback,
   FC, useEffect, useState, useMemo, memo, useCallback,
 } from 'react';
 } from 'react';
 
 
+import { Nullable } from '@growi/core';
 import { Button } from 'reactstrap';
 import { Button } from 'reactstrap';
 
 
-
-import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { apiPost } from '~/client/util/apiv1-client';
+import { useCurrentPagePath } from '~/stores/context';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { useCommentPreviewOptions } from '~/stores/renderer';
 import { useCommentPreviewOptions } from '~/stores/renderer';
 
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 
-
-import Comment from './PageComment/Comment';
+import { Comment } from './PageComment/Comment';
 import CommentEditor from './PageComment/CommentEditor';
 import CommentEditor from './PageComment/CommentEditor';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
 import ReplayComments from './PageComment/ReplayComments';
 import ReplayComments from './PageComment/ReplayComments';
 
 
 type Props = {
 type Props = {
-  appContainer: AppContainer,
-  pageId: string,
+  pageId?: Nullable<string>, // TODO: check pageId type
   isReadOnly : boolean,
   isReadOnly : boolean,
   titleAlign?: 'center' | 'left' | 'right',
   titleAlign?: 'center' | 'left' | 'right',
   highlightKeywords?:string[],
   highlightKeywords?:string[],
   hideIfEmpty?: boolean,
   hideIfEmpty?: boolean,
 }
 }
 
 
-
-const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
+export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 
 
   const {
   const {
-    appContainer, pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
+    pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
   } = props;
   } = props;
 
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
   const { data: comments, mutate } = useSWRxPageComment(pageId);
   const { data: rendererOptions } = useCommentPreviewOptions();
   const { data: rendererOptions } = useCommentPreviewOptions();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: currentPagePath } = useCurrentPagePath();
 
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
@@ -110,17 +110,8 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     }
     }
   }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
   }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
 
 
-  const generateCommentInnerElement = (comment: ICommentHasId) => (
-    <Comment
-      rendererOptions={rendererOptions}
-      deleteBtnClicked={onClickDeleteButton}
-      comment={comment}
-      onComment={mutate}
-      isReadOnly={isReadOnly}
-    />
-  );
-
   const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
   const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
+    // TODO: need page props path
     <ReplayComments
     <ReplayComments
       replyList={replyComments}
       replyList={replyComments}
       deleteBtnClicked={onClickDeleteButton}
       deleteBtnClicked={onClickDeleteButton}
@@ -143,11 +134,25 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   if (hideIfEmpty && comments?.length === 0) {
   if (hideIfEmpty && comments?.length === 0) {
     return <></>;
     return <></>;
   }
   }
-
-  if (rendererOptions == null) {
+  if (rendererOptions == null || currentPagePath == null) {
     return <></>;
     return <></>;
   }
   }
 
 
+  const generateCommentInnerElement = (comment: ICommentHasId) => (
+    currentPage != null && (
+      <Comment
+        rendererOptions={rendererOptions}
+        deleteBtnClicked={onClickDeleteButton}
+        comment={comment}
+        onComment={mutate}
+        isReadOnly={isReadOnly}
+        currentPagePath={currentPagePath}
+        currentRevisionId={currentPage.revision._id}
+        currentRevisionCreatedAt={currentPage.revision.createdAt}
+      />
+    )
+  );
+
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
 
@@ -224,5 +229,3 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 });
 });
 
 
 PageComment.displayName = 'PageComment';
 PageComment.displayName = 'PageComment';
-
-export default PageComment;

+ 0 - 259
packages/app/src/components/PageComment/Comment.jsx

@@ -1,259 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui';
-import { format } from 'date-fns';
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { useCurrentUser } from '~/stores/context';
-
-import FormattedDistanceDate from '../FormattedDistanceDate';
-import HistoryIcon from '../Icons/HistoryIcon';
-import RevisionBody from '../Page/RevisionBody';
-import { withUnstatedContainers } from '../UnstatedUtils';
-import Username from '../User/Username';
-
-import CommentControl from './CommentControl';
-import CommentEditor from './CommentEditor';
-
-import { RendererOptions } from '~/services/renderer/renderer';
-
-/**
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class Comment
- * @extends {React.Component}
- */
-class Comment extends React.PureComponent {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      html: '',
-      isReEdit: false,
-    };
-
-    this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
-    this.isCurrentRevision = this.isCurrentRevision.bind(this);
-    this.getRootClassName = this.getRootClassName.bind(this);
-    this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
-    this.renderText = this.renderText.bind(this);
-    this.renderHtml = this.renderHtml.bind(this);
-  }
-
-
-  initCurrentRenderingContext() {
-    this.currentRenderingContext = {
-      markdown: this.props.comment.comment,
-    };
-  }
-
-  componentDidMount() {
-    this.initCurrentRenderingContext();
-    this.renderHtml();
-  }
-
-  componentDidUpdate(prevProps) {
-    const { comment: prevComment } = prevProps;
-    const { comment } = this.props;
-
-    // render only when props.markdown is updated
-    if (comment !== prevComment) {
-      this.initCurrentRenderingContext();
-      this.renderHtml();
-      return;
-    }
-
-    const { interceptorManager } = window;
-
-    interceptorManager.process('postRenderCommentHtml', this.currentRenderingContext);
-  }
-
-  isCurrentUserEqualsToAuthor() {
-    const { comment, currentUser } = this.props;
-    const { creator } = comment;
-
-    if (creator == null || currentUser == null) {
-      return false;
-    }
-    return creator.username === currentUser.username;
-  }
-
-  isCurrentRevision() {
-    return this.props.comment.revision === this.props.pageContainer.state.revisionId;
-  }
-
-  getRootClassName(comment) {
-    let className = 'page-comment flex-column';
-
-    const { revisionId, revisionCreatedAt } = this.props.pageContainer.state;
-    if (comment.revision === revisionId) {
-      className += ' page-comment-current';
-    }
-    else if (Date.parse(comment.createdAt) / 1000 > revisionCreatedAt) {
-      className += ' page-comment-newer';
-    }
-    else {
-      className += ' page-comment-older';
-    }
-
-    if (this.isCurrentUserEqualsToAuthor()) {
-      className += ' page-comment-me';
-    }
-
-    return className;
-  }
-
-  deleteBtnClickedHandler() {
-    this.props.deleteBtnClicked(this.props.comment);
-  }
-
-  renderText(comment) {
-    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
-  }
-
-  renderRevisionBody() {
-    const config = this.props.appContainer.getConfig();
-    const isMathJaxEnabled = !!config.env.MATHJAX;
-    return (
-      <RevisionBody
-        html={this.state.html}
-        isMathJaxEnabled={isMathJaxEnabled}
-        renderMathJaxOnInit
-        additionalClassName="comment"
-      />
-    );
-  }
-
-  async renderHtml() {
-
-    const { rendererOptions, appContainer } = this.props;
-    const { interceptorManager } = window;
-    const context = this.currentRenderingContext;
-
-    await interceptorManager.process('preRenderComment', context);
-    await interceptorManager.process('prePreProcess', context);
-    context.markdown = await rendererOptions.preProcess(context.markdown, context);
-    await interceptorManager.process('postPreProcess', context);
-    context.parsedHTML = await rendererOptions.process(context.markdown, context);
-    await interceptorManager.process('prePostProcess', context);
-    context.parsedHTML = await rendererOptions.postProcess(context.parsedHTML, context);
-    await interceptorManager.process('postPostProcess', context);
-    await interceptorManager.process('preRenderCommentHtml', context);
-    this.setState({ html: context.parsedHTML });
-    await interceptorManager.process('postRenderCommentHtml', context);
-  }
-
-  render() {
-    const {
-      t, comment, isReadOnly, onComment,
-    } = this.props;
-    const commentId = comment._id;
-    const creator = comment.creator;
-    const isMarkdown = comment.isMarkdown;
-    const createdAt = new Date(comment.createdAt);
-    const updatedAt = new Date(comment.updatedAt);
-    const isEdited = createdAt < updatedAt;
-
-    const rootClassName = this.getRootClassName(comment);
-    const commentBody = isMarkdown ? this.renderRevisionBody() : this.renderText(comment.comment);
-    const revHref = `?revision=${comment.revision}`;
-
-    const editedDateId = `editedDate-${comment._id}`;
-    const editedDateFormatted = isEdited
-      ? format(updatedAt, 'yyyy/MM/dd HH:mm')
-      : null;
-
-    return (
-      <React.Fragment>
-        {(this.state.isReEdit && !isReadOnly) ? (
-          <CommentEditor
-            rendererOptions={this.props.rendererOptions}
-            currentCommentId={commentId}
-            commentBody={comment.comment}
-            replyTo={undefined}
-            commentCreator={creator?.username}
-            onCancelButtonClicked={() => this.setState({ isReEdit: false })}
-            onCommentButtonClicked={() => {
-              this.setState({ isReEdit: false });
-              if (onComment != null) onComment();
-            }}
-          />
-        ) : (
-          <div id={commentId} className={rootClassName}>
-            <div className="page-comment-writer">
-              <UserPicture user={creator} />
-            </div>
-            <div className="page-comment-main">
-              <div className="page-comment-creator">
-                <Username user={creator} />
-              </div>
-              <div className="page-comment-body">{commentBody}</div>
-              <div className="page-comment-meta">
-                <a href={`#${commentId}`}>
-                  <FormattedDistanceDate id={commentId} date={comment.createdAt} />
-                </a>
-                { isEdited && (
-                  <>
-                    <span id={editedDateId}>&nbsp;(edited)</span>
-                    <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
-                  </>
-                )}
-                <span className="ml-2">
-                  <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
-                    <HistoryIcon />
-                  </a>
-                  <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
-                    {t('page_comment.display_the_page_when_posting_this_comment')}
-                  </UncontrolledTooltip>
-                </span>
-              </div>
-              {(this.isCurrentUserEqualsToAuthor() && !isReadOnly) && (
-                <CommentControl
-                  onClickDeleteBtn={this.deleteBtnClickedHandler}
-                  onClickEditBtn={() => this.setState({ isReEdit: true })}
-                />
-              ) }
-            </div>
-          </div>
-        )
-        }
-      </React.Fragment>
-    );
-  }
-
-}
-
-Comment.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
-  comment: PropTypes.object.isRequired,
-  isReadOnly: PropTypes.bool.isRequired,
-  rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
-  deleteBtnClicked: PropTypes.func.isRequired,
-  currentUser: PropTypes.object,
-  onComment: PropTypes.func,
-};
-
-const CommentWrapperFC = (props) => {
-  const { t } = useTranslation();
-
-  const { data: currentUser } = useCurrentUser();
-
-  return <Comment t={t} currentUser={currentUser} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const CommentWrapper = withUnstatedContainers(CommentWrapperFC, [AppContainer, PageContainer]);
-
-export default CommentWrapper;

+ 178 - 0
packages/app/src/components/PageComment/Comment.tsx

@@ -0,0 +1,178 @@
+import React, { useEffect, useState } from 'react';
+
+
+import { UserPicture } from '@growi/ui';
+import { ConsoleFormattedStream } from 'browser-bunyan';
+import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useCurrentUser } from '~/stores/context';
+
+import { ICommentHasId } from '../../interfaces/comment';
+import FormattedDistanceDate from '../FormattedDistanceDate';
+import HistoryIcon from '../Icons/HistoryIcon';
+import RevisionRenderer from '../Page/RevisionRenderer';
+import Username from '../User/Username';
+
+import CommentControl from './CommentControl';
+import CommentEditor from './CommentEditor';
+
+type CommentProps = {
+  comment: ICommentHasId,
+  isReadOnly: boolean,
+  deleteBtnClicked: (comment: ICommentHasId) => void,
+  onComment: () => void,
+  rendererOptions: RendererOptions,
+  currentPagePath: string,
+  currentRevisionId: string,
+  currentRevisionCreatedAt: Date,
+}
+
+export const Comment = (props: CommentProps): JSX.Element => {
+  const {
+    comment, isReadOnly, deleteBtnClicked, onComment, rendererOptions, currentPagePath, currentRevisionId, currentRevisionCreatedAt,
+  } = props;
+  const { t } = useTranslation();
+  const { data: currentUser } = useCurrentUser();
+
+  const [markdown, setMarkdown] = useState('');
+  const [isReEdit, setIsReEdit] = useState(false);
+
+  const commentId = comment._id;
+  const creator = comment.creator;
+  const isMarkdown = comment.isMarkdown;
+  const createdAt = new Date(comment.createdAt);
+  const updatedAt = new Date(comment.updatedAt);
+  const isEdited = createdAt < updatedAt;
+
+  useEffect(() => {
+    setMarkdown(comment.comment);
+
+    const isCurrentRevision = () => {
+      return comment.revision === currentRevisionId;
+    };
+    isCurrentRevision();
+
+  }, [comment, currentRevisionId]);
+
+  const isCurrentUserEqualsToAuthor = () => {
+    const { creator }: any = comment;
+
+    if (creator == null || currentUser == null) {
+      return false;
+    }
+    return creator.username === currentUser.username;
+  };
+
+  const getRootClassName = (comment) => {
+    let className = 'page-comment flex-column';
+
+    if (comment.revision === currentRevisionId) {
+      className += ' page-comment-current';
+    }
+    else if (comment.createdAt.getTime() > currentRevisionCreatedAt.getTime()) {
+      className += ' page-comment-newer';
+    }
+    else {
+      className += ' page-comment-older';
+    }
+
+    if (isCurrentUserEqualsToAuthor()) {
+      className += ' page-comment-me';
+    }
+
+    return className;
+  };
+
+  const deleteBtnClickedHandler = (comment) => {
+    deleteBtnClicked(comment);
+  };
+
+  const renderText = (comment) => {
+    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
+  };
+
+  // TODO: Remove when update ReplayComments.jsx
+  if (currentPagePath == null) {
+    return <></>;
+  }
+
+  const renderRevisionBody = () => {
+    return (
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+        pagePath={currentPagePath}
+      />
+    );
+  };
+
+  const rootClassName = getRootClassName(comment);
+  const commentBody = isMarkdown ? renderRevisionBody() : renderText(comment.comment);
+  const revHref = `?revision=${comment.revision}`;
+
+  const editedDateId = `editedDate-${comment._id}`;
+  const editedDateFormatted = isEdited
+    ? format(updatedAt, 'yyyy/MM/dd HH:mm')
+    : null;
+
+  return (
+    <>
+      {(isReEdit && !isReadOnly) ? (
+        <CommentEditor
+          rendererOptions={rendererOptions}
+          currentCommentId={commentId}
+          commentBody={comment.comment}
+          replyTo={undefined}
+          commentCreator={creator?.username}
+          onCancelButtonClicked={() => setIsReEdit(false)}
+          onCommentButtonClicked={() => {
+            setIsReEdit(false);
+            if (onComment != null) onComment();
+          }}
+        />
+      ) : (
+        <div id={commentId} className={rootClassName}>
+          <div className="page-comment-writer">
+            <UserPicture user={creator} />
+          </div>
+          <div className="page-comment-main">
+            <div className="page-comment-creator">
+              <Username user={creator} />
+            </div>
+            <div className="page-comment-body">{commentBody}</div>
+            <div className="page-comment-meta">
+              <a href={`#${commentId}`}>
+                <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+              </a>
+              { isEdited && (
+                <>
+                  <span id={editedDateId}>&nbsp;(edited)</span>
+                  <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
+                </>
+              )}
+              <span className="ml-2">
+                <a id={`page-comment-revision-${commentId}`} className="page-comment-revision" href={revHref}>
+                  <HistoryIcon />
+                </a>
+                <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                  {t('page_comment.display_the_page_when_posting_this_comment')}
+                </UncontrolledTooltip>
+              </span>
+            </div>
+            {(isCurrentUserEqualsToAuthor() && !isReadOnly) && (
+              <CommentControl
+                onClickDeleteBtn={deleteBtnClickedHandler}
+                onClickEditBtn={() => setIsReEdit(true)}
+              />
+            ) }
+          </div>
+        </div>
+      )
+      }
+    </>
+  );
+};

+ 16 - 29
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -9,16 +9,13 @@ import {
 } from 'reactstrap';
 } from 'reactstrap';
 import * as toastr from 'toastr';
 import * as toastr from 'toastr';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { CustomWindow } from '~/interfaces/global';
 import { CustomWindow } from '~/interfaces/global';
 import { IInterceptorManager } from '~/interfaces/interceptor-manager';
 import { IInterceptorManager } from '~/interfaces/interceptor-manager';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
 import {
-  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,
+  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useRendererConfig,
 } from '~/stores/context';
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
 import { useIsMobile } from '~/stores/ui';
@@ -26,9 +23,8 @@ import { useIsMobile } from '~/stores/ui';
 
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-import Editor from '../PageEditor/Editor';
+// import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
 import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 import CommentPreview from './CommentPreview';
 import CommentPreview from './CommentPreview';
 
 
@@ -47,8 +43,6 @@ const navTabMapping = {
 };
 };
 
 
 type PropsType = {
 type PropsType = {
-  appContainer: AppContainer,
-
   rendererOptions: RendererOptions,
   rendererOptions: RendererOptions,
   isForNewComment?: boolean,
   isForNewComment?: boolean,
   replyTo?: string,
   replyTo?: string,
@@ -68,7 +62,7 @@ type EditorRef = {
 const CommentEditor = (props: PropsType): JSX.Element => {
 const CommentEditor = (props: PropsType): JSX.Element => {
 
 
   const {
   const {
-    appContainer, rendererOptions, isForNewComment, replyTo,
+    rendererOptions, isForNewComment, replyTo,
     currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
     currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
   } = props;
   const { data: currentUser } = useCurrentUser();
   const { data: currentUser } = useCurrentUser();
@@ -79,11 +73,11 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   const { data: isMobile } = useIsMobile();
   const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: config } = useRendererConfig();
 
 
-  const config = appContainer.getConfig();
-  const isUploadable = config.upload.image || config.upload.file;
-  const isUploadableFile = config.upload.file;
-  const isSlackConfigured = config.isSlackConfigured;
+  // const isUploadable = config.upload.image || config.upload.file;
+  // const isUploadableFile = config.upload.file;
+  // const isSlackConfigured = config.isSlackConfigured;
 
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
   const [comment, setComment] = useState(commentBody ?? '');
@@ -292,8 +286,8 @@ const CommentEditor = (props: PropsType): JSX.Element => {
       </Button>
       </Button>
     );
     );
 
 
-    // TODO: typescriptize Editor
-    const AnyEditor = Editor as any;
+    // // TODO: typescriptize Editor
+    // const AnyEditor = Editor as any;
 
 
     return (
     return (
       <>
       <>
@@ -301,18 +295,18 @@ const CommentEditor = (props: PropsType): JSX.Element => {
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
             <TabPane tabId="comment_editor">
-              <AnyEditor
+              {/* <AnyEditor
                 ref={editorRef}
                 ref={editorRef}
                 value={comment}
                 value={comment}
                 lineNumbers={false}
                 lineNumbers={false}
                 isMobile={isMobile}
                 isMobile={isMobile}
-                isUploadable={isUploadable}
-                isUploadableFile={isUploadableFile}
+                // isUploadable={isUploadable}
+                // isUploadableFile={isUploadableFile}
                 onChange={setComment}
                 onChange={setComment}
                 onUpload={uploadHandler}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
                 isComment
-              />
+              /> */}
               {/*
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 See a review comment in https://github.com/weseek/growi/pull/3473
                 See a review comment in https://github.com/weseek/growi/pull/3473
@@ -331,7 +325,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
             <span className="flex-grow-1" />
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
 
-            { isSlackConfigured
+            {/* { isSlackConfigured
               && (
               && (
                 <div className="form-inline align-self-center mr-md-2">
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
                   <SlackNotification
@@ -343,7 +337,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
                   />
                   />
                 </div>
                 </div>
               )
               )
-            }
+            } */}
             <div className="d-none d-sm-block">
             <div className="d-none d-sm-block">
               <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
               <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
             </div>
             </div>
@@ -377,11 +371,4 @@ const CommentEditor = (props: PropsType): JSX.Element => {
 
 
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const CommentEditorWrapper = withUnstatedContainers<unknown, Partial<PropsType>>(
-  CommentEditor, [AppContainer, PageContainer, EditorContainer],
-);
-
-export default CommentEditorWrapper;
+export default CommentEditor;

+ 2 - 7
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,14 +1,12 @@
 import React, { FC } from 'react';
 import React, { FC } from 'react';
 
 
-import { useSWRxPageComment } from '../../stores/comment';
+import { useCommentPreviewOptions } from '~/stores/renderer';
 
 
-import AppContainer from '~/client/services/AppContainer';
+import { useSWRxPageComment } from '../../stores/comment';
 
 
 import CommentEditor from './CommentEditor';
 import CommentEditor from './CommentEditor';
-import { useCommentPreviewOptions } from '~/stores/renderer';
 
 
 type Props = {
 type Props = {
-  appContainer: AppContainer,
   pageId: string,
   pageId: string,
 }
 }
 
 
@@ -22,11 +20,8 @@ const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
     return <></>;
     return <></>;
   }
   }
 
 
-  const { appContainer } = props;
-
   return (
   return (
     <CommentEditor
     <CommentEditor
-      appContainer={appContainer}
       rendererOptions={rendererOptions}
       rendererOptions={rendererOptions}
       replyTo={undefined}
       replyTo={undefined}
       onCommentButtonClicked={mutate}
       onCommentButtonClicked={mutate}

+ 13 - 17
packages/app/src/components/PageComment/ReplayComments.jsx

@@ -1,16 +1,13 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
 
 
+import PropTypes from 'prop-types';
 import { Collapse } from 'reactstrap';
 import { Collapse } from 'reactstrap';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import PageContainer from '~/client/services/PageContainer';
-
-import Comment from './Comment';
+import { RendererOptions } from '~/services/renderer/renderer';
+import { useRendererConfig } from '~/stores/context';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
+import { Comment } from './Comment';
 
 
-import { RendererOptions } from '~/services/renderer/renderer';
 
 
 class ReplayComments extends React.PureComponent {
 class ReplayComments extends React.PureComponent {
 
 
@@ -42,8 +39,9 @@ class ReplayComments extends React.PureComponent {
   }
   }
 
 
   render() {
   render() {
+    const { config } = this.props;
 
 
-    const isAllReplyShown = this.props.appContainer.getConfig().isAllReplyShown || false;
+    const isAllReplyShown = config.isAllReplyShown || false;
     const replyList = this.props.replyList;
     const replyList = this.props.replyList;
 
 
     if (isAllReplyShown) {
     if (isAllReplyShown) {
@@ -100,19 +98,17 @@ class ReplayComments extends React.PureComponent {
 
 
 }
 }
 
 
-/**
- * Wrapper component for using unstated
- */
-const ReplayCommentsWrapper = withUnstatedContainers(ReplayComments, [AppContainer, PageContainer]);
-
 ReplayComments.propTypes = {
 ReplayComments.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-
   rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
   rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
   isReadOnly: PropTypes.bool.isRequired,
   isReadOnly: PropTypes.bool.isRequired,
   replyList: PropTypes.array,
   replyList: PropTypes.array,
 };
 };
 
 
-export default ReplayCommentsWrapper;
+const ReplayCommentsWrapperFC = (props) => {
+  const { data: config } = useRendererConfig();
+
+  return <ReplayComments config={config} {...props} />;
+};
+
+export default ReplayCommentsWrapperFC;

+ 104 - 102
packages/app/src/components/PageEditor.tsx

@@ -8,31 +8,32 @@ import { envUtils } from '@growi/core';
 import detectIndent from 'detect-indent';
 import detectIndent from 'detect-indent';
 import { throttle, debounce } from 'throttle-debounce';
 import { throttle, debounce } from 'throttle-debounce';
 
 
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
+// import AppContainer from '~/client/services/AppContainer';
+// import EditorContainer from '~/client/services/EditorContainer';
+// import PageContainer from '~/client/services/PageContainer';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import {
 import {
-  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPageId,
+  useIsEditable, useIsIndentSizeForced, useCurrentPagePath, useCurrentPageId, useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 } from '~/stores/context';
 import {
 import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
   useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
 import {
   EditorMode,
   EditorMode,
-  useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useIsMobile, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 
 
-import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+// import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 import Editor from './PageEditor/Editor';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
-import { withUnstatedContainers } from './UnstatedUtils';
+// import { withUnstatedContainers } from './UnstatedUtils';
 
 
 
 
 // TODO: remove this when omitting unstated is completed
 // TODO: remove this when omitting unstated is completed
@@ -52,26 +53,26 @@ type EditorRef = {
 }
 }
 
 
 type Props = {
 type Props = {
-  appContainer: AppContainer,
-  pageContainer: PageContainer,
-  editorContainer: EditorContainer,
-
-  isEditable: boolean,
-
-  editorMode: string,
-  isSlackEnabled: boolean,
-  slackChannels: string,
-  isMobile?: boolean,
-
-  grant: number,
-  grantGroupId?: string,
-  grantGroupName?: string,
-  mutateGrant: (grant: number) => void,
-
-  isTextlintEnabled?: boolean,
-  isIndentSizeForced?: boolean,
-  indentSize?: number,
-  mutateCurrentIndentSize: (indent: number) => void,
+  // appContainer: AppContainer,
+  // pageContainer: PageContainer,
+  // editorContainer: EditorContainer,
+
+  // isEditable: boolean,
+
+  // editorMode: string,
+  // isSlackEnabled: boolean,
+  // slackChannels: string,
+  // isMobile?: boolean,
+
+  // grant: number,
+  // grantGroupId?: string,
+  // grantGroupName?: string,
+  // mutateGrant: (grant: number) => void,
+
+  // isTextlintEnabled?: boolean,
+  // isIndentSizeForced?: boolean,
+  // indentSize?: number,
+  // mutateCurrentIndentSize: (indent: number) => void,
 };
 };
 
 
 // for scrolling
 // for scrolling
@@ -80,9 +81,9 @@ let isOriginOfScrollSyncEditor = false;
 let isOriginOfScrollSyncPreview = false;
 let isOriginOfScrollSyncPreview = false;
 
 
 const PageEditor = (props: Props): JSX.Element => {
 const PageEditor = (props: Props): JSX.Element => {
-  const {
-    appContainer, pageContainer, editorContainer,
-  } = props;
+  // const {
+  //   appContainer, pageContainer, editorContainer,
+  // } = props;
 
 
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
@@ -92,76 +93,75 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
-  const { data: grantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isTextlintEnabled } = useIsTextlintEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
+  const { data: isUploadableFile } = useIsUploadableFile();
+  const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: currentPage } = useSWRxCurrentPage();
 
 
   const { data: rendererOptions } = usePreviewOptions();
   const { data: rendererOptions } = usePreviewOptions();
 
 
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-  const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
+  const [markdown, setMarkdown] = useState<string>('');
+
+  useEffect(() => {
+    if (currentPage != null) {
+      setMarkdown(currentPage.revision.body);
+    }
+  }, [currentPage, currentPage?.revision.body]);
 
 
 
 
   const editorRef = useRef<EditorRef>(null);
   const editorRef = useRef<EditorRef>(null);
   const previewRef = useRef<HTMLDivElement>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
 
   const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, value => setMarkdown(value))), []);
   const setMarkdownWithDebounce = useMemo(() => debounce(50, throttle(100, value => setMarkdown(value))), []);
-  const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
-    editorContainer.saveDraft(pageContainer.state.path, markdown);
-  }), [editorContainer, markdown, pageContainer.state.path]);
+  // const saveDraftWithDebounce = useMemo(() => debounce(800, () => {
+  //   editorContainer.saveDraft(pageContainer.state.path, markdown);
+  // }), [editorContainer, markdown, pageContainer.state.path]);
 
 
   const markdownChangedHandler = useCallback((value: string): void => {
   const markdownChangedHandler = useCallback((value: string): void => {
     setMarkdownWithDebounce(value);
     setMarkdownWithDebounce(value);
     // only when the first time to edit
     // only when the first time to edit
-    if (!pageContainer.state.revisionId) {
-      saveDraftWithDebounce();
-    }
-  }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
+    // if (!pageContainer.state.revisionId) {
+    //   saveDraftWithDebounce();
+    // }
+  // }, [pageContainer.state.revisionId, saveDraftWithDebounce, setMarkdownWithDebounce]);
+  }, [setMarkdownWithDebounce]);
 
 
 
 
   const saveWithShortcut = useCallback(async() => {
   const saveWithShortcut = useCallback(async() => {
-    if (grant == null) {
+    if (grantData == null) {
       return;
       return;
     }
     }
 
 
     const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
     const slackChannels = slackChannelsData ? slackChannelsData.toString() : '';
 
 
-    const optionsToSave = getOptionsToSave(isSlackEnabled ?? false, slackChannels, grant, grantGroupId, grantGroupName, pageTags || []);
+    const optionsToSave = getOptionsToSave(
+      isSlackEnabled ?? false, slackChannels,
+      grantData.grant, grantData.grantedGroup?.id, grantData.grantedGroup?.name,
+      pageTags || [],
+    );
 
 
     try {
     try {
       // disable unsaved warning
       // disable unsaved warning
       mutateIsEnabledUnsavedWarning(false);
       mutateIsEnabledUnsavedWarning(false);
 
 
       // eslint-disable-next-line no-unused-vars
       // eslint-disable-next-line no-unused-vars
-      const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
+      // const { tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
       logger.debug('success to save');
       logger.debug('success to save');
 
 
-      pageContainer.showSuccessToastr();
+      // pageContainer.showSuccessToastr();
 
 
       // update state of EditorContainer
       // update state of EditorContainer
-      editorContainer.setState({ tags });
+      // editorContainer.setState({ tags });
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
+      // pageContainer.showErrorToastr(error);
     }
     }
-  }, [
-    editorContainer,
-    editorMode,
-    grant,
-    grantGroupId,
-    grantGroupName,
-    isSlackEnabled,
-    slackChannelsData,
-    markdown,
-    pageContainer,
-    pageTags,
-    mutateIsEnabledUnsavedWarning,
-  ]);
+  }, [grantData, isSlackEnabled, slackChannelsData, pageTags, mutateIsEnabledUnsavedWarning]);
 
 
 
 
   /**
   /**
@@ -184,10 +184,10 @@ const PageEditor = (props: Props): JSX.Element => {
       }
       }
 
 
       const formData = new FormData();
       const formData = new FormData();
-      const { pageId, path } = pageContainer.state;
+      // const { pageId, path } = pageContainer.state;
       formData.append('file', file);
       formData.append('file', file);
-      if (path != null) {
-        formData.append('path', path);
+      if (currentPagePath != null) {
+        formData.append('path', currentPagePath);
       }
       }
       if (pageId != null) {
       if (pageId != null) {
         formData.append('page_id', pageId);
         formData.append('page_id', pageId);
@@ -208,18 +208,19 @@ const PageEditor = (props: Props): JSX.Element => {
       // when if created newly
       // when if created newly
       if (res.pageCreated) {
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
         logger.info('Page is created', res.page._id);
-        pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
+        // pageContainer.updateStateAfterSave(res.page, res.tags, res.revision, editorMode);
         mutateGrant(res.page.grant);
         mutateGrant(res.page.grant);
       }
       }
     }
     }
     catch (e) {
     catch (e) {
       logger.error('failed to upload', e);
       logger.error('failed to upload', e);
-      pageContainer.showErrorToastr(e);
+      // pageContainer.showErrorToastr(e);
     }
     }
     finally {
     finally {
       editorRef.current.terminateUploadingState();
       editorRef.current.terminateUploadingState();
     }
     }
-  }, [editorMode, mutateGrant, pageContainer]);
+  // }, [editorMode, mutateGrant, pageContainer]);
+  }, [editorMode, mutateGrant]);
 
 
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -274,7 +275,9 @@ const PageEditor = (props: Props): JSX.Element => {
 
 
     // turn on the flag
     // turn on the flag
     isOriginOfScrollSyncEditor = true;
     isOriginOfScrollSyncEditor = true;
-    scrollSyncHelper.scrollPreviewToRevealOverflowing(previewRef.current, line);
+    if (previewRef.current != null) {
+      scrollSyncHelper.scrollPreviewToRevealOverflowing(previewRef.current, line);
+    }
   }, []);
   }, []);
   const scrollPreviewByCursorMovingWithThrottle = useMemo(() => throttle(20, scrollPreviewByCursorMoving), [scrollPreviewByCursorMoving]);
   const scrollPreviewByCursorMovingWithThrottle = useMemo(() => throttle(20, scrollPreviewByCursorMoving), [scrollPreviewByCursorMoving]);
 
 
@@ -314,14 +317,14 @@ const PageEditor = (props: Props): JSX.Element => {
 
 
 
 
   // register dummy instance to get markdown
   // register dummy instance to get markdown
-  useEffect(() => {
-    const pageEditorInstance = {
-      getMarkdown: () => {
-        return markdown;
-      },
-    };
-    appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
-  }, [appContainer, markdown]);
+  // useEffect(() => {
+  //   const pageEditorInstance = {
+  //     getMarkdown: () => {
+  //       return markdown;
+  //     },
+  //   };
+  //   appContainer.registerComponentInstance('PageEditor', pageEditorInstance);
+  // }, [appContainer, markdown]);
 
 
   // initial caret line
   // initial caret line
   useEffect(() => {
   useEffect(() => {
@@ -369,23 +372,23 @@ const PageEditor = (props: Props): JSX.Element => {
   }, []);
   }, []);
 
 
   // Displays an alert if there is a difference with pageContainer's markdown
   // Displays an alert if there is a difference with pageContainer's markdown
-  useEffect(() => {
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    if (pageContainer.state.markdown! !== markdown) {
-      mutateIsEnabledUnsavedWarning(true);
-    }
-  }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
+  // useEffect(() => {
+  //   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  //   if (pageContainer.state.markdown! !== markdown) {
+  //     mutateIsEnabledUnsavedWarning(true);
+  //   }
+  // }, [editorContainer, markdown, mutateIsEnabledUnsavedWarning, pageContainer.state.markdown]);
 
 
   // Detect indent size from contents (only when users are allowed to change it)
   // Detect indent size from contents (only when users are allowed to change it)
-  useEffect(() => {
-    const currentPageMarkdown = pageContainer.state.markdown;
-    if (!isIndentSizeForced && currentPageMarkdown != null) {
-      const detectedIndent = detectIndent(currentPageMarkdown);
-      if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
-        mutateCurrentIndentSize(detectedIndent.amount);
-      }
-    }
-  }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
+  // useEffect(() => {
+  //   const currentPageMarkdown = pageContainer.state.markdown;
+  //   if (!isIndentSizeForced && currentPageMarkdown != null) {
+  //     const detectedIndent = detectIndent(currentPageMarkdown);
+  //     if (detectedIndent.type === 'space' && new Set([2, 4]).has(detectedIndent.amount)) {
+  //       mutateCurrentIndentSize(detectedIndent.amount);
+  //     }
+  //   }
+  // }, [isIndentSizeForced, mutateCurrentIndentSize, pageContainer.state.markdown]);
 
 
 
 
   if (!isEditable) {
   if (!isEditable) {
@@ -396,12 +399,10 @@ const PageEditor = (props: Props): JSX.Element => {
     return <></>;
     return <></>;
   }
   }
 
 
-  const config = props.appContainer.getConfig();
-  const isUploadable = config.upload.image || config.upload.file;
-  const isUploadableFile = config.upload.file;
-  const isMathJaxEnabled = !!config.env.MATHJAX;
+  // const config = props.appContainer.getConfig();
+  // const isUploadable = config.upload.image || config.upload.file;
+  const isUploadable = isUploadableImage || isUploadableFile;
 
 
-  const noCdn = envUtils.toBoolean(config.env.NO_CDN);
 
 
   // TODO: omit no-explicit-any -- 2022.06.02 Yuki Takei
   // TODO: omit no-explicit-any -- 2022.06.02 Yuki Takei
   // It is impossible to avoid the error
   // It is impossible to avoid the error
@@ -410,13 +411,14 @@ const PageEditor = (props: Props): JSX.Element => {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   const EditorAny = Editor as any;
   const EditorAny = Editor as any;
 
 
+  // console.log('EditorAny', markdown);
+
   return (
   return (
     <div className="d-flex flex-wrap">
     <div className="d-flex flex-wrap">
       <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
       <div className="page-editor-editor-container flex-grow-1 flex-basis-0 mw-0">
         <EditorAny
         <EditorAny
           ref={editorRef}
           ref={editorRef}
           value={markdown}
           value={markdown}
-          noCdn={noCdn}
           isMobile={isMobile}
           isMobile={isMobile}
           isUploadable={isUploadable}
           isUploadable={isUploadable}
           isUploadableFile={isUploadableFile}
           isUploadableFile={isUploadableFile}
@@ -434,17 +436,16 @@ const PageEditor = (props: Props): JSX.Element => {
           markdown={markdown}
           markdown={markdown}
           rendererOptions={rendererOptions}
           rendererOptions={rendererOptions}
           ref={previewRef}
           ref={previewRef}
-          // isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={false}
           renderMathJaxOnInit={false}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
         />
         />
       </div>
       </div>
-      <ConflictDiffModal
+      {/* <ConflictDiffModal
         isOpen={pageContainer.state.isConflictDiffModalOpen}
         isOpen={pageContainer.state.isConflictDiffModalOpen}
         onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
         onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
         pageContainer={pageContainer}
         pageContainer={pageContainer}
         markdownOnEdit={markdown}
         markdownOnEdit={markdown}
-      />
+      /> */}
     </div>
     </div>
   );
   );
 };
 };
@@ -452,6 +453,7 @@ const PageEditor = (props: Props): JSX.Element => {
 /**
 /**
    * Wrapper component for using unstated
    * Wrapper component for using unstated
    */
    */
-const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
+// const PageEditorWrapper = withUnstatedContainers(PageEditor, [AppContainer, PageContainer, EditorContainer]);
 
 
-export default PageEditorWrapper;
+// export default PageEditorWrapper;
+export default PageEditor;

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

@@ -16,14 +16,14 @@ import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 
 import AbstractEditor from './AbstractEditor';
 import AbstractEditor from './AbstractEditor';
 import CommentMentionHelper from './CommentMentionHelper';
 import CommentMentionHelper from './CommentMentionHelper';
-import DrawioModal from './DrawioModal';
+// import DrawioModal from './DrawioModal';
 import EditorIcon from './EditorIcon';
 import EditorIcon from './EditorIcon';
 import EmojiPicker from './EmojiPicker';
 import EmojiPicker from './EmojiPicker';
 import EmojiPickerHelper from './EmojiPickerHelper';
 import EmojiPickerHelper from './EmojiPickerHelper';
-import GridEditModal from './GridEditModal';
+// import GridEditModal from './GridEditModal';
 import geu from './GridEditorUtil';
 import geu from './GridEditorUtil';
-import HandsontableModal from './HandsontableModal';
-import LinkEditModal from './LinkEditModal';
+// import HandsontableModal from './HandsontableModal';
+// import LinkEditModal from './LinkEditModal';
 import mdu from './MarkdownDrawioUtil';
 import mdu from './MarkdownDrawioUtil';
 import mlu from './MarkdownLinkUtil';
 import mlu from './MarkdownLinkUtil';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import MarkdownTableInterceptor from './MarkdownTableInterceptor';
@@ -32,6 +32,8 @@ import pasteHelper from './PasteHelper';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import PreventMarkdownListInterceptor from './PreventMarkdownListInterceptor';
 import SimpleCheatsheet from './SimpleCheatsheet';
 import SimpleCheatsheet from './SimpleCheatsheet';
 
 
+import styles from './CodeMirrorEditor.module.scss';
+
 // Textlint
 // Textlint
 window.JSHINT = JSHINT;
 window.JSHINT = JSHINT;
 window.kuromojin = { dicPath: '/static/dict' };
 window.kuromojin = { dicPath: '/static/dict' };
@@ -50,7 +52,6 @@ require('codemirror/addon/edit/matchtags');
 require('codemirror/addon/edit/closetag');
 require('codemirror/addon/edit/closetag');
 require('codemirror/addon/edit/continuelist');
 require('codemirror/addon/edit/continuelist');
 require('codemirror/addon/hint/show-hint');
 require('codemirror/addon/hint/show-hint');
-require('codemirror/addon/hint/show-hint.css');
 require('codemirror/addon/search/searchcursor');
 require('codemirror/addon/search/searchcursor');
 require('codemirror/addon/search/match-highlighter');
 require('codemirror/addon/search/match-highlighter');
 require('codemirror/addon/selection/active-line');
 require('codemirror/addon/selection/active-line');
@@ -58,12 +59,10 @@ require('codemirror/addon/scroll/annotatescrollbar');
 require('codemirror/addon/scroll/scrollpastend');
 require('codemirror/addon/scroll/scrollpastend');
 require('codemirror/addon/fold/foldcode');
 require('codemirror/addon/fold/foldcode');
 require('codemirror/addon/fold/foldgutter');
 require('codemirror/addon/fold/foldgutter');
-require('codemirror/addon/fold/foldgutter.css');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/markdown-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/addon/fold/brace-fold');
 require('codemirror/addon/display/placeholder');
 require('codemirror/addon/display/placeholder');
 require('codemirror/addon/lint/lint');
 require('codemirror/addon/lint/lint');
-require('codemirror/addon/lint/lint.css');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/autorefresh.ext');
 require('~/client/util/codemirror/drawio-fold.ext');
 require('~/client/util/codemirror/drawio-fold.ext');
 require('~/client/util/codemirror/gfm-growi.mode');
 require('~/client/util/codemirror/gfm-growi.mode');
@@ -790,19 +789,19 @@ class CodeMirrorEditor extends AbstractEditor {
   }
   }
 
 
   showGridEditorHandler() {
   showGridEditorHandler() {
-    this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
+    // this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
   }
   }
 
 
   showLinkEditHandler() {
   showLinkEditHandler() {
-    this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
+    // this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
   }
   }
 
 
   showHandsonTableHandler() {
   showHandsonTableHandler() {
-    this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
+    // this.handsontableModal.current.show(mtu.getMarkdownTable(this.getCodeMirror()));
   }
   }
 
 
   showDrawioHandler() {
   showDrawioHandler() {
-    this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
+    // this.drawioModal.current.show(mdu.getMarkdownDrawioMxfile(this.getCodeMirror()));
   }
   }
 
 
 
 
@@ -988,8 +987,11 @@ class CodeMirrorEditor extends AbstractEditor {
       gutters.push('CodeMirror-lint-markers');
       gutters.push('CodeMirror-lint-markers');
     }
     }
 
 
+    console.log(' this.state.value', this.state.value);
+    console.log(' this.props.value', this.props.value);
+
     return (
     return (
-      <React.Fragment>
+      <div className={`grw-codemirror-editor ${styles['grw-codemirror-editor']}`}>
 
 
         <UncontrolledCodeMirror
         <UncontrolledCodeMirror
           ref={(c) => { this.cm = c }}
           ref={(c) => { this.cm = c }}
@@ -1000,7 +1002,9 @@ class CodeMirrorEditor extends AbstractEditor {
             editor.on('paste', this.pasteHandler);
             editor.on('paste', this.pasteHandler);
             editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
             editor.on('scrollCursorIntoView', this.scrollCursorIntoViewHandler);
           }}
           }}
-          value={this.state.value}
+          // temporary set props.value
+          // value={this.state.value}
+          value={this.props.value}
           options={{
           options={{
             indentUnit: this.props.indentSize,
             indentUnit: this.props.indentSize,
             theme: this.props.editorSettings.theme ?? 'elegant',
             theme: this.props.editorSettings.theme ?? 'elegant',
@@ -1052,25 +1056,25 @@ class CodeMirrorEditor extends AbstractEditor {
         { this.renderCheatsheetOverlay() }
         { this.renderCheatsheetOverlay() }
         { this.renderEmojiPicker() }
         { this.renderEmojiPicker() }
 
 
-        <GridEditModal
+        {/* <GridEditModal
           ref={this.gridEditModal}
           ref={this.gridEditModal}
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
           onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
-        />
-        <LinkEditModal
+        /> */}
+        {/* <LinkEditModal
           ref={this.linkEditModal}
           ref={this.linkEditModal}
           onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
           onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
-        />
-        <HandsontableModal
+        /> */}
+        {/* <HandsontableModal
           ref={this.handsontableModal}
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
           autoFormatMarkdownTable={this.props.editorSettings.autoFormatMarkdownTable}
-        />
-        <DrawioModal
+        /> */}
+        {/* <DrawioModal
           ref={this.drawioModal}
           ref={this.drawioModal}
           onSave={this.onSaveForDrawio}
           onSave={this.onSaveForDrawio}
-        />
+        /> */}
 
 
-      </React.Fragment>
+      </div>
     );
     );
   }
   }
 
 

+ 93 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.module.scss

@@ -0,0 +1,93 @@
+@use '~/styles/variables' as var;
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-codemirror-editor :global {
+  @import '~codemirror/lib/codemirror';
+
+  // addons
+  @import '~codemirror/addon/hint/show-hint';
+  @import '~codemirror/addon/fold/foldgutter';
+  @import '~codemirror/addon/lint/lint';
+
+  // themes
+  @import '~codemirror/theme/elegant';
+  @import '~codemirror/theme/eclipse';
+
+  .CodeMirror {
+    pre.CodeMirror-line.grw-cm-header-line {
+      padding-top: 0.16em;
+      padding-bottom: 0.08em;
+      font-family: bs.$font-family-monospace;
+
+      // '#'
+      .cm-formatting-header {
+        font-style: italic;
+        font-weight: bold;
+        opacity: 0.5;
+      }
+
+      .cm-header-1 {
+        font-size: 1.9em;
+      }
+      .cm-header-2 {
+        font-size: 1.6em;
+      }
+      .cm-header-3 {
+        font-size: 1.4em;
+      }
+      .cm-header-4 {
+        font-size: 1.35em;
+      }
+      .cm-header-5 {
+        font-size: 1.25em;
+      }
+      .cm-header-6 {
+        font-size: 1.2em;
+      }
+    }
+
+    .cm-matchhighlight {
+      color: bs.$gray-900 !important;
+      background-color: cyan;
+    }
+
+    .CodeMirror-selection-highlight-scrollbar {
+      background-color: darkcyan;
+    }
+
+    // overwrite .CodeMirror-placeholder
+    pre.CodeMirror-line-like.CodeMirror-placeholder {
+      color: bs.$text-muted;
+    }
+  }
+
+  // patch to fix https://github.com/codemirror/CodeMirror/issues/4089
+  // see also https://github.com/codemirror/CodeMirror/commit/51a1e7da60a99e019f026a118dc7c98c2b1f9d62
+  .CodeMirror-wrap > div > textarea {
+    font-size: #{bs.$line-height-base}rem;
+  }
+
+  // overwrite .CodeMirror-hints
+  .CodeMirror-hints {
+    max-height: 30em !important;
+
+    .CodeMirror-hint.crowi-emoji-autocomplete {
+      font-family: var.$font-family-monospace-not-strictly;
+      line-height: 1.6em;
+
+      .img-container {
+        display: inline-block;
+        width: 30px;
+      }
+    }
+
+    // active line
+    .CodeMirror-hint-active.crowi-emoji-autocomplete {
+      .img-container {
+        padding-top: 0.3em;
+        padding-bottom: 0.3em;
+        font-size: 15px; // adjust to .wiki
+      }
+    }
+  }
+}

+ 3 - 9
packages/app/src/components/PageEditor/DrawioModal.jsx

@@ -1,15 +1,12 @@
 import React from 'react';
 import React from 'react';
-import PropTypes from 'prop-types';
-import i18next from 'i18next';
 
 
+import i18next from 'i18next';
+import PropTypes from 'prop-types';
 import {
 import {
   Modal,
   Modal,
   ModalBody,
   ModalBody,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import { withUnstatedContainers } from '../UnstatedUtils';
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
 import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
 import { getDiagramsNetLangCode } from '~/client/util/locale-utils';
 
 
 class DrawioModal extends React.PureComponent {
 class DrawioModal extends React.PureComponent {
@@ -168,11 +165,8 @@ class DrawioModal extends React.PureComponent {
 }
 }
 
 
 DrawioModal.propTypes = {
 DrawioModal.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
   onSave: PropTypes.func,
   onSave: PropTypes.func,
 };
 };
 
 
 
 
-export default withUnstatedContainers(DrawioModal, [AppContainer, EditorContainer]);
+export default DrawioModal;

+ 1 - 8
packages/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,10 +1,8 @@
 import React, { useCallback, useState, useEffect } from 'react';
 import React, { useCallback, useState, useEffect } from 'react';
 
 
-import PropTypes from 'prop-types';
 import { Collapse, Button } from 'reactstrap';
 import { Collapse, Button } from 'reactstrap';
 
 
 
 
-import EditorContainer from '~/client/services/EditorContainer';
 import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
 import { useCurrentPagePath, useIsSlackConfigured } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import {
 import {
@@ -14,7 +12,6 @@ import {
 import SavePageControls from '../SavePageControls';
 import SavePageControls from '../SavePageControls';
 import SlackLogo from '../SlackLogo';
 import SlackLogo from '../SlackLogo';
 import { SlackNotification } from '../SlackNotification';
 import { SlackNotification } from '../SlackNotification';
-import { withUnstatedContainers } from '../UnstatedUtils';
 
 
 
 
 import OptionsSelector from './OptionsSelector';
 import OptionsSelector from './OptionsSelector';
@@ -152,8 +149,4 @@ const EditorNavbarBottom = (props) => {
   );
   );
 };
 };
 
 
-EditorNavbarBottom.propTypes = {
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-};
-
-export default withUnstatedContainers(EditorNavbarBottom, [EditorContainer]);
+export default EditorNavbarBottom;

+ 6 - 79
packages/app/src/components/PageEditor/Preview.tsx

@@ -1,18 +1,14 @@
 import React, {
 import React, {
-  useCallback, useEffect, useMemo, useState, SyntheticEvent, RefObject,
+  SyntheticEvent, RefObject,
 } from 'react';
 } from 'react';
 
 
+import ReactMarkdown from 'react-markdown';
+
 
 
-import AppContainer from '~/client/services/AppContainer';
-import InterceptorManager from '~/services/interceptor-manager';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useEditorSettings } from '~/stores/editor';
 import { useEditorSettings } from '~/stores/editor';
 
 
 import RevisionBody from '../Page/RevisionBody';
 import RevisionBody from '../Page/RevisionBody';
-import { withUnstatedContainers } from '../UnstatedUtils';
-
-
-declare const interceptorManager: InterceptorManager;
 
 
 
 
 type Props = {
 type Props = {
@@ -23,68 +19,15 @@ type Props = {
   onScroll?: (scrollTop: number) => void,
   onScroll?: (scrollTop: number) => void,
 }
 }
 
 
-type UnstatedProps = Props & { appContainer: AppContainer };
-
-const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivElement>): JSX.Element => {
+const Preview = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
 
 
   const {
   const {
     rendererOptions,
     rendererOptions,
     markdown, pagePath,
     markdown, pagePath,
   } = props;
   } = props;
 
 
-  const [html, setHtml] = useState('');
-
   const { data: editorSettings } = useEditorSettings();
   const { data: editorSettings } = useEditorSettings();
 
 
-  const context = useMemo(() => {
-    return {
-      markdown,
-      pagePath,
-      renderDrawioInRealtime: editorSettings?.renderDrawioInRealtime,
-      currentPathname: decodeURIComponent(window.location.pathname),
-      parsedHTML: null,
-    };
-  }, [markdown, pagePath, editorSettings?.renderDrawioInRealtime]);
-
-  const renderPreview = useCallback(async() => {
-
-    // TODO: use ReactMarkdown
-
-    // if (interceptorManager != null) {
-    //   await interceptorManager.process('preRenderPreview', context);
-    //   await interceptorManager.process('prePreProcess', context);
-    //   context.markdown = rendererOptions.preProcess(context.markdown, context);
-    //   await interceptorManager.process('postPreProcess', context);
-    //   context.parsedHTML = rendererOptions.process(context.markdown, context);
-    //   await interceptorManager.process('prePostProcess', context);
-    //   context.parsedHTML = rendererOptions.postProcess(context.parsedHTML, context);
-    //   await interceptorManager.process('postPostProcess', context);
-    //   await interceptorManager.process('preRenderPreviewHtml', context);
-    // }
-
-    // setHtml(context.parsedHTML ?? '');
-  }, [context, rendererOptions]);
-
-  useEffect(() => {
-    if (markdown == null) {
-      setHtml('');
-    }
-
-    renderPreview();
-  }, [markdown, renderPreview]);
-
-  useEffect(() => {
-    if (html == null) {
-      return;
-    }
-
-    if (interceptorManager != null) {
-      interceptorManager.process('postRenderPreviewHtml', {
-        ...context,
-        parsedHTML: html,
-      });
-    }
-  }, [context, html]);
 
 
   return (
   return (
     <div
     <div
@@ -96,11 +39,7 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
         }
         }
       }}
       }}
     >
     >
-      <RevisionBody
-        {...props}
-        html={html}
-        renderMathJaxInRealtime={editorSettings?.renderMathJaxInRealtime}
-      />
+      <ReactMarkdown {...rendererOptions} >{markdown || ''}</ReactMarkdown>
     </div>
     </div>
   );
   );
 
 
@@ -108,16 +47,4 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
 
 
 Preview.displayName = 'Preview';
 Preview.displayName = 'Preview';
 
 
-/**
- * Wrapper component for using unstated
- */
-const PreviewWrapper = withUnstatedContainers(Preview, [AppContainer]);
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-const PreviewWrapper2 = React.forwardRef((props: Props, ref: RefObject<HTMLDivElement>): JSX.Element => {
-  return <PreviewWrapper ref={ref} {...props} />;
-});
-
-PreviewWrapper2.displayName = 'PreviewWrapper2';
-
-export default PreviewWrapper2;
+export default Preview;

+ 5 - 7
packages/app/src/components/PageEditorByHackmd.jsx

@@ -13,7 +13,7 @@ import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
 } from '~/stores/editor';
 import {
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -456,9 +456,7 @@ const PageEditorByHackmdWrapper = (props) => {
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
-  const { data: grant } = useSelectedGrant();
-  const { data: grantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: grantData } = useSelectedGrant();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
   if (editorMode == null) {
   if (editorMode == null) {
@@ -473,9 +471,9 @@ const PageEditorByHackmdWrapper = (props) => {
       isSlackEnabled={isSlackEnabled}
       isSlackEnabled={isSlackEnabled}
       slackChannels={slackChannelsData.toString()}
       slackChannels={slackChannelsData.toString()}
       pageTags={pageTags}
       pageTags={pageTags}
-      grant={grant}
-      grantGroupId={grantGroupId}
-      grantGroupName={grantGroupName}
+      grant={grantData.grant}
+      grantGroupId={grantData.grantGroup?.id}
+      grantGroupName={grantData.grantedGroup?.name}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
     />
     />
   );
   );

+ 35 - 32
packages/app/src/components/SavePageControls.jsx

@@ -7,12 +7,12 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
-import PageContainer from '~/client/services/PageContainer';
+// import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
 import { getOptionsToSave } from '~/client/util/editor';
 import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
 import { useIsEditable, useCurrentPageId, useIsAclEnabled } from '~/stores/context';
 import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import { usePageTagsForEditors, useIsEnabledUnsavedWarning } from '~/stores/editor';
 import {
 import {
-  useEditorMode, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
+  useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -43,7 +43,7 @@ class SavePageControls extends React.Component {
 
 
   async save() {
   async save() {
     const {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, /* pageContainer, */ pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     } = this.props;
     // disable unsaved warning
     // disable unsaved warning
     mutateIsEnabledUnsavedWarning(false);
     mutateIsEnabledUnsavedWarning(false);
@@ -51,25 +51,25 @@ class SavePageControls extends React.Component {
     try {
     try {
       // save
       // save
       const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
       const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-      await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
+      // await pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
     }
     }
     catch (error) {
     catch (error) {
       logger.error('failed to save', error);
       logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
+      // pageContainer.showErrorToastr(error);
       if (error.code === 'conflict') {
       if (error.code === 'conflict') {
-        pageContainer.setState({
-          remoteRevisionId: error.data.revisionId,
-          remoteRevisionBody: error.data.revisionBody,
-          remoteRevisionUpdateAt: error.data.createdAt,
-          lastUpdateUser: error.data.user,
-        });
+        // pageContainer.setState({
+        //   remoteRevisionId: error.data.revisionId,
+        //   remoteRevisionBody: error.data.revisionBody,
+        //   remoteRevisionUpdateAt: error.data.createdAt,
+        //   lastUpdateUser: error.data.user,
+        // });
       }
       }
     }
     }
   }
   }
 
 
   saveAndOverwriteScopesOfDescendants() {
   saveAndOverwriteScopesOfDescendants() {
     const {
     const {
-      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageContainer, pageTags, mutateIsEnabledUnsavedWarning,
+      isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, /* pageContainer, */ pageTags, mutateIsEnabledUnsavedWarning,
     } = this.props;
     } = this.props;
     // disable unsaved warning
     // disable unsaved warning
     mutateIsEnabledUnsavedWarning(false);
     mutateIsEnabledUnsavedWarning(false);
@@ -78,18 +78,18 @@ class SavePageControls extends React.Component {
     const optionsToSave = Object.assign(currentOptionsToSave, {
     const optionsToSave = Object.assign(currentOptionsToSave, {
       overwriteScopesOfDescendants: true,
       overwriteScopesOfDescendants: true,
     });
     });
-    pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
+    // pageContainer.saveAndReload(optionsToSave, this.props.editorMode);
   }
   }
 
 
   render() {
   render() {
 
 
     const {
     const {
-      t, pageContainer, isAclEnabled, grant, grantGroupId, grantGroupName,
+      t, /* pageContainer, */ isAclEnabled, grant, grantGroupId, grantGroupName,
     } = this.props;
     } = this.props;
 
 
-    const isRootPage = pageContainer.state.path === '/';
-    const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
-    const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
+    // const isRootPage = pageContainer.state.path === '/';
+    // const labelSubmitButton = pageContainer.state.pageId == null ? t('Create') : t('Update');
+    // const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
 
 
     return (
     return (
       <div className="d-flex align-items-center form-inline flex-nowrap">
       <div className="d-flex align-items-center form-inline flex-nowrap">
@@ -98,7 +98,7 @@ class SavePageControls extends React.Component {
           && (
           && (
             <div className="mr-2">
             <div className="mr-2">
               <GrantSelector
               <GrantSelector
-                disabled={isRootPage}
+                // disabled={isRootPage}
                 grant={grant}
                 grant={grant}
                 grantGroupId={grantGroupId}
                 grantGroupId={grantGroupId}
                 grantGroupName={grantGroupName}
                 grantGroupName={grantGroupName}
@@ -109,11 +109,15 @@ class SavePageControls extends React.Component {
         }
         }
 
 
         <UncontrolledButtonDropdown direction="up">
         <UncontrolledButtonDropdown direction="up">
-          <Button id="caret" color="primary" className="btn-submit" onClick={this.save}>{labelSubmitButton}</Button>
+          <Button id="caret" color="primary" className="btn-submit" onClick={this.save}>
+          labelSubmitButton
+            {/* {labelSubmitButton} */}
+          </Button>
           <DropdownToggle caret color="primary" />
           <DropdownToggle caret color="primary" />
           <DropdownMenu right>
           <DropdownMenu right>
             <DropdownItem onClick={this.saveAndOverwriteScopesOfDescendants}>
             <DropdownItem onClick={this.saveAndOverwriteScopesOfDescendants}>
-              {labelOverwriteScopes}
+            labelOverwriteScopes
+              {/* {labelOverwriteScopes} */}
             </DropdownItem>
             </DropdownItem>
           </DropdownMenu>
           </DropdownMenu>
         </UncontrolledButtonDropdown>
         </UncontrolledButtonDropdown>
@@ -127,22 +131,20 @@ class SavePageControls extends React.Component {
 /**
 /**
  * Wrapper component for using unstated
  * Wrapper component for using unstated
  */
  */
-const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
+// const SavePageControlsHOCWrapper = withUnstatedContainers(SavePageControls, [PageContainer]);
 
 
 const SavePageControlsWrapper = (props) => {
 const SavePageControlsWrapper = (props) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { data: isEditable } = useIsEditable();
   const { data: isEditable } = useIsEditable();
   const { data: editorMode } = useEditorMode();
   const { data: editorMode } = useEditorMode();
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grant, mutate: mutateGrant } = useSelectedGrant();
-  const { data: grantGroupId, mutate: mutateGrantGroupId } = useSelectedGrantGroupId();
-  const { data: grantGroupName, mutate: mutateGrantGroupName } = useSelectedGrantGroupName();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: pageId } = useCurrentPageId();
   const { data: pageId } = useCurrentPageId();
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { data: pageTags } = usePageTagsForEditors(pageId);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
 
 
 
-  if (isEditable == null || editorMode == null || isAclEnabled == null) {
+  if (isEditable == null || editorMode == null || isAclEnabled == null || grantData == null) {
     return null;
     return null;
   }
   }
 
 
@@ -151,17 +153,18 @@ const SavePageControlsWrapper = (props) => {
   }
   }
 
 
   return (
   return (
-    <SavePageControlsHOCWrapper
+    // <SavePageControlsHOCWrapper
+    <SavePageControls
       t={t}
       t={t}
       {...props}
       {...props}
       editorMode={editorMode}
       editorMode={editorMode}
       isAclEnabled={isAclEnabled}
       isAclEnabled={isAclEnabled}
-      grant={grant}
-      grantGroupId={grantGroupId}
-      grantGroupName={grantGroupName}
+      grant={grantData.grant}
+      grantGroupId={grantData.grantGroup?.id}
+      grantGroupName={grantData.grantedGroup?.name}
       mutateGrant={mutateGrant}
       mutateGrant={mutateGrant}
-      mutateGrantGroupId={mutateGrantGroupId}
-      mutateGrantGroupName={mutateGrantGroupName}
+      // mutateGrantGroupId={mutateGrantGroupId}
+      // mutateGrantGroupName={mutateGrantGroupName}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
       pageTags={pageTags}
       pageTags={pageTags}
     />
     />
@@ -171,7 +174,7 @@ const SavePageControlsWrapper = (props) => {
 SavePageControls.propTypes = {
 SavePageControls.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
 
 
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+  // pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
 
 
   // TODO: remove this when omitting unstated is completed
   // TODO: remove this when omitting unstated is completed
   editorMode: PropTypes.string.isRequired,
   editorMode: PropTypes.string.isRequired,

+ 2 - 2
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -24,7 +24,7 @@ import { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { GrowiSubNavigation } from '../Navbar/GrowiSubNavigation';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import { SubNavButtons } from '../Navbar/SubNavButtons';
 import RevisionLoader from '../Page/RevisionLoader';
 import RevisionLoader from '../Page/RevisionLoader';
-import PageComment from '../PageComment';
+import { PageComment } from '../PageComment';
 import PageContentFooter from '../PageContentFooter';
 import PageContentFooter from '../PageContentFooter';
 
 
 
 
@@ -214,7 +214,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           revisionId={page.revision}
           revisionId={page.revision}
           highlightKeywords={highlightKeywords}
           highlightKeywords={highlightKeywords}
         />
         />
-        <PageComment appContainer={appContainer} pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
+        <PageComment pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
         <PageContentFooter
         <PageContentFooter
           createdAt={new Date(pageWithMeta.data.createdAt)}
           createdAt={new Date(pageWithMeta.data.createdAt)}
           updatedAt={new Date(pageWithMeta.data.updatedAt)}
           updatedAt={new Date(pageWithMeta.data.updatedAt)}

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

@@ -12,10 +12,14 @@ export const Skelton = (props: SkeltonProps): JSX.Element => {
     width, height, additionalClass, roundedPill,
     width, height, additionalClass, roundedPill,
   } = props;
   } = props;
 
 
-  const style = {
+  const skeltonStyle = {
     width,
     width,
     height,
     height,
   };
   };
 
 
-  return <div style={style} className={`grw-skelton ${additionalClass} ${roundedPill ? 'rounded-pill' : ''}`}></div>;
+  return (
+    <div style={skeltonStyle} className={`${additionalClass}`}>
+      <div className={`grw-skelton h-100 w-100 ${roundedPill ? 'rounded-pill' : ''}`}></div>
+    </div>
+  );
 };
 };

+ 0 - 59
packages/app/src/components/TagPage.tsx

@@ -1,59 +0,0 @@
-import React, { FC, useState, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
-
-import { IDataTagCount } from '~/interfaces/tag';
-import { useSWRxTagsList } from '~/stores/tag';
-
-import TagCloudBox from './TagCloudBox';
-import TagList from './TagList';
-
-const PAGING_LIMIT = 10;
-
-const TagPage: FC = () => {
-  const [activePage, setActivePage] = useState<number>(1);
-  const [offset, setOffset] = useState<number>(0);
-
-  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
-  const tagData: IDataTagCount[] = tagDataList?.data || [];
-  const totalCount: number = tagDataList?.totalCount || 0;
-  const isLoading = tagDataList === undefined && error == null;
-
-  const { t } = useTranslation('');
-
-  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
-    setActivePage(selectedPageNumber);
-    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
-  }, []);
-
-  // todo: adjust margin and redesign tags page
-  return (
-    <div className="grw-container-convertible mb-5 pb-5">
-      <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
-      <div className="px-3 mb-5 text-center">
-        <TagCloudBox tags={tagData} minSize={20} />
-      </div>
-      { isLoading
-        ? (
-          <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
-          </div>
-        )
-        : (
-          <div data-testid="grw-tags-list">
-            <TagList
-              tagData={tagData}
-              totalTags={totalCount}
-              activePage={activePage}
-              onChangePage={setOffsetByPageNumber}
-              pagingLimit={PAGING_LIMIT}
-            />
-          </div>
-        )
-      }
-    </div>
-  );
-
-};
-
-export default TagPage;

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

@@ -19,7 +19,7 @@ interface UncontrolledCodeMirrorCoreProps extends UncontrolledCodeMirrorProps {
   forwardedRef: Ref<UncontrolledCodeMirrorCore>;
   forwardedRef: Ref<UncontrolledCodeMirrorCore>;
 }
 }
 
 
-class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
+export class UncontrolledCodeMirrorCore extends AbstractEditor<UncontrolledCodeMirrorCoreProps> {
 
 
   override render(): ReactNode {
   override render(): ReactNode {
 
 

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

@@ -4,6 +4,7 @@ import React from 'react';
 
 
 import { Provider, Subscribe } from 'unstated';
 import { Provider, Subscribe } from 'unstated';
 
 
+
 /**
 /**
  * generate K/V object by specified instances
  * generate K/V object by specified instances
  *
  *

+ 21 - 0
packages/app/src/interfaces/customize.ts

@@ -0,0 +1,21 @@
+const IHighlightJsCssSelectorThemes = {
+  GITHUB: 'github',
+  GITHUB_GIST: 'github-gist',
+  ATOM_ONE_LIGHT: 'atom-one-light',
+  XCIDE: 'xcode',
+  VS: 'vs',
+  ATOM_ONE_DARK: 'atom-one-dark',
+  HYBRID: 'hybrid',
+  MONOKAI: 'monokai',
+  TOMMORROW_NIGHT: 'tomorrow-night',
+  VS_2015: 'vs2015',
+} as const;
+
+type IHighlightJsCssSelectorThemes = typeof IHighlightJsCssSelectorThemes[keyof typeof IHighlightJsCssSelectorThemes];
+
+export type IHighlightJsCssSelectorOptions = {
+  [theme in IHighlightJsCssSelectorThemes]: {
+    name: string,
+    border: boolean
+  }
+}

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

@@ -7,6 +7,7 @@ import {
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, isClient, isIPageInfoForEntity, isServer, IUser, IUserHasId, pagePathUtils, pathUtils,
   IDataWithMeta, IPageInfoForEntity, IPagePopulatedToShowRevision, isClient, isIPageInfoForEntity, isServer, IUser, IUserHasId, pagePathUtils, pathUtils,
 } from '@growi/core';
 } from '@growi/core';
 import ExtensibleCustomError from 'extensible-custom-error';
 import ExtensibleCustomError from 'extensible-custom-error';
+import mongoose from 'mongoose';
 import {
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
@@ -17,8 +18,9 @@ import { useRouter } from 'next/router';
 import superjson from 'superjson';
 import superjson from 'superjson';
 
 
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
-// import { PageComments } from '~/components/PageComment/PageComments';
+import { PageComment } from '~/components/PageComment';
 // import { useTranslation } from '~/i18n';
 // import { useTranslation } from '~/i18n';
+import CommentEditorLazyRenderer from '~/components/PageComment/CommentEditorLazyRenderer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
 // import { useIndentSize } from '~/stores/editor';
@@ -29,11 +31,12 @@ import { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { PageModel, PageDocument } from '~/server/models/page';
 import { PageModel, PageDocument } from '~/server/models/page';
+import { PageRedirectModel } from '~/server/models/page-redirect';
 import UserUISettings from '~/server/models/user-ui-settings';
 import UserUISettings from '~/server/models/user-ui-settings';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
-import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxCurrentPage, useSWRxIsGrantNormalized, useSWRxPageInfo } from '~/stores/page';
 import {
 import {
-  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth, useSelectedGrant,
 } from '~/stores/ui';
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -121,8 +124,7 @@ type Props = CommonProps & {
 
 
   pageWithMeta: IPageToShowRevisionWithMeta,
   pageWithMeta: IPageToShowRevisionWithMeta,
   // pageUser?: any,
   // pageUser?: any,
-  // redirectTo?: string;
-  // redirectFrom?: string;
+  redirectFrom?: string;
 
 
   // shareLinkId?: string;
   // shareLinkId?: string;
   isLatestRevision?: boolean
   isLatestRevision?: boolean
@@ -147,7 +149,6 @@ type Props = CommonProps & {
   // noCdn: string,
   // noCdn: string,
   // highlightJsStyle: string,
   // highlightJsStyle: string,
   // isAllReplyShown: boolean,
   // isAllReplyShown: boolean,
-  // isContainerFluid: boolean,
   // editorConfig: any,
   // editorConfig: any,
   isEnabledStaleNotification: boolean,
   isEnabledStaleNotification: boolean,
   // isEnabledLinebreaks: boolean,
   // isEnabledLinebreaks: boolean,
@@ -225,7 +226,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
 
 
-
   // const { data: editorMode } = useEditorMode();
   // const { data: editorMode } = useEditorMode();
 
 
   const { pageWithMeta, userUISettings } = props;
   const { pageWithMeta, userUISettings } = props;
@@ -235,16 +235,24 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
     shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
     shouldRenderPutbackPageModal = _isTrashPage(pageWithMeta.data.path);
   }
   }
 
 
-  useCurrentPageId(pageWithMeta?.data._id);
+  const pageId = pageWithMeta?.data._id;
+
+  useCurrentPageId(pageId);
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
   useSWRxCurrentPage(undefined, pageWithMeta?.data); // store initial data
-  // useSWRxPage(pageWithMeta?.data._id);
-  useSWRxPageInfo(pageWithMeta?.data._id, undefined, pageWithMeta?.meta); // store initial data
+  useSWRxPageInfo(pageId, undefined, pageWithMeta?.meta); // store initial data
   useIsTrashPage(_isTrashPage(pageWithMeta?.data.path ?? ''));
   useIsTrashPage(_isTrashPage(pageWithMeta?.data.path ?? ''));
   useIsUserPage(isUserPage(pageWithMeta?.data.path ?? ''));
   useIsUserPage(isUserPage(pageWithMeta?.data.path ?? ''));
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pageWithMeta?.data.path ?? '')); // TODO: need to include props.isIdentical
   useIsNotCreatable(props.isForbidden || !isCreatablePage(pageWithMeta?.data.path ?? '')); // TODO: need to include props.isIdentical
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPagePath(pageWithMeta?.data.path);
   useCurrentPathname(props.currentPathname);
   useCurrentPathname(props.currentPathname);
-  useEditingMarkdown(pageWithMeta?.data.revision?.body);
+  useEditingMarkdown(pageWithMeta?.data.revision.body);
+  const { data: grantData } = useSWRxIsGrantNormalized(pageId);
+  const { mutate: mutateSelectedGrant } = useSelectedGrant();
+
+  // sync grant data
+  useEffect(() => {
+    mutateSelectedGrant(grantData?.grantData.currentPageGrant);
+  }, [grantData?.grantData.currentPageGrant, mutateSelectedGrant]);
 
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
   useEffect(() => {
@@ -263,9 +271,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   //     classNames.push('on-edit', 'hackmd');
   //     classNames.push('on-edit', 'hackmd');
   //     break;
   //     break;
   // }
   // }
-  // if (props.isContainerFluid) {
-  //   classNames.push('growi-layout-fluid');
-  // }
   // if (page == null) {
   // if (page == null) {
   //   classNames.push('not-found-page');
   //   classNames.push('not-found-page');
   // }
   // }
@@ -281,7 +286,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
         */}
         */}
       </Head>
       </Head>
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
       {/* <BasicLayout title={useCustomTitle(props, t('GROWI'))} className={classNames.join(' ')}> */}
-      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')} expandContainer={props.isContainerFluid}>
         <header className="py-0">
         <header className="py-0">
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
           <GrowiContextualSubNavigation isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
         </header>
@@ -307,7 +312,6 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
                     {/* <DisplaySwitcher /> */}
                     {/* <DisplaySwitcher /> */}
                     <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                     <div id="page-editor-navbar-bottom-container" className="d-none d-edit-block"></div>
                     {/* <PageStatusAlert /> */}
                     {/* <PageStatusAlert /> */}
-                    PageStatusAlert
                   </>
                   </>
                 ) }
                 ) }
 
 
@@ -322,8 +326,8 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
           </div>
         </div>
         </div>
         <footer>
         <footer>
-          {/* <PageComments /> */}
-          PageComments
+          <PageComment pageId={useCurrentPageId().data} isReadOnly={false} titleAlign="left" />
+          {/* <CommentEditorLazyRenderer pageId={useCurrentPageId().data} /> */}
         </footer>
         </footer>
 
 
         <UnsavedAlertDialog />
         <UnsavedAlertDialog />
@@ -356,17 +360,28 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   const { revisionId } = req.query;
   const { revisionId } = req.query;
 
 
   const Page = crowi.model('Page') as PageModel;
   const Page = crowi.model('Page') as PageModel;
+  const PageRedirect = mongoose.model('PageRedirect') as PageRedirectModel;
   const { pageService } = crowi;
   const { pageService } = crowi;
 
 
-  const { currentPathname } = props;
+  let currentPathname = props.currentPathname;
 
 
   const pageId = getPageIdFromPathname(currentPathname);
   const pageId = getPageIdFromPathname(currentPathname);
   const isPermalink = _isPermalink(currentPathname);
   const isPermalink = _isPermalink(currentPathname);
 
 
   const { user } = req;
   const { user } = req;
 
 
-  // check whether the specified page path hits to multiple pages
   if (!isPermalink) {
   if (!isPermalink) {
+    // check redirects
+    const chains = await PageRedirect.retrievePageRedirectEndpoints(currentPathname);
+    if (chains != null) {
+      // overwrite currentPathname
+      currentPathname = chains.end.toPath;
+      props.currentPathname = currentPathname;
+      // set redirectFrom
+      props.redirectFrom = chains.start.fromPath;
+    }
+
+    // check whether the specified page path hits to multiple pages
     const count = await Page.countByPathAndViewer(currentPathname, user, null, true);
     const count = await Page.countByPathAndViewer(currentPathname, user, null, true);
     if (count > 1) {
     if (count > 1) {
       throw new MultiplePagesHitsError(currentPathname);
       throw new MultiplePagesHitsError(currentPathname);
@@ -470,7 +485,6 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
   // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
   // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
-  // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');

+ 98 - 7
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import React from 'react';
 
 
+import { isClient } from '@growi/core';
 import {
 import {
   NextPage, GetServerSideProps, GetServerSidePropsContext,
   NextPage, GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
 } from 'next';
@@ -7,17 +8,39 @@ import { useTranslation } from 'next-i18next';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
-
+import { Container, Provider } from 'unstated';
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import AdminBasicSecurityContainer from '~/client/services/AdminBasicSecurityContainer';
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import AdminExternalAccountsContainer from '~/client/services/AdminExternalAccountsContainer';
+import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
+import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
+import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
+import AdminHomeContainer from '~/client/services/AdminHomeContainer';
+import AdminImportContainer from '~/client/services/AdminImportContainer';
+import AdminLdapSecurityContainer from '~/client/services/AdminLdapSecurityContainer';
+import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
+import AdminMarkDownContainer from '~/client/services/AdminMarkDownContainer';
+import AdminNotificationContainer from '~/client/services/AdminNotificationContainer';
+import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
+import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
+import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
+import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
+import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import PluginUtils from '~/server/plugins/plugin-utils';
 import PluginUtils from '~/server/plugins/plugin-utils';
 import ConfigLoader from '~/server/service/config-loader';
 import ConfigLoader from '~/server/service/config-loader';
 import {
 import {
-  useCurrentUser, /* useSearchServiceConfigured, */ useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
+  useCurrentUser, /* useSearchServiceConfigured, */ useIsAclEnabled, useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
 } from '~/stores/context';
 } from '~/stores/context';
 
 
 import {
 import {
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
   CommonProps, getServerSideCommonProps, useCustomTitle, getNextI18NextConfig,
 } from '../utils/commons';
 } from '../utils/commons';
+
+
 // import { useEnvVars } from '~/stores/admin-context';
 // import { useEnvVars } from '~/stores/admin-context';
 
 
 const AdminHome = dynamic(() => import('../../components/Admin/AdminHome/AdminHome'), { ssr: false });
 const AdminHome = dynamic(() => import('../../components/Admin/AdminHome/AdminHome'), { ssr: false });
@@ -36,6 +59,7 @@ const ElasticsearchManagement = dynamic(() => import('../../components/Admin/Ela
 // named export
 // named export
 const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(module => module.AuditLogManagement));
 const AuditLogManagement = dynamic(() => import('../../components/Admin/AuditLogManagement').then(module => module.AuditLogManagement));
 
 
+
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
 const AdminLayout = dynamic(() => import('../../components/Layout/AdminLayout'), { ssr: false });
 
 
 const pluginUtils = new PluginUtils();
 const pluginUtils = new PluginUtils();
@@ -48,7 +72,7 @@ type Props = CommonProps & {
   yarnVersion: string,
   yarnVersion: string,
   installedPlugins: any,
   installedPlugins: any,
   envVars: any,
   envVars: any,
-
+  isAclEnabled: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isMailerSetup: boolean,
   isMailerSetup: boolean,
@@ -141,14 +165,80 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   // useSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
 
 
+  useIsAclEnabled(props.isAclEnabled);
   useSiteUrl(props.siteUrl);
   useSiteUrl(props.siteUrl);
 
 
   // useEnvVars(props.envVars);
   // useEnvVars(props.envVars);
 
 
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    // Create unstated container instances (except Security)
+    const adminAppContainer = new AdminAppContainer();
+    const adminImportContainer = new AdminImportContainer();
+    const adminHomeContainer = new AdminHomeContainer();
+    const adminCustomizeContainer = new AdminCustomizeContainer();
+    const adminUsersContainer = new AdminUsersContainer();
+    const adminExternalAccountsContainer = new AdminExternalAccountsContainer();
+    const adminNotificationContainer = new AdminNotificationContainer();
+    const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
+    const adminMarkDownContainer = new AdminMarkDownContainer();
+    const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer();
+
+    injectableContainers.push(
+      adminAppContainer,
+      adminImportContainer,
+      adminHomeContainer,
+      adminCustomizeContainer,
+      adminUsersContainer,
+      adminExternalAccountsContainer,
+      adminNotificationContainer,
+      adminSlackIntegrationLegacyContainer,
+      adminMarkDownContainer,
+      adminUserGroupDetailContainer,
+    );
+  }
+
+
+  const adminSecurityContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminSecuritySettingElem = document.getElementById('admin-security-setting');
+
+    if (adminSecuritySettingElem != null) {
+      // Create unstated container instances (Security)
+      const adminGeneralSecurityContainer = new AdminGeneralSecurityContainer();
+      const adminLocalSecurityContainer = new AdminLocalSecurityContainer();
+      const adminLdapSecurityContainer = new AdminLdapSecurityContainer();
+      const adminSamlSecurityContainer = new AdminSamlSecurityContainer();
+      const adminOidcSecurityContainer = new AdminOidcSecurityContainer();
+      const adminBasicSecurityContainer = new AdminBasicSecurityContainer();
+      const adminGoogleSecurityContainer = new AdminGoogleSecurityContainer();
+      const adminGitHubSecurityContainer = new AdminGitHubSecurityContainer();
+      const adminTwitterSecurityContainer = new AdminTwitterSecurityContainer();
+
+      adminSecurityContainers.push(
+        adminGeneralSecurityContainer,
+        adminLocalSecurityContainer,
+        adminLdapSecurityContainer,
+        adminSamlSecurityContainer,
+        adminOidcSecurityContainer,
+        adminBasicSecurityContainer,
+        adminGoogleSecurityContainer,
+        adminGitHubSecurityContainer,
+        adminTwitterSecurityContainer,
+      );
+    }
+
+  }
+
+
   return (
   return (
-    <AdminLayout title={title} selectedNavOpt={name}>
-      {content.component}
-    </AdminLayout>
+    <Provider inject={[...injectableContainers, ...adminSecurityContainers]}>
+      <AdminLayout title={title} selectedNavOpt={name}>
+        {content.component}
+      </AdminLayout>
+    </Provider>
   );
   );
 };
 };
 
 
@@ -176,7 +266,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const req: CrowiRequest = context.req as CrowiRequest;
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
   const { crowi } = req;
   const {
   const {
-    appService, searchService,
+    appService, searchService, aclService,
   } = crowi;
   } = crowi;
 
 
   const { user } = req;
   const { user } = req;
@@ -202,6 +292,7 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
   props.installedPlugins = pluginUtils.listPlugins();
   props.installedPlugins = pluginUtils.listPlugins();
   props.envVars = await ConfigLoader.getEnvVarsForDisplay(true);
   props.envVars = await ConfigLoader.getEnvVarsForDisplay(true);
+  props.isAclEnabled = aclService.isAclEnabled();
 
 
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchServiceReachable = searchService.isReachable;

+ 20 - 9
packages/app/src/pages/installer.page.tsx

@@ -48,18 +48,29 @@ const InstallerPage: NextPage<Props> = (props: Props) => {
   return (
   return (
     <>
     <>
       <RawLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
       <RawLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
-        <div id="page-wrapper">
-          <div className="main container-fluid">
+        <div className='nologin'>
+          <div id="wrapper">
+            <div id="page-wrapper">
+              <div className="main container-fluid">
+
+                <div className="row">
+
+                  <div className="col-md-12">
+                    <div className="login-header mx-auto">
+                      <div className="logo"></div>
+                      <h1 className="my-3">GROWI</h1>
+                      <div className="login-form-errors px-3"></div>
+                    </div>
+                  </div>
+
+                  <div className="col-md-12">
+                    <div id="installer-form-container">
+                      <InstallerForm />
+                    </div>
+                  </div>
 
 
-            <div className="row">
-              <div className="col-md-12">
-                <div className="login-header mx-auto">
-                  <h1 className="my-3">GROWI</h1>
                 </div>
                 </div>
               </div>
               </div>
-              <div className="col-md-12">
-                <InstallerForm />
-              </div>
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 137 - 0
packages/app/src/pages/tags.page.tsx

@@ -0,0 +1,137 @@
+import React, { useState, useCallback } from 'react';
+
+import {
+  IUser, IUserHasId,
+} from '@growi/core';
+import { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next';
+import { useTranslation } from 'next-i18next';
+import Head from 'next/head';
+
+import TagCloudBox from '~/components/TagCloudBox';
+import TagList from '~/components/TagList';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { IDataTagCount } from '~/interfaces/tag';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import UserUISettings from '~/server/models/user-ui-settings';
+import { useSWRxTagsList } from '~/stores/tag';
+
+import { BasicLayout } from '../components/Layout/BasicLayout';
+import {
+  useCurrentUser,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable,
+  useIsSearchScopeChildrenAsDefault,
+} from '../stores/context';
+
+import {
+  CommonProps, getServerSideCommonProps, useCustomTitle,
+} from './utils/commons';
+
+const PAGING_LIMIT = 10;
+
+type Props = CommonProps & {
+  currentUser: IUser,
+  isSearchServiceConfigured: boolean,
+  isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+  userUISettings?: IUserUISettings
+};
+
+const TagPage: NextPage<CommonProps> = (props: Props) => {
+  const [activePage, setActivePage] = useState<number>(1);
+  const [offset, setOffset] = useState<number>(0);
+
+  useCurrentUser(props.currentUser ?? null);
+  const { data: tagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const { t } = useTranslation('');
+  const setOffsetByPageNumber = useCallback((selectedPageNumber: number) => {
+    setActivePage(selectedPageNumber);
+    setOffset((selectedPageNumber - 1) * PAGING_LIMIT);
+  }, []);
+
+  const tagData: IDataTagCount[] = tagDataList?.data || [];
+  const totalCount: number = tagDataList?.totalCount || 0;
+  const isLoading = tagDataList === undefined && error == null;
+  const classNames: string[] = [];
+
+  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  return (
+    <>
+      <Head>
+      </Head>
+      <BasicLayout title={useCustomTitle(props, 'GROWI')} className={classNames.join(' ')}>
+        <div className="grw-container-convertible mb-5 pb-5">
+          <h2 className="my-3">{`${t('Tags')}(${totalCount})`}</h2>
+          <div className="px-3 mb-5 text-center">
+            <TagCloudBox tags={tagData} minSize={20} />
+          </div>
+          { isLoading
+            ? (
+              <div className="text-muted text-center">
+                <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+              </div>
+            )
+            : (
+              <div data-testid="grw-tags-list">
+                <TagList
+                  tagData={tagData}
+                  totalTags={totalCount}
+                  activePage={activePage}
+                  onChangePage={setOffsetByPageNumber}
+                  pagingLimit={PAGING_LIMIT}
+                />
+              </div>
+            )
+          }
+        </div>
+      </BasicLayout>
+    </>
+  );
+};
+
+async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
+
+  if (userUISettings != null) {
+    props.userUISettings = userUISettings.toObject();
+  }
+}
+
+function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): void {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    searchService, configManager,
+  } = crowi;
+
+  props.isSearchServiceConfigured = searchService.isConfigured;
+  props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+  const result = await getServerSideCommonProps(context);
+
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+  await injectUserUISettings(context, props);
+  injectServerConfigurations(context, props);
+
+  return {
+    props,
+  };
+};
+
+export default TagPage;

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

@@ -17,6 +17,7 @@ export type CommonProps = {
   theme: GrowiThemes,
   theme: GrowiThemes,
   customTitleTemplate: string,
   customTitleTemplate: string,
   csrfToken: string,
   csrfToken: string,
+  isContainerFluid: boolean,
   growiVersion: string,
   growiVersion: string,
 } & Partial<SSRConfig>;
 } & Partial<SSRConfig>;
 
 
@@ -41,6 +42,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     theme: configManager.getConfig('crowi', 'customize:theme'),
     theme: configManager.getConfig('crowi', 'customize:theme'),
     customTitleTemplate: customizeService.customTitleTemplate,
     customTitleTemplate: customizeService.customTitleTemplate,
     csrfToken: req.csrfToken(),
     csrfToken: req.csrfToken(),
+    isContainerFluid: configManager.getConfig('crowi', 'customize:isContainerFluid') ?? false,
     growiVersion: crowi.version,
     growiVersion: crowi.version,
   };
   };
 
 

+ 133 - 4
packages/app/src/server/models/page-redirect.ts

@@ -4,19 +4,37 @@ import {
   Schema, Model, Document,
   Schema, Model, Document,
 } from 'mongoose';
 } from 'mongoose';
 
 
+import loggerFactory from '~/utils/logger';
+
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-export interface IPageRedirect {
+
+const logger = loggerFactory('growi:models:page-redirects');
+
+
+export type IPageRedirect = {
   fromPath: string,
   fromPath: string,
   toPath: string,
   toPath: string,
 }
 }
 
 
+export type IPageRedirectEndpoints = {
+  start: IPageRedirect,
+  end: IPageRedirect,
+}
+
 export interface PageRedirectDocument extends IPageRedirect, Document {}
 export interface PageRedirectDocument extends IPageRedirect, Document {}
 
 
 export interface PageRedirectModel extends Model<PageRedirectDocument> {
 export interface PageRedirectModel extends Model<PageRedirectDocument> {
-  [x:string]: any // TODO: improve type
+  retrievePageRedirectEndpoints(fromPath: string): Promise<IPageRedirectEndpoints>
+  removePageRedirectsByToPath(toPath: string): Promise<void>
 }
 }
 
 
+const CHAINS_FIELD_NAME = 'chains';
+const DEPTH_FIELD_NAME = 'depth';
+type IPageRedirectWithChains = PageRedirectDocument & {
+  [CHAINS_FIELD_NAME]: (PageRedirectDocument & { [DEPTH_FIELD_NAME]: number })[]
+};
+
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
 const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   fromPath: {
   fromPath: {
     type: String, required: true, unique: true, index: true,
     type: String, required: true, unique: true, index: true,
@@ -24,9 +42,120 @@ const schema = new Schema<PageRedirectDocument, PageRedirectModel>({
   toPath: { type: String, required: true },
   toPath: { type: String, required: true },
 });
 });
 
 
-schema.statics.removePageRedirectByToPath = async function(toPath: string): Promise<void> {
-  await this.deleteMany({ toPath });
+schema.statics.retrievePageRedirectEndpoints = async function(fromPath: string): Promise<IPageRedirectEndpoints|null> {
+  const aggResult: IPageRedirectWithChains[] = await this.aggregate([
+    { $match: { fromPath } },
+    {
+      $graphLookup: {
+        from: 'pageredirects',
+        startWith: '$toPath',
+        connectFromField: 'toPath',
+        connectToField: 'fromPath',
+        as: CHAINS_FIELD_NAME,
+        depthField: DEPTH_FIELD_NAME,
+      },
+    },
+  ]);
+  /* ---------- aggResult example ----------
+  {
+    "_id" : ObjectId("62e5650d6134d37aa0935e6d"),
+    "fromPath" : "/page1",
+    "toPath" : "/page2",
+    "chains" : [
+        {
+            "_id" : ObjectId("62e5651b6134d37aa0935e7a"),
+            "fromPath" : "/page2",
+            "toPath" : "/page3",
+            "depth" : NumberLong(0)
+        },
+        {
+            "_id" : ObjectId("62e565256134d37aa0935e80"),
+            "fromPath" : "/page3",
+            "toPath" : "/Sandbox",
+            "depth" : NumberLong(1)
+        }
+    ]
+  }
+  */
+
+  if (aggResult.length === 0) {
+    return null;
+  }
+
+  if (aggResult.length > 1) {
+    logger.warn(`Although two or more PageRedirect documents starts from '${fromPath}' exists, The first one is used.`);
+  }
+
+  const redirectWithChains = aggResult[0];
+
+  // sort chains in desc
+  const sortedChains = redirectWithChains[CHAINS_FIELD_NAME].sort((a, b) => b[DEPTH_FIELD_NAME] - a[DEPTH_FIELD_NAME]);
+
+  const start = { fromPath: redirectWithChains.fromPath, toPath: redirectWithChains.toPath };
+  const end = sortedChains.length === 0
+    ? start
+    : sortedChains[0];
+
+  return { start, end };
+};
+
+schema.statics.removePageRedirectsByToPath = async function(toPath: string): Promise<void> {
+  const aggResult: IPageRedirectWithChains[] = await this.aggregate([
+    { $match: { toPath } },
+    {
+      $graphLookup: {
+        from: 'pageredirects',
+        startWith: '$fromPath',
+        connectFromField: 'fromPath',
+        connectToField: 'toPath',
+        as: CHAINS_FIELD_NAME,
+      },
+    },
+  ]);
+  /* ---------- aggResult example ----------
+  // 1
+  {
+    "_id" : ObjectId("62e565256134d37aa0935e80"),
+    "fromPath" : "/page3",
+    "toPath" : "/page4",
+    "chains" : [
+        {
+            "_id" : ObjectId("62e5651b6134d37aa0935e7a"),
+            "fromPath" : "/page2",
+            "toPath" : "/page3",
+            "depth" : NumberLong(0)
+        },
+        {
+            "_id" : ObjectId("62e5650d6134d37aa0935e6d"),
+            "fromPath" : "/page1",
+            "toPath" : "/page2",
+            "depth" : NumberLong(1)
+        }
+    ]
+  }
+  // 2
+  {
+    "_id" : ObjectId("62e5937a6134d37aa0936405"),
+    "fromPath" : "/org/page4",
+    "toPath" : "/page4",
+    "chains" : []
+  }
+  */
+
+  if (aggResult.length === 0) {
+    return;
+  }
+
+  const idsToRemove = aggResult
+    .map((redirectWithChains) => {
+      return [
+        redirectWithChains._id,
+        redirectWithChains[CHAINS_FIELD_NAME].map(doc => doc._id),
+      ].flat();
+    })
+    .flat();
 
 
+  await this.deleteMany({ _id: { $in: idsToRemove } });
   return;
   return;
 };
 };
 
 

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

@@ -204,7 +204,8 @@ module.exports = function(crowi, app) {
 
 
   app.use(unavailableWhenMaintenanceMode);
   app.use(unavailableWhenMaintenanceMode);
 
 
-  app.get('/tags'                     , loginRequired, tag.showPage);
+  // app.get('/tags'                     , loginRequired, tag.showPage);
+  app.get('/tags', loginRequired, next.delegateToNext);
 
 
   app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   app.get('/me'                                 , loginRequiredStrictly, injectUserUISettings, me.index);
   // external-accounts
   // external-accounts

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

@@ -1476,7 +1476,7 @@ module.exports = function(crowi, app) {
     const path = req.body.path;
     const path = req.body.path;
 
 
     try {
     try {
-      await PageRedirect.removePageRedirectByToPath(path);
+      await PageRedirect.removePageRedirectsByToPath(path);
       logger.debug('Redirect Page deleted', path);
       logger.debug('Redirect Page deleted', path);
     }
     }
     catch (err) {
     catch (err) {

+ 1 - 11
packages/app/src/server/views/admin/customize.html

@@ -1,16 +1,6 @@
 {% extends '../layout/admin.html' %}
 {% extends '../layout/admin.html' %}
-{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('Customize')) }}{% endblock %}
 
 
-{% block html_additional_headers %}
-{% parent %}
-<!-- CodeMirror -->
-{{ cdnStyleTag('jquery-ui') }}
-<style>
-  .CodeMirror {
-    border: 1px solid $gray-100;
-  }
-</style>
-{% endblock %}
+{% block html_title %}{{ customizeService.generateCustomTitleForFixedPageName(t('Customize')) }}{% endblock %}
 
 
 {% block content_header %}
 {% block content_header %}
 <h1 class="title">{{ t('Customize') }}</h1>
 <h1 class="title">{{ t('Customize') }}</h1>

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

@@ -340,6 +340,15 @@ export const generatePreviewOptions: ReactMarkdownOptionsGenerator = (config: Re
 
 
 export const generateCommentPreviewOptions: ReactMarkdownOptionsGenerator = (config: RendererConfig): RendererOptions => {
 export const generateCommentPreviewOptions: ReactMarkdownOptionsGenerator = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(config);
   const options = generateCommonOptions(config);
+  const { remarkPlugins } = options;
+
+  // add remark plugins
+  if (remarkPlugins != null) {
+    remarkPlugins.push(emoji);
+    if (config.isEnabledLinebreaksInComments) {
+      remarkPlugins.push(breaks);
+    }
+  }
 
 
   // renderer.addConfigurers([
   // renderer.addConfigurers([
   //   new TableConfigurer(),
   //   new TableConfigurer(),

+ 7 - 0
packages/app/src/stores/context.tsx

@@ -240,6 +240,13 @@ export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Er
   return useStaticSWR('currentMarkdown', initialData);
   return useStaticSWR('currentMarkdown', initialData);
 };
 };
 
 
+export const useIsUploadableImage = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isUploadableImage', initialData);
+};
+
+export const useIsUploadableFile = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isUploadableFile', initialData);
+};
 
 
 /** **********************************************************
 /** **********************************************************
  *                     Computed contexts
  *                     Computed contexts

+ 3 - 11
packages/app/src/stores/ui.tsx

@@ -14,6 +14,7 @@ import useSWRImmutable from 'swr/immutable';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { IFocusable } from '~/client/interfaces/focusable';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
+import { IPageGrantData } from '~/interfaces/page';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { UpdateDescCountData } from '~/interfaces/websocket';
 import { UpdateDescCountData } from '~/interfaces/websocket';
@@ -365,17 +366,8 @@ export const useSidebarResizeDisabled = (isDisabled?: boolean): SWRResponse<bool
   return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
   return useStaticSWR('isSidebarResizeDisabled', isDisabled, { fallbackData: false });
 };
 };
 
 
-
-export const useSelectedGrant = (initialData?: number): SWRResponse<number, Error> => {
-  return useStaticSWR<number, Error>('grant', initialData);
-};
-
-export const useSelectedGrantGroupId = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('grantGroupId', initialData);
-};
-
-export const useSelectedGrantGroupName = (initialData?: Nullable<string>): SWRResponse<Nullable<string>, Error> => {
-  return useStaticSWR<Nullable<string>, Error>('grantGroupName', initialData);
+export const useSelectedGrant = (initialData?: Nullable<IPageGrantData>): SWRResponse<Nullable<IPageGrantData>, Error> => {
+  return useStaticSWR<Nullable<IPageGrantData>, Error>('selectedGrant', initialData);
 };
 };
 
 
 export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {
 export const useGlobalSearchFormRef = (initialData?: RefObject<IFocusable>): SWRResponse<RefObject<IFocusable>, Error> => {

+ 5 - 5
packages/app/src/styles/_layout.scss

@@ -6,16 +6,16 @@ body {
   overscroll-behavior-y: none;
   overscroll-behavior-y: none;
 }
 }
 
 
-body:not(.growi-layout-fluid) .grw-container-convertible {
+.wrapper:not(.growi-layout-fluid) .grw-container-convertible {
   @extend .container-lg;
   @extend .container-lg;
 }
 }
 
 
-body.not-found-page .grw-container-convertible {
-  @extend .container-lg;
+.wrapper.growi-layout-fluid .grw-container-convertible {
+  @extend .container-fluid;
 }
 }
 
 
-body.growi-layout-fluid .grw-container-convertible {
-  @extend .container-fluid;
+body.not-found-page .grw-container-convertible {
+  @extend .container-lg;
 }
 }
 
 
 .grw-modal-head {
 .grw-modal-head {

+ 6 - 33
packages/app/src/styles/_on-edit.scss

@@ -1,12 +1,9 @@
+@import './bootstrap/init' ;
+@import './variables' ;
+@import './mixins' ;
+@import 'sidebar-wiki';
 @import 'editor-overlay';
 @import 'editor-overlay';
 
 
-body:not(.on-edit) {
-  // hide .page-editor-footer
-  .page-editor-footer {
-    display: none !important;
-    border: none;
-  }
-}
 
 
 body.on-edit {
 body.on-edit {
   overflow-y: hidden !important;
   overflow-y: hidden !important;
@@ -296,43 +293,19 @@ body.on-edit {
 }
 }
 
 
 body.on-edit {
 body.on-edit {
-  &:not(.growi-layout-fluid) .page-editor-preview-body {
+  .wrapper:not(.growi-layout-fluid) .page-editor-preview-body {
     .wiki {
     .wiki {
       max-width: 980px;
       max-width: 980px;
       margin: 0 auto;
       margin: 0 auto;
     }
     }
   }
   }
-  &.growi-layout-fluid .page-editor-preview-body {
+  .wrapper.growi-layout-fluid .page-editor-preview-body {
     .wiki {
     .wiki {
       margin: 0 auto;
       margin: 0 auto;
     }
     }
   }
   }
 }
 }
 
 
-// overwrite .CodeMirror-hints
-.CodeMirror-hints {
-  max-height: 30em !important;
-
-  .CodeMirror-hint.crowi-emoji-autocomplete {
-    font-family: $font-family-monospace-not-strictly;
-    line-height: 1.6em;
-
-    .img-container {
-      display: inline-block;
-      width: 30px;
-    }
-  }
-
-  // active line
-  .CodeMirror-hint-active.crowi-emoji-autocomplete {
-    .img-container {
-      padding-top: 0.3em;
-      padding-bottom: 0.3em;
-      font-size: 15px; // adjust to .wiki
-    }
-  }
-}
-
 #tag-edit-button-tooltip {
 #tag-edit-button-tooltip {
   .tooltip-inner {
   .tooltip-inner {
     color: black;
     color: black;

+ 0 - 53
packages/app/src/styles/_override-codemirror.scss

@@ -1,53 +0,0 @@
-.CodeMirror {
-  pre.CodeMirror-line.grw-cm-header-line {
-    padding-top: 0.16em;
-    padding-bottom: 0.08em;
-    font-family: $font-family-monospace;
-
-    // '#'
-    .cm-formatting-header {
-      font-style: italic;
-      font-weight: bold;
-      opacity: 0.5;
-    }
-
-    .cm-header-1 {
-      font-size: 1.9em;
-    }
-    .cm-header-2 {
-      font-size: 1.6em;
-    }
-    .cm-header-3 {
-      font-size: 1.4em;
-    }
-    .cm-header-4 {
-      font-size: 1.35em;
-    }
-    .cm-header-5 {
-      font-size: 1.25em;
-    }
-    .cm-header-6 {
-      font-size: 1.2em;
-    }
-  }
-
-  .cm-matchhighlight {
-    color: $gray-900 !important;
-    background-color: cyan;
-  }
-
-  .CodeMirror-selection-highlight-scrollbar {
-    background-color: darkcyan;
-  }
-
-  // overwrite .CodeMirror-placeholder
-  pre.CodeMirror-line-like.CodeMirror-placeholder {
-    color: $text-muted;
-  }
-}
-
-// patch to fix https://github.com/codemirror/CodeMirror/issues/4089
-// see also https://github.com/codemirror/CodeMirror/commit/51a1e7da60a99e019f026a118dc7c98c2b1f9d62
-.CodeMirror-wrap > div > textarea {
-  font-size: #{$line-height-base}rem;
-}

+ 1 - 1
packages/app/src/styles/style-next.scss

@@ -52,7 +52,7 @@
 // @import 'modal';
 // @import 'modal';
 // @import 'navbar';
 // @import 'navbar';
 // @import 'old-ios';
 // @import 'old-ios';
-// @import 'on-edit';
+@import 'on-edit';
 // @import 'page-duplicate-modal';
 // @import 'page-duplicate-modal';
 // @import 'page_list';
 // @import 'page_list';
 // @import 'page-accessories-control';
 // @import 'page-accessories-control';

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

@@ -518,5 +518,5 @@ ul.pagination {
  * skelton
  * skelton
  */
  */
 .grw-skelton {
 .grw-skelton {
-  background-color: lighten($bgcolor-subnav, 20%);
+  background-color: lighten($bgcolor-subnav, 15%);
 }
 }

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

@@ -410,5 +410,5 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
  * skelton
  * skelton
  */
  */
 .grw-skelton {
 .grw-skelton {
-  background-color: $gray-200;
+  background-color: darken($bgcolor-subnav, 10%);
 }
 }

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

@@ -524,10 +524,6 @@ body.on-edit {
     .page-editor-preview-container {
     .page-editor-preview-container {
       background-color: $bgcolor-global;
       background-color: $bgcolor-global;
     }
     }
-
-    .page-editor-footer {
-      border-top-color: $border-color-theme;
-    }
   }
   }
 }
 }
 
 

+ 111 - 0
packages/app/test/integration/models/page-redirect.test.js

@@ -0,0 +1,111 @@
+import mongoose from 'mongoose';
+
+import { IPageRedirect, PageRedirectModel } from '../../../src/server/models/page-redirect';
+import { getInstance } from '../setup-crowi';
+
+describe('PageRedirect', () => {
+  // eslint-disable-next-line no-unused-vars
+  let crowi;
+  let PageRedirect;
+
+  beforeAll(async() => {
+    crowi = await getInstance();
+
+    PageRedirect = mongoose.model('PageRedirect');
+  });
+
+  beforeEach(async() => {
+    // clear collection
+    await PageRedirect.deleteMany({});
+  });
+
+  describe('.removePageRedirectsByToPath', () => {
+    test('works fine', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/org/path1', toPath: '/path1' },
+        { fromPath: '/org/path2', toPath: '/path2' },
+        { fromPath: '/org/path3', toPath: '/path3' },
+        { fromPath: '/org/path33', toPath: '/org/path333' },
+        { fromPath: '/org/path333', toPath: '/path3' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/org/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path3' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path33' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path333' })).not.toBeNull();
+
+      // when:
+      // remove all documents that have { toPath: '/path/3' }
+      await PageRedirect.removePageRedirectsByToPath('/path3');
+
+      // then:
+      expect(await PageRedirect.findOne({ fromPath: '/org/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path3' })).toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path33' })).toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/org/path333' })).toBeNull();
+    });
+  });
+
+  describe('.retrievePageRedirectEndpoints', () => {
+    test('shoud return null when data is not found', async() => {
+      // setup:
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).toBeNull();
+    });
+
+    test('shoud return IPageRedirectEnds (start and end is the same)', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/path1', toPath: '/path2' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).not.toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).not.toBeNull();
+      expect(endpoints.start).not.toBeNull();
+      expect(endpoints.start.fromPath).toEqual('/path1');
+      expect(endpoints.start.toPath).toEqual('/path2');
+      expect(endpoints.end).not.toBeNull();
+      expect(endpoints.end.fromPath).toEqual('/path1');
+      expect(endpoints.end.toPath).toEqual('/path2');
+    });
+
+    test('shoud return IPageRedirectEnds', async() => {
+      // setup:
+      await PageRedirect.insertMany([
+        { fromPath: '/path1', toPath: '/path2' },
+        { fromPath: '/path2', toPath: '/path3' },
+        { fromPath: '/path3', toPath: '/path4' },
+      ]);
+      expect(await PageRedirect.findOne({ fromPath: '/path1' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/path2' })).not.toBeNull();
+      expect(await PageRedirect.findOne({ fromPath: '/path3' })).not.toBeNull();
+
+      // when:
+      // retrieve
+      const endpoints = await PageRedirect.retrievePageRedirectEndpoints('/path1');
+
+      // then:
+      expect(endpoints).not.toBeNull();
+      expect(endpoints.start).not.toBeNull();
+      expect(endpoints.start.fromPath).toEqual('/path1');
+      expect(endpoints.start.toPath).toEqual('/path2');
+      expect(endpoints.end).not.toBeNull();
+      expect(endpoints.end.fromPath).toEqual('/path3');
+      expect(endpoints.end.toPath).toEqual('/path4');
+    });
+  });
+
+});

+ 1 - 8
yarn.lock

@@ -12614,18 +12614,11 @@ jquery-slimscroll@^1.3.8:
   dependencies:
   dependencies:
     jquery ">= 1.7"
     jquery ">= 1.7"
 
 
-jquery-ui@^1.12.1:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.13.0.tgz#ab5ac65f37ca093c51b3478c4097f55bbc008f36"
-  integrity sha512-Osf7ECXNTYHtKBkn9xzbIf9kifNrBhfywFEKxOeB/OVctVmLlouV9mfc2qXCp6uyO4Pn72PXKOnj09qXetopCw==
-  dependencies:
-    jquery ">=1.8.0 <4.0.0"
-
 jquery.cookie@~1.4.1:
 jquery.cookie@~1.4.1:
   version "1.4.1"
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jquery.cookie/-/jquery.cookie-1.4.1.tgz#d63dce209eab691fe63316db08ca9e47e0f9385b"
   resolved "https://registry.yarnpkg.com/jquery.cookie/-/jquery.cookie-1.4.1.tgz#d63dce209eab691fe63316db08ca9e47e0f9385b"
 
 
-"jquery@>= 1.7", jquery@>=1.12.0, "jquery@>=1.8.0 <4.0.0":
+"jquery@>= 1.7", jquery@>=1.12.0:
   version "3.6.0"
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
   resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470"
   integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==
   integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw==