Przeglądaj źródła

Merge branch 'master' into fix/110717-markdown-help-styles

Kaori Tokashiki 3 lat temu
rodzic
commit
f3df735c7a
100 zmienionych plików z 912 dodań i 1165 usunięć
  1. 7 4
      .github/workflows/ci-app.yml
  2. 1 0
      package.json
  3. 0 0
      packages/app/_obsolete/src/client/legacy/crowi.js
  4. 3 2
      packages/app/docker/Dockerfile
  5. 5 3
      packages/app/package.json
  6. 28 0
      packages/app/src/client/services/page-operation.ts
  7. 1 1
      packages/app/src/client/util/toastr.ts
  8. 1 1
      packages/app/src/components/Admin/App/AppSettingsPageContents.tsx
  9. 5 7
      packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx
  10. 26 25
      packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx
  11. 2 5
      packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx
  12. 1 4
      packages/app/src/components/BookmarkButtons.tsx
  13. 5 13
      packages/app/src/components/Layout/RawLayout.tsx
  14. 1 4
      packages/app/src/components/LikeButtons.tsx
  15. 4 6
      packages/app/src/components/Navbar/AppearanceModeDropdown.tsx
  16. 0 5
      packages/app/src/components/Navbar/PageEditorModeManager.jsx
  17. 0 39
      packages/app/src/components/NotAvailableForGuest.jsx
  18. 35 0
      packages/app/src/components/NotAvailableForGuest.tsx
  19. 7 3
      packages/app/src/components/Page.tsx
  20. 36 6
      packages/app/src/components/Page/DisplaySwitcher.tsx
  21. 14 15
      packages/app/src/components/Page/RenderTagLabels.tsx
  22. 1 1
      packages/app/src/components/PageAlert/PageRedirectedAlert.tsx
  23. 16 5
      packages/app/src/components/PageAlert/TrashPageAlert.tsx
  24. 1 1
      packages/app/src/components/PageComment/CommentEditor.tsx
  25. 1 0
      packages/app/src/components/PageDeleteModal.tsx
  26. 39 17
      packages/app/src/components/PageEditor.tsx
  27. 98 45
      packages/app/src/components/PageEditor/ConflictDiffModal.tsx
  28. 1 1
      packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts
  29. 16 14
      packages/app/src/components/PageEditorByHackmd.tsx
  30. 9 6
      packages/app/src/components/PageStatusAlert.tsx
  31. 2 2
      packages/app/src/components/PutbackPageModal.jsx
  32. 1 1
      packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  33. 1 1
      packages/app/src/components/Script/DrawioViewerScript.tsx
  34. 28 21
      packages/app/src/components/Sidebar/PageTree/Item.tsx
  35. 7 5
      packages/app/src/components/SlackNotification.module.scss
  36. 3 1
      packages/app/src/components/SlackNotification.tsx
  37. 1 4
      packages/app/src/components/SubscribeButton.tsx
  38. 0 32
      packages/app/src/components/Theme/ThemeAntarctic.tsx
  39. 0 8
      packages/app/src/components/Theme/ThemeBlackboard.tsx
  40. 0 32
      packages/app/src/components/Theme/ThemeChristmas.tsx
  41. 0 8
      packages/app/src/components/Theme/ThemeDefault.tsx
  42. 0 8
      packages/app/src/components/Theme/ThemeFireRed.tsx
  43. 0 8
      packages/app/src/components/Theme/ThemeFuture.tsx
  44. 0 32
      packages/app/src/components/Theme/ThemeHalloween.tsx
  45. 0 36
      packages/app/src/components/Theme/ThemeHufflepuff.tsx
  46. 0 32
      packages/app/src/components/Theme/ThemeIsland.tsx
  47. 0 8
      packages/app/src/components/Theme/ThemeJadeGreen.tsx
  48. 0 8
      packages/app/src/components/Theme/ThemeKibela.tsx
  49. 0 8
      packages/app/src/components/Theme/ThemeMonoBlue.tsx
  50. 0 8
      packages/app/src/components/Theme/ThemeNature.tsx
  51. 0 32
      packages/app/src/components/Theme/ThemeSpring.tsx
  52. 0 32
      packages/app/src/components/Theme/ThemeWood.tsx
  53. 0 36
      packages/app/src/components/Theme/utils/ThemeInjector.tsx
  54. 0 66
      packages/app/src/components/Theme/utils/ThemeProvider.tsx
  55. 2 2
      packages/app/src/components/User/SeenUserInfo.module.scss
  56. 4 0
      packages/app/src/interfaces/customize.ts
  57. 1 0
      packages/app/src/interfaces/plugin.ts
  58. 3 0
      packages/app/src/interfaces/websocket.ts
  59. 6 0
      packages/app/src/pages/[[...path]].page.tsx
  60. 2 4
      packages/app/src/pages/_app.page.tsx
  61. 48 7
      packages/app/src/pages/_document.page.tsx
  62. 0 3
      packages/app/src/pages/utils/commons.ts
  63. 6 1
      packages/app/src/server/crowi/express-init.js
  64. 12 40
      packages/app/src/server/routes/apiv3/customize-setting.js
  65. 1 1
      packages/app/src/server/routes/apiv3/index.js
  66. 1 2
      packages/app/src/server/routes/apiv3/plugins.ts
  67. 66 38
      packages/app/src/server/service/plugin.ts
  68. 3 2
      packages/app/src/server/views/widget/page_alerts.html
  69. 41 9
      packages/app/src/services/renderer/renderer.tsx
  70. 31 4
      packages/app/src/stores/admin/customize.tsx
  71. 0 4
      packages/app/src/stores/context.tsx
  72. 27 0
      packages/app/src/stores/modal.tsx
  73. 45 3
      packages/app/src/stores/remote-latest-page.ts
  74. 0 59
      packages/app/src/styles/_mixins.scss
  75. 0 43
      packages/app/src/styles/_page.scss
  76. 0 32
      packages/app/src/styles/_vendor.scss
  77. 60 0
      packages/app/src/styles/atoms/mixins/_buttons.scss
  78. 34 3
      packages/app/src/styles/bootstrap/_apply.scss
  79. 6 0
      packages/app/src/styles/bootstrap/_variables.scss
  80. 36 58
      packages/app/src/styles/style-app.scss
  81. 0 149
      packages/app/src/styles/style-next.scss
  82. 0 15
      packages/app/src/styles/style-themes.scss
  83. 2 2
      packages/app/src/styles/theme/_apply-colors-dark.scss
  84. 2 2
      packages/app/src/styles/theme/_apply-colors-light.scss
  85. 3 3
      packages/app/src/styles/theme/_apply-colors.scss
  86. 1 1
      packages/app/src/styles/theme/_reboot-bootstrap-border-colors.scss
  87. 1 1
      packages/app/src/styles/theme/_reboot-bootstrap-colors.scss
  88. 3 3
      packages/app/src/styles/theme/_reboot-bootstrap-theme-colors.scss
  89. 4 4
      packages/app/src/styles/theme/_reboot-toastr-colors.scss
  90. 17 5
      packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts
  91. 4 1
      packages/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.spec.ts
  92. 2 0
      packages/app/test/cypress/integration/40-admin/40-admin--access-to-admin-page.spec.ts
  93. 1 2
      packages/hackmd/package.json
  94. 2 0
      packages/preset-themes/.eslintignore
  95. 0 0
      packages/preset-themes/.eslintrc.js
  96. 0 0
      packages/preset-themes/.gitignore
  97. 1 0
      packages/preset-themes/README.md
  98. 27 0
      packages/preset-themes/package.json
  99. 0 0
      packages/preset-themes/public/images/antarctic/bg.svg
  100. 0 0
      packages/preset-themes/public/images/antarctic/topimage.svg

+ 7 - 4
.github/workflows/ci-app.yml

@@ -52,12 +52,15 @@ jobs:
         run: |
           npx lerna bootstrap -- --frozen-lockfile
 
-      - name: lerna run lint for plugins
+      - name: lerna run lint for dependent packages
         run: |
-          yarn lerna run lint --scope @growi/remark-*
+          yarn lerna run lint --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/hackmd --scope @growi/preset-themes --scope @growi/remark-* --scope @growi/slack --scope @growi/ui
+      - name: build dependent packages
+        run: |
+          yarn lerna run build --scope @growi/preset-themes
       - name: lerna run lint for app
         run: |
-          yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
+          yarn lerna run lint --scope @growi/app
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master
@@ -123,7 +126,7 @@ jobs:
           name: Coverage Report
           path: |
             packages/app/coverage
-            packages/remark-growi-plugin/coverage
+            packages/remark-growi-directive/coverage
 
       - name: Slack Notification
         uses: weseek/ghaction-slack-notification@master

+ 1 - 0
package.json

@@ -89,6 +89,7 @@
     "ts-node": "^10.9.1",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~4.7",
+    "vite": "^3.2.5",
     "yargs": "^17.3.1"
   },
   "engines": {

+ 0 - 0
packages/app/src/client/legacy/crowi.js → packages/app/_obsolete/src/client/legacy/crowi.js


+ 3 - 2
packages/app/docker/Dockerfile

@@ -107,10 +107,11 @@ COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
-COPY packages/remark-drawio-plugin packages/remark-drawio-plugin
-COPY packages/remark-growi-plugin packages/remark-growi-plugin
+COPY packages/remark-drawio packages/remark-drawio
+COPY packages/remark-growi-directive packages/remark-growi-directive
 COPY packages/remark-lsx packages/remark-lsx
 COPY packages/hackmd packages/hackmd
+COPY packages/preset-themes packages/preset-themes
 
 # build
 RUN yarn lerna run build

+ 5 - 3
packages/app/package.json

@@ -46,7 +46,7 @@
     "openapi:v3": "yarn cross-env API_VERSION=3 yarn swagger-jsdoc -- \"src/server/routes/apiv3/**/*.js\" \"src/server/models/**/*.js\"",
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
-    "resources:dummy": "true",
+    "resources:preset-themes": "yarn lerna run build --scope=@growi/preset-themes",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
@@ -67,8 +67,9 @@
     "@growi/codemirror-textlint": "^6.0.0-RC.9",
     "@growi/core": "^6.0.0-RC.9",
     "@growi/hackmd": "^6.0.0-RC.9",
-    "@growi/remark-drawio-plugin": "^6.0.0-RC.9",
-    "@growi/remark-growi-plugin": "^6.0.0-RC.9",
+    "@growi/preset-themes": "^6.0.0-RC.9",
+    "@growi/remark-drawio": "^6.0.0-RC.9",
+    "@growi/remark-growi-directive": "^6.0.0-RC.9",
     "@growi/remark-lsx": "^6.0.0-RC.9",
     "@growi/slack": "^6.0.0-RC.9",
     "@promster/express": "^7.0.2",
@@ -157,6 +158,7 @@
     "react-bootstrap-typeahead": "^5.2.2",
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
+    "react-disable": "^0.1.1",
     "react-dnd": "^14.0.5",
     "react-dnd-html5-backend": "^14.1.0",
     "react-dom": "^18.2.0",

+ 28 - 0
packages/app/src/client/services/page-operation.ts

@@ -2,7 +2,10 @@ import { SubscriptionStatusType, Nullable } from '@growi/core';
 import urljoin from 'url-join';
 
 import { OptionsToSave } from '~/interfaces/page-operation';
+import { useCurrentPageId } from '~/stores/context';
 import { useIsEnabledUnsavedWarning } from '~/stores/editor';
+import { useSWRxCurrentPage } from '~/stores/page';
+import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
 import { toastError } from '../util/apiNotification';
@@ -171,3 +174,28 @@ export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
     return res;
   };
 };
+
+export const useUpdateStateAfterSave = () => {
+  const { mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { mutate: mutateCurrentPage } = useSWRxCurrentPage();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // update swr 'currentPageId', 'currentPage', remote states
+  return async(pageId: string) => {
+    await mutateCurrentPageId(pageId);
+    const updatedPage = await mutateCurrentPage();
+
+    if (updatedPage == null) { return }
+
+    const remoterevisionData = {
+      remoteRevisionId: updatedPage.revision._id,
+      remoteRevisionBody: updatedPage.revision.body,
+      remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
+      remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
+      revisionIdHackmdSynced: updatedPage.revisionHackmdSynced.toString(),
+      hasDraftOnHackmd: updatedPage.hasDraftOnHackmd,
+    };
+
+    setRemoteLatestPageData(remoterevisionData);
+  };
+};

+ 1 - 1
packages/app/src/client/util/toastr.ts

@@ -5,7 +5,7 @@ import { toArrayIfNot } from '~/utils/array-utils';
 
 
 export const toastErrorOption: ToastOptions = {
-  autoClose: 0,
+  autoClose: false,
   closeButton: true,
 };
 export const toastError = (err: string | Error | Error[], option: ToastOptions = toastErrorOption): void => {

+ 1 - 1
packages/app/src/components/Admin/App/AppSettingsPageContents.tsx

@@ -73,7 +73,7 @@ const AppSettingsPageContents = (props: Props) => {
           && (
             <div className="row">
               <div className="col-lg-12">
-                <h2 className="admin-setting-header">{t('V5 Page Migration')}</h2>
+                <h2 className="admin-setting-header" data-testid="v5-page-migration">{t('V5 Page Migration')}</h2>
                 <V5PageMigration />
               </div>
             </div>

+ 5 - 7
packages/app/src/components/Admin/Customize/CustomizeThemeOptions.jsx

@@ -49,14 +49,13 @@ const uniqueTheme = [{
 
 const CustomizeThemeOptions = (props) => {
 
-  const { adminCustomizeContainer, currentTheme } = props;
-  const { currentLayout } = adminCustomizeContainer.state;
+  const { selectedTheme } = props;
 
   const { t } = useTranslation('admin');
 
 
   return (
-    <div id="themeOptions" className={`${currentLayout === 'kibela' && 'disabled'}`}>
+    <div id="themeOptions">
       {/* Light and Dark Themes */}
       <div>
         <h3>{t('customize_settings.theme_desc.light_and_dark')}</h3>
@@ -65,7 +64,7 @@ const CustomizeThemeOptions = (props) => {
             return (
               <ThemeColorBox
                 key={theme.name}
-                isSelected={currentTheme === theme.name}
+                isSelected={selectedTheme === theme.name}
                 onSelected={() => props.onSelected(theme.name)}
                 {...theme}
               />
@@ -81,7 +80,7 @@ const CustomizeThemeOptions = (props) => {
             return (
               <ThemeColorBox
                 key={theme.name}
-                isSelected={currentTheme === theme.name}
+                isSelected={selectedTheme === theme.name}
                 onSelected={() => props.onSelected(theme.name)}
                 {...theme}
               />
@@ -97,9 +96,8 @@ const CustomizeThemeOptions = (props) => {
 const CustomizeThemeOptionsWrapper = withUnstatedContainers(CustomizeThemeOptions, [AdminCustomizeContainer]);
 
 CustomizeThemeOptions.propTypes = {
-  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
   onSelected: PropTypes.func,
-  currentTheme: PropTypes.string,
+  selectedTheme: PropTypes.string,
 };
 
 export default CustomizeThemeOptionsWrapper;

+ 26 - 25
packages/app/src/components/Admin/Customize/CustomizeThemeSetting.tsx

@@ -1,60 +1,61 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { useGrowiTheme } from '~/stores/context';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { useSWRxGrowiTheme } from '~/stores/admin/customize';
 
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
 import CustomizeThemeOptions from './CustomizeThemeOptions';
 
+// eslint-disable-next-line @typescript-eslint/ban-types
 type Props = {
-  adminCustomizeContainer: AdminCustomizeContainer
 }
 
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
 const CustomizeThemeSetting = (props: Props): JSX.Element => {
-
-  const { adminCustomizeContainer } = props;
-  const { data: currentTheme } = useGrowiTheme();
   const { t } = useTranslation();
 
-  const selectedHandler = useCallback((themeName) => {
-    // TODO: preview without using mutate of useGrowiTheme
-    // https://github.com/weseek/growi/pull/6860
-    // mutateGrowiTheme(themeName);
+  const { data: currentTheme, error } = useSWRxGrowiTheme();
+  const [selectedTheme, setSelectedTheme] = useState(currentTheme);
+
+  useEffect(() => {
+    setSelectedTheme(currentTheme);
+  }, [currentTheme]);
+
+  const selectedHandler = useCallback((themeName: string) => {
+    setSelectedTheme(themeName);
   }, []);
 
   const submitHandler = useCallback(async() => {
+    if (selectedTheme == null) {
+      toastWarning('The selected theme is undefined');
+      return;
+    }
+
     try {
-      if (currentTheme != null) {
-        await apiv3Put('/customize-setting/theme', {
-          themeType: currentTheme,
-        });
-      }
+      await apiv3Put('/customize-setting/theme', {
+        theme: selectedTheme,
+      });
 
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.theme'), ns: 'commons' }));
     }
     catch (err) {
       toastError(err);
     }
-  }, [currentTheme, t]);
+  }, [selectedTheme, t]);
 
   return (
     <div className="row">
       <div className="col-12">
         <h2 className="admin-setting-header">{t('admin:customize_settings.theme')}</h2>
-        <CustomizeThemeOptions onSelected={selectedHandler} currentTheme={currentTheme} />
-        <AdminUpdateButtonRow onClick={submitHandler} disabled={adminCustomizeContainer.state.retrieveError != null} />
+        <CustomizeThemeOptions onSelected={selectedHandler} selectedTheme={selectedTheme} />
+        <AdminUpdateButtonRow onClick={submitHandler} disabled={error != null} />
       </div>
     </div>
   );
 };
 
-const CustomizeThemeSettingWrapper = withUnstatedContainers(CustomizeThemeSetting, [AdminCustomizeContainer]);
-
-export default CustomizeThemeSettingWrapper;
+export default CustomizeThemeSetting;

+ 2 - 5
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -1,9 +1,7 @@
 import React, { useCallback } from 'react';
 
-import { useTranslation } from 'react-i18next';
-
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { apiv3Post } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 import AdminInstallButtonRow from '../Common/AdminUpdateButtonRow';
 // TODO: error notification (toast, loggerFactory)
@@ -30,12 +28,11 @@ export const PluginInstallerForm = (): JSX.Element => {
     };
 
     try {
-      await apiv3Post('/plugins-extension', { pluginInstallerForm });
+      await apiv3Post('/plugins', { pluginInstallerForm });
       toastSuccess('Plugin Install Successed!');
     }
     catch (err) {
       toastError(err);
-      // logger.error(err);
     }
   }, []);
 

+ 1 - 4
packages/app/src/components/BookmarkButtons.tsx

@@ -41,15 +41,12 @@ const BookmarkButtons: FC<Props> = (props: Props) => {
   };
 
   const getTooltipMessage = useCallback(() => {
-    if (isGuestUser) {
-      return 'Not available for guest';
-    }
 
     if (isBookmarked) {
       return 'tooltip.cancel_bookmark';
     }
     return 'tooltip.bookmark';
-  }, [isGuestUser, isBookmarked]);
+  }, [isBookmarked]);
 
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">

+ 5 - 13
packages/app/src/components/Layout/RawLayout.tsx

@@ -4,14 +4,10 @@ import Head from 'next/head';
 import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
-import { useGrowiTheme } from '~/stores/context';
 import { ColorScheme, useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 
-import { ThemeProvider as GrowiThemeProvider } from '../Theme/utils/ThemeProvider';
-
-
 const logger = loggerFactory('growi:cli:RawLayout');
 
 
@@ -26,8 +22,6 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   if (className != null) {
     classNames.push(className);
   }
-  const { data: growiTheme } = useGrowiTheme();
-
   // get color scheme from next-themes
   const { resolvedTheme, resolvedThemeByAttributes } = useNextThemes();
 
@@ -36,7 +30,7 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
   // set colorScheme in CSR
   useIsomorphicLayoutEffect(() => {
     setColorScheme(resolvedTheme ?? resolvedThemeByAttributes);
-  }, [resolvedTheme]);
+  }, [resolvedTheme, resolvedThemeByAttributes]);
 
   return (
     <>
@@ -46,12 +40,10 @@ export const RawLayout = ({ children, title, className }: Props): JSX.Element =>
         <meta name="viewport" content="initial-scale=1.0, width=device-width" />
       </Head>
       <NextThemesProvider>
-        <GrowiThemeProvider theme={growiTheme} colorScheme={colorScheme}>
-          <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
-            {children}
-            <ToastContainer theme={colorScheme} />
-          </div>
-        </GrowiThemeProvider>
+        <div className={classNames.join(' ')} data-color-scheme={colorScheme}>
+          {children}
+          <ToastContainer theme={colorScheme} />
+        </div>
       </NextThemesProvider>
     </>
   );

+ 1 - 4
packages/app/src/components/LikeButtons.tsx

@@ -34,15 +34,12 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   } = props;
 
   const getTooltipMessage = useCallback(() => {
-    if (isGuestUser) {
-      return 'Not available for guest';
-    }
 
     if (isLiked) {
       return 'tooltip.cancel_like';
     }
     return 'tooltip.like';
-  }, [isGuestUser, isLiked]);
+  }, [isLiked]);
 
   return (
     <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">

+ 4 - 6
packages/app/src/components/Navbar/AppearanceModeDropdown.tsx

@@ -68,6 +68,8 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
     </>
   );
 
+  const dropdownDivider = <div className="dropdown-divider"></div>;
+
   const renderSidebarModeSwitch = useCallback((isEditMode: boolean) => {
     return (
       <>
@@ -111,14 +113,10 @@ export const AppearanceModeDropdown:FC<AppearanceModeDropdownProps> = (props: Ap
       <div className="dropdown-menu dropdown-menu-right">
 
         {/* sidebar mode */}
-        {renderSidebarModeSwitch(false)}
-
-        <div className="dropdown-divider"></div>
+        {[renderSidebarModeSwitch(false), dropdownDivider]}
 
         {/* side bar mode on editor */}
-        {isAuthenticated && renderSidebarModeSwitch(true)}
-
-        <div className="dropdown-divider"></div>
+        {isAuthenticated && [renderSidebarModeSwitch(true), dropdownDivider]}
 
         {/* color mode */}
         <h6 className="dropdown-header">{t('personal_dropdown.color_mode')}</h6>

+ 0 - 5
packages/app/src/components/Navbar/PageEditorModeManager.jsx

@@ -110,11 +110,6 @@ function PageEditorModeManager(props) {
           </>
         )}
       </div>
-      {isBtnDisabled && (
-        <UncontrolledTooltip placement="top" target="grw-page-editor-mode-manager" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
     </>
   );
 

+ 0 - 39
packages/app/src/components/NotAvailableForGuest.jsx

@@ -1,39 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { UncontrolledTooltip } from 'reactstrap';
-
-import { useIsGuestUser } from '~/stores/context';
-
-const NotAvailableForGuest = (props) => {
-  const { children } = props;
-
-  const { data: isGuestUser } = useIsGuestUser();
-
-  if (!isGuestUser) {
-    return props.children;
-  }
-
-  const id = children.props.id || `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
-
-  // clone and add className
-  const clonedChild = React.cloneElement(children, {
-    id,
-    className: `${children.props.className} grw-not-available-for-guest`,
-    onClick: () => { /* do nothing */ },
-  });
-
-  return (
-    <>
-      { clonedChild }
-      <UncontrolledTooltip placement="top" target={id}>Not available for guest</UncontrolledTooltip>
-    </>
-  );
-
-};
-
-NotAvailableForGuest.propTypes = {
-  children: PropTypes.node.isRequired,
-};
-
-export default NotAvailableForGuest;

+ 35 - 0
packages/app/src/components/NotAvailableForGuest.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Disable } from 'react-disable';
+import { UncontrolledTooltip } from 'reactstrap';
+
+import { useIsGuestUser } from '~/stores/context';
+
+type NotAvailableForGuestProps = {
+  children: JSX.Element
+}
+
+export const NotAvailableForGuest = ({ children }: NotAvailableForGuestProps): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: isGuestUser } = useIsGuestUser();
+  const isDisabled = !!isGuestUser;
+
+  if (!isGuestUser) {
+    return children;
+  }
+
+  const id = `grw-not-available-for-guest-${Math.random().toString(32).substring(2)}`;
+
+  return (
+    <>
+      <div id={id}>
+        <Disable disabled={isDisabled}>
+          { children }
+        </Disable>
+      </div>
+      <UncontrolledTooltip placement="top" target={id}>{t('Not available for guest')}</UncontrolledTooltip>
+    </>
+  );
+};

+ 7 - 3
packages/app/src/components/Page.tsx

@@ -5,7 +5,8 @@ import React, {
 
 import EventEmitter from 'events';
 
-import { DrawioEditByViewerProps } from '@growi/remark-drawio-plugin';
+import { pagePathUtils } from '@growi/core';
+import { DrawioEditByViewerProps } from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { HtmlElementNode } from 'rehype-toc';
@@ -15,7 +16,7 @@ import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { OptionsToSave } from '~/interfaces/page-operation';
 import {
-  useIsGuestUser, useShareLinkId,
+  useIsGuestUser, useShareLinkId, useCurrentPathname,
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useDrawioModal, useHandsontableModal } from '~/stores/modal';
@@ -59,10 +60,13 @@ export const Page = (props) => {
     tocRef.current = toc;
   }, []);
 
+  const { data: currentPathname } = useCurrentPathname();
+  const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
+
   const { data: shareLinkId } = useShareLinkId();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-  const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
+  const { data: tagsInfo } = useSWRxTagsInfo(!isSharedPage ? currentPage?._id : undefined);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
   const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);

+ 36 - 6
packages/app/src/components/Page/DisplaySwitcher.tsx

@@ -10,9 +10,12 @@ import { SocketEventName } from '~/interfaces/websocket';
 import {
   useIsSharedUser, useIsEditable, useShareLinkId, useIsNotFound,
 } from '~/stores/context';
+import { useIsHackmdDraftUpdatingInRealtime } from '~/stores/hackmd';
 import { useDescendantsPageListModal } from '~/stores/modal';
 import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
-import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
+import {
+  useSetRemoteLatestPageData,
+} from '~/stores/remote-latest-page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
 
@@ -47,8 +50,9 @@ const PageView = React.memo((): JSX.Element => {
   const { data: isNotFound } = useIsNotFound();
   const { data: currentPage } = useSWRxCurrentPage(shareLinkId ?? undefined);
   const { open: openDescendantPageListModal } = useDescendantsPageListModal();
-  const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
-  const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  const { mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
 
   const isTopPagePath = isTopPage(currentPagePath ?? '');
   const isUsersHomePagePath = isUsersHomePage(currentPagePath ?? '');
@@ -58,9 +62,23 @@ const PageView = React.memo((): JSX.Element => {
   const setLatestRemotePageData = useCallback((data) => {
     const { s2cMessagePageUpdated } = data;
 
-    mutateRemoteRevisionId(s2cMessagePageUpdated.revisionId);
-    mutateRemoteRevisionLastUpdateUser(s2cMessagePageUpdated.remoteLastUpdateUser);
-  }, [mutateRemoteRevisionId, mutateRemoteRevisionLastUpdateUser]);
+    const remoteData = {
+      remoteRevisionId: s2cMessagePageUpdated.revisionId,
+      remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+      remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
+      remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+      revisionIdHackmdSynced: s2cMessagePageUpdated.revisionIdHackmdSynced,
+      hasDraftOnHackmd: s2cMessagePageUpdated.hasDraftOnHackmd,
+    };
+    setRemoteLatestPageData(remoteData);
+  }, [setRemoteLatestPageData]);
+
+  const setIsHackmdDraftUpdatingInRealtime = useCallback((data) => {
+    const { s2cMessagePageUpdated } = data;
+    if (s2cMessagePageUpdated.pageId === currentPage?._id) {
+      mutateIsHackmdDraftUpdatingInRealtime(true);
+    }
+  }, [currentPage?._id, mutateIsHackmdDraftUpdatingInRealtime]);
 
   // listen socket for someone updating this page
   useEffect(() => {
@@ -75,6 +93,18 @@ const PageView = React.memo((): JSX.Element => {
 
   }, [setLatestRemotePageData, socket]);
 
+  // listen socket for hackmd saved
+  useEffect(() => {
+
+    if (socket == null) { return }
+
+    socket.on(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+
+    return () => {
+      socket.off(SocketEventName.EditingWithHackmd, setIsHackmdDraftUpdatingInRealtime);
+    };
+  }, [setIsHackmdDraftUpdatingInRealtime, socket]);
+
   return (
     <div className="d-flex flex-column flex-lg-row">
 

+ 14 - 15
packages/app/src/components/Page/RenderTagLabels.tsx

@@ -1,7 +1,8 @@
 import React from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip } from 'reactstrap';
+
+import { NotAvailableForGuest } from '../NotAvailableForGuest';
 
 type RenderTagLabelsProps = {
   tags: string[],
@@ -31,21 +32,19 @@ const RenderTagLabels = React.memo((props: RenderTagLabelsProps) => {
           </a>
         );
       })}
-      <div id="edit-tags-btn-wrapper-for-tooltip">
-        <a
-          className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isGuestUser && 'disabled'}`}
-          onClick={openEditorHandler}
-        >
-          { isTagsEmpty && <>{ t('Add tags for this page') }</>}
-          <i className={`icon-plus ${isTagsEmpty && 'ml-1'}`}/>
-        </a>
-      </div>
-      {isGuestUser && (
-        <UncontrolledTooltip placement="top" target="edit-tags-btn-wrapper-for-tooltip" fade={false}>
-          {t('Not available for guest')}
-        </UncontrolledTooltip>
-      )}
+      <NotAvailableForGuest>
+        <div id="edit-tags-btn-wrapper-for-tooltip">
+          <a
+            className={`btn btn-link btn-edit-tags text-muted p-0 d-flex align-items-center ${isTagsEmpty && 'no-tags'} ${isGuestUser && 'disabled'}`}
+            onClick={openEditorHandler}
+          >
+            { isTagsEmpty && <>{ t('Add tags for this page') }</>}
+            <i className={`icon-plus ${isTagsEmpty && 'ml-1'}`}/>
+          </a>
+        </div>
+      </NotAvailableForGuest>
     </>
+
   );
 
 });

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

@@ -21,7 +21,7 @@ export const PageRedirectedAlert = React.memo((): JSX.Element => {
     }
   }, [unlink]);
 
-  if (redirectFrom == null) {
+  if (redirectFrom == null || redirectFrom === '') {
     return <></>;
   }
 

+ 16 - 5
packages/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -5,10 +5,13 @@ import { format } from 'date-fns';
 import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 
+import { toastError } from '~/client/util/apiNotification';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage } from '~/stores/page';
+import { useRedirectFrom } from '~/stores/page-redirect';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
+
 const onDeletedHandler = (pathOrPathsToDelete) => {
   if (typeof pathOrPathsToDelete !== 'string') {
     return;
@@ -31,6 +34,7 @@ export const TrashPageAlert = (): JSX.Element => {
 
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
+  const { unlink } = useRedirectFrom();
 
 
   const deleteUser = pageData?.deleteUser;
@@ -43,12 +47,18 @@ export const TrashPageAlert = (): JSX.Element => {
       return;
     }
     const putBackedHandler = () => {
-      // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
-      // See: https://github.com/weseek/growi/pull/7054
-      router.reload();
+      try {
+        unlink();
+        // Do not use "router.push(`/${pageId}`)" to avoid `Error: Invariant: attempted to hard navigate to the same URL`
+        // See: https://github.com/weseek/growi/pull/7054
+        router.reload();
+      }
+      catch (err) {
+        toastError(err);
+      }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [openPutBackPageModal, pageId, pagePath, router]);
+  }, [openPutBackPageModal, pageId, pagePath, router, unlink]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {
@@ -73,6 +83,7 @@ export const TrashPageAlert = (): JSX.Element => {
           className="btn btn-info rounded-pill btn-sm ml-auto mr-2"
           onClick={openPutbackPageModalHandler}
           data-toggle="modal"
+          data-testid="put-back-button"
         >
           <i className="icon-action-undo" aria-hidden="true"></i> { t('Put Back') }
         </button>
@@ -94,7 +105,7 @@ export const TrashPageAlert = (): JSX.Element => {
 
   return (
     <>
-      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row">
+      <div className="alert alert-warning py-3 pl-4 d-flex flex-column flex-lg-row" data-testid="trash-page-alert">
         <div className="flex-grow-1">
           This page is in the trash <i className="icon-trash" aria-hidden="true"></i>.
           <br />

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -20,7 +20,7 @@ import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
-import NotAvailableForGuest from '../NotAvailableForGuest';
+import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import Editor from '../PageEditor/Editor';
 
 

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

@@ -272,6 +272,7 @@ const PageDeleteModal: FC = () => {
           className={`btn btn-${deleteIconAndKey[deleteMode].color}`}
           disabled={!isDeletable}
           onClick={deleteButtonHandler}
+          data-testid="delete-page-button"
         >
           <i className={`mr-1 icon-${deleteIconAndKey[deleteMode].icon}`} aria-hidden="true"></i>
           { t(`modal_delete.delete_${deleteIconAndKey[deleteMode].translationKey}`) }

+ 39 - 17
packages/app/src/components/PageEditor.tsx

@@ -13,7 +13,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { apiGet, apiPostForm } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { IEditorMethods } from '~/interfaces/editor-methods';
@@ -29,7 +29,9 @@ import {
   useIsConflict,
   useEditingMarkdown,
 } from '~/stores/editor';
-import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import { useConflictDiffModal } from '~/stores/modal';
+import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import { usePreviewOptions } from '~/stores/renderer';
 import {
   EditorMode,
@@ -41,6 +43,7 @@ import loggerFactory from '~/utils/logger';
 
 
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
+import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 import scrollSyncHelper from './PageEditor/ScrollSyncHelper';
@@ -71,8 +74,9 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage, mutate: mutateCurrentPage } = useSWRxCurrentPage();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
-  const { data: pageTags } = usePageTagsForEditors(pageId);
-  const { data: editingMarkdown } = useEditingMarkdown();
+  const { data: pageTags, sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
+  const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
+  const { data: editingMarkdown, mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: templateBodyData } = useTemplateBodyData();
   const { data: isEditable } = useIsEditable();
@@ -84,11 +88,14 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
+  const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
 
   const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
 
+  const updateStateAfterSave = useUpdateStateAfterSave();
+
   const currentRevisionId = currentPage?.revision?._id;
 
   const initialValue = useMemo(() => {
@@ -241,11 +248,10 @@ const PageEditor = React.memo((): JSX.Element => {
       await router.push(`/${page._id}`);
     }
     else {
-      await mutateCurrentPageId(page._id);
-      await mutateCurrentPage();
+      updateStateAfterSave(page._id);
     }
     mutateEditorMode(EditorMode.View);
-  }, [editorMode, save, isNotFound, mutateEditorMode, router, mutateCurrentPageId, mutateCurrentPage]);
+  }, [editorMode, save, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
 
   const saveWithShortcut = useCallback(async() => {
     if (editorMode !== EditorMode.Editor) {
@@ -254,11 +260,10 @@ const PageEditor = React.memo((): JSX.Element => {
 
     const page = await save();
     if (page != null) {
+      updateStateAfterSave(page._id);
       toastSuccess(t('toaster.save_succeeded'));
-      await mutateCurrentPageId(page._id);
-      await mutateCurrentPage();
     }
-  }, [editorMode, mutateCurrentPage, mutateCurrentPageId, save, t]);
+  }, [editorMode, save, t, useUpdateStateAfterSave]);
 
 
   /**
@@ -411,6 +416,23 @@ const PageEditor = React.memo((): JSX.Element => {
   }, []);
   const scrollEditorByPreviewScrollWithThrottle = useMemo(() => throttle(20, scrollEditorByPreviewScroll), [scrollEditorByPreviewScroll]);
 
+  const afterResolvedHandler = useCallback(async() => {
+    // get page data from db
+    const pageData = await mutateCurrentPage();
+
+    // update tag
+    await mutateTagsInfo(); // get from DB
+    syncTagsInfoForEditor(); // sync global state for client
+
+    // clear isConflict
+    mutateIsConflict(false);
+
+    // set resolved markdown in editing markdown
+    const markdown = pageData?.revision.body ?? '';
+    mutateEditingMarkdown(markdown);
+
+  }, [mutateCurrentPage, mutateEditingMarkdown, mutateIsConflict, mutateTagsInfo, syncTagsInfoForEditor]);
+
 
   // initialize
   useEffect(() => {
@@ -514,13 +536,13 @@ const PageEditor = React.memo((): JSX.Element => {
           onScroll={offset => scrollEditorByPreviewScrollWithThrottle(offset)}
         />
       </div>
-      {/* <ConflictDiffModal
-        isOpen={pageContainer.state.isConflictDiffModalOpen}
-        onClose={() => pageContainer.setState({ isConflictDiffModalOpen: false })}
-        pageContainer={pageContainer}
-        markdownOnEdit={markdown}
-        optionsToSave={optionsToSave}
-      /> */}
+      <ConflictDiffModal
+        isOpen={conflictDiffModalStatus?.isOpened}
+        onClose={() => closeConflictDiffModal()}
+        markdownOnEdit={markdownToPreview}
+        optionsToSave={undefined} // replace undefined
+        afterResolvedHandler={afterResolvedHandler}
+      />
     </div>
   );
 });

+ 98 - 45
packages/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -2,7 +2,6 @@ import React, {
   useState, useEffect, useRef, useMemo, useCallback,
 } from 'react';
 
-import type { IUser } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import CodeMirror from 'codemirror/lib/codemirror';
 import { format } from 'date-fns';
@@ -11,9 +10,14 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import { useSaveOrUpdate } from '~/client/services/page-operation';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { OptionsToSave } from '~/interfaces/page-operation';
-import { useCurrentUser } from '~/stores/context';
-import { useEditorMode } from '~/stores/ui';
+import { useCurrentPageId, useCurrentPathname, useCurrentUser } from '~/stores/context';
+import { useCurrentPagePath, useSWRxCurrentPage } from '~/stores/page';
+import {
+  useRemoteRevisionBody, useRemoteRevisionId, useRemoteRevisionLastUpdatedAt, useRemoteRevisionLastUpdateUser, useSetRemoteLatestPageData,
+} from '~/stores/remote-latest-page';
 
 import { IRevisionOnConflict } from '../../interfaces/revision';
 import ExpandOrContractButton from '../ExpandOrContractButton';
@@ -29,19 +33,29 @@ Object.keys(DMP).forEach((key) => { window[key] = DMP[key] });
 type ConflictDiffModalProps = {
   isOpen?: boolean;
   onClose?: (() => void);
-  // pageContainer: PageContainer;
   markdownOnEdit: string;
   optionsToSave: OptionsToSave | undefined;
+  afterResolvedHandler: () => void,
+};
+
+type ConflictDiffModalCoreProps = {
+  isOpen?: boolean;
+  onClose?: (() => void);
+  optionsToSave: OptionsToSave | undefined;
+  request: IRevisionOnConflictWithStringDate,
+  origin: IRevisionOnConflictWithStringDate,
+  latest: IRevisionOnConflictWithStringDate,
+  afterResolvedHandler: () => void,
 };
 
 type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
   createdAt: string
 }
 
-const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IUser }): JSX.Element => {
-  const { currentUser, onClose } = props;
-
-  const { data: editorMode } = useEditorMode();
+const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element => {
+  const {
+    onClose, request, origin, latest, optionsToSave, afterResolvedHandler,
+  } = props;
 
   const { t } = useTranslation('');
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
@@ -49,37 +63,15 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
   const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
   const [codeMirrorRef, setCodeMirrorRef] = useState<HTMLDivElement | null>(null);
 
-  const uncontrolledRef = useRef<CodeMirror>(null);
+  const { data: remoteRevisionId } = useRemoteRevisionId();
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+  const { data: pageId } = useCurrentPageId();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: currentPathname } = useCurrentPathname();
 
-  const currentTime: Date = new Date();
+  const saveOrUpdate = useSaveOrUpdate();
 
-  const request: IRevisionOnConflictWithStringDate = {
-    revisionId: '',
-    revisionBody: props.markdownOnEdit,
-    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
-    user: currentUser,
-  };
-  const origin: IRevisionOnConflictWithStringDate = {
-    // revisionId: pageContainer.state.revisionId || '',
-    // revisionBody: pageContainer.state.markdown || '',
-    // createdAt: pageContainer.state.updatedAt || '',
-    // user: pageContainer.state.revisionAuthor,
-    revisionId:  '',
-    revisionBody: '',
-    createdAt: '',
-    user: {} as IUser,
-  };
-  const latest: IRevisionOnConflictWithStringDate = {
-    // revisionId: pageContainer.state.remoteRevisionId || '',
-    // revisionBody: pageContainer.state.remoteRevisionBody || '',
-    // createdAt: format(new Date(pageContainer.state.remoteRevisionUpdateAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
-    // user: pageContainer.state.lastUpdateUser,
-    revisionId: '',
-    revisionBody: '',
-    createdAt: format(new Date(''), 'yyyy/MM/dd HH:mm:ss'),
-    user: {} as IUser,
-
-  };
+  const uncontrolledRef = useRef<CodeMirror>(null);
 
   useEffect(() => {
     if (codeMirrorRef != null) {
@@ -105,21 +97,38 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
   }, [onClose]);
 
   const onResolveConflict = useCallback(async() => {
+    if (currentPathname == null) { return }
     // disable button after clicked
     setIsRevisionSelected(false);
 
     const codeMirrorVal = uncontrolledRef.current?.editor.doc.getValue();
 
     try {
-      // await pageContainer.resolveConflict(codeMirrorVal, editorMode, props.optionsToSave);
-      // close();
-      // pageContainer.showSuccessToastr();
+      const { page } = await saveOrUpdate(
+        codeMirrorVal,
+        { pageId, path: currentPagePath || currentPathname, revisionId: remoteRevisionId },
+        optionsToSave,
+      );
+      const remotePageData = {
+        remoteRevisionId: page.revision._id,
+        remoteRevisionBody: page.revision.body,
+        remoteRevisionLastUpdateUser: page.lastUpdateUser,
+        remoteRevisionLastUpdatedAt: page.updatedAt,
+        revisionIdHackmdSynced: page.revisionIdHackmdSynced,
+        hasDraftOnHackmd: page.hasDraftOnHackmd,
+      };
+      setRemoteLatestPageData(remotePageData);
+      afterResolvedHandler();
+
+      close();
+
+      toastSuccess('Saved successfully');
     }
     catch (error) {
-      // pageContainer.showErrorToastr(error);
+      toastError(`Error occured: ${error.message}`);
     }
 
-  }, []);
+  }, [afterResolvedHandler, close, currentPagePath, currentPathname, optionsToSave, pageId, remoteRevisionId, saveOrUpdate, setRemoteLatestPageData]);
 
   const resizeAndCloseButtons = useMemo(() => (
     <div className="d-flex flex-nowrap">
@@ -274,12 +283,56 @@ const ConflictDiffModalCore = (props: ConflictDiffModalProps & { currentUser: IU
 
 
 export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element => {
-  const { isOpen } = props;
+  const {
+    isOpen, onClose, optionsToSave, afterResolvedHandler,
+  } = props;
   const { data: currentUser } = useCurrentUser();
 
-  if (!isOpen || currentUser == null) {
+  // state for current page
+  const { data: currentPage } = useSWRxCurrentPage();
+
+  // state for latest page
+  const { data: remoteRevisionId } = useRemoteRevisionId();
+  const { data: remoteRevisionBody } = useRemoteRevisionBody();
+  const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
+  const { data: remoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
+
+  const currentTime: Date = new Date();
+
+  const isRemotePageDataInappropriate = remoteRevisionId == null || remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
+
+  if (!isOpen || currentUser == null || currentPage == null || isRemotePageDataInappropriate) {
     return <></>;
   }
 
-  return <ConflictDiffModalCore {...props} currentUser={currentUser} />;
+  const request: IRevisionOnConflictWithStringDate = {
+    revisionId: '',
+    revisionBody: props.markdownOnEdit,
+    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+    user: currentUser,
+  };
+  const origin: IRevisionOnConflictWithStringDate = {
+    revisionId: currentPage?.revision._id,
+    revisionBody: currentPage?.revision.body,
+    createdAt: format(currentPage.updatedAt, 'yyyy/MM/dd HH:mm:ss'),
+    user: currentPage?.lastUpdateUser,
+  };
+  const latest: IRevisionOnConflictWithStringDate = {
+    revisionId: remoteRevisionId,
+    revisionBody: remoteRevisionBody,
+    createdAt: format(new Date(remoteRevisionLastUpdatedAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
+    user: remoteRevisionLastUpdateUser,
+  };
+
+  const propsForCore = {
+    isOpen,
+    onClose,
+    optionsToSave,
+    request,
+    origin,
+    latest,
+    afterResolvedHandler,
+  };
+
+  return <ConflictDiffModalCore {...propsForCore}/>;
 };

+ 1 - 1
packages/app/src/components/PageEditor/DrawioCommunicationHelper.ts

@@ -1,4 +1,4 @@
-import { extractCodeFromMxfile } from '@growi/remark-drawio-plugin';
+import { extractCodeFromMxfile } from '@growi/remark-drawio';
 
 import loggerFactory from '~/utils/logger';
 

+ 16 - 14
packages/app/src/components/PageEditorByHackmd.tsx

@@ -10,7 +10,7 @@ import { useRouter } from 'next/router';
 import { useTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave, useSaveOrUpdate } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
 import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
@@ -25,7 +25,7 @@ import {
   usePageIdOnHackmd, useHasDraftOnHackmd, useRevisionIdHackmdSynced, useIsHackmdDraftUpdatingInRealtime,
 } from '~/stores/hackmd';
 import { useCurrentPagePath, useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
-import { useRemoteRevisionId } from '~/stores/remote-latest-page';
+import { useRemoteRevisionId, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import {
   EditorMode,
   useEditorMode, useSelectedGrant,
@@ -85,8 +85,10 @@ export const PageEditorByHackmd = (): JSX.Element => {
   const { data: hasDraftOnHackmd, mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: revisionIdHackmdSynced, mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime(false);
-  const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId(revision?._id);
+  const { data: isHackmdDraftUpdatingInRealtime, mutate: mutateIsHackmdDraftUpdatingInRealtime } = useIsHackmdDraftUpdatingInRealtime();
+  const { data: remoteRevisionId, mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
+
+  const updateStateAfterSave = useUpdateStateAfterSave();
 
   const hackmdEditorRef = useRef<HackEditorRef>(null);
 
@@ -94,7 +96,8 @@ export const PageEditorByHackmd = (): JSX.Element => {
     if (editorMode !== EditorMode.HackMD) { return }
 
     try {
-      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null || revision == null || hackmdEditorRef.current == null) {
+      if (isSlackEnabled == null || currentPathname == null || slackChannels == null || grant == null
+          || revision == null || hackmdEditorRef.current == null || revisionIdHackmdSynced == null) {
         throw new Error('Some materials to save are invalid');
       }
 
@@ -111,7 +114,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
 
       const markdown = await hackmdEditorRef.current.getValue();
 
-      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revision?._id }, optionsToSave);
+      const { page } = await saveOrUpdate(markdown, { pageId, path: currentPagePath || currentPathname, revisionId: revisionIdHackmdSynced }, optionsToSave);
       await mutatePageData();
       await mutateTagsInfo();
 
@@ -122,9 +125,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
         await router.push(`/${page._id}`);
       }
       else {
-        await mutateCurrentPageId(page._id);
-        await mutatePageData();
+        updateStateAfterSave(page._id);
       }
+      setIsInitialized(false);
       mutateEditorMode(EditorMode.View);
     }
     catch (error) {
@@ -132,7 +135,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   // eslint-disable-next-line max-len
-  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, mutateCurrentPageId]);
+  }, [editorMode, isSlackEnabled, currentPathname, slackChannels, grant, revision, revisionIdHackmdSynced, pageTags, saveOrUpdate, pageId, currentPagePath, mutatePageData, mutateTagsInfo, isNotFound, mutateEditorMode, router, useUpdateStateAfterSave]);
 
   // set handler to save and reload Page
   useEffect(() => {
@@ -253,9 +256,7 @@ export const PageEditorByHackmd = (): JSX.Element => {
       mutatePageData(res);
 
       // set updated data
-      mutateRemoteRevisionId(res.revision._id);
-      mutateRevisionIdHackmdSynced(res.page.revisionHackmdSynced);
-      mutateHasDraftOnHackmd(res.page.hasDraftOnHackmd);
+      updateStateAfterSave(res._id);
       mutateTagsInfo();
 
       logger.debug('success to save');
@@ -267,8 +268,9 @@ export const PageEditorByHackmd = (): JSX.Element => {
       toastError(error.message);
     }
   }, [
-    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced, pageTags,
-    saveOrUpdate, mutatePageData, mutateRemoteRevisionId, mutateRevisionIdHackmdSynced, mutateHasDraftOnHackmd, mutateTagsInfo, t]);
+    currentPagePath, currentPathname, isSlackEnabled, grant, slackChannels, pageId, revisionIdHackmdSynced,
+    pageTags, saveOrUpdate, mutatePageData, useUpdateStateAfterSave, mutateTagsInfo, t,
+  ]);
 
   /**
    * onChange event of HackmdEditor handler

+ 9 - 6
packages/app/src/components/PageStatusAlert.tsx

@@ -7,8 +7,9 @@ import { useEditingMarkdown, useIsConflict } from '~/stores/editor';
 import {
   useHasDraftOnHackmd, useIsHackmdDraftUpdatingInRealtime, useRevisionIdHackmdSynced,
 } from '~/stores/hackmd';
+import { useConflictDiffModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
-import { useRemoteRevisionId, useRemoteRevisionLastUpdatUser } from '~/stores/remote-latest-page';
+import { useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/stores/remote-latest-page';
 
 import { Username } from './User/Username';
 
@@ -27,11 +28,12 @@ export const PageStatusAlert = (): JSX.Element => {
   const { data: hasDraftOnHackmd } = useHasDraftOnHackmd();
   const { data: isConflict } = useIsConflict();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { open: openConflictDiffModal } = useConflictDiffModal();
 
   // store remote latest page data
   const { data: revisionIdHackmdSynced } = useRevisionIdHackmdSynced();
   const { data: remoteRevisionId } = useRemoteRevisionId();
-  const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdatUser();
+  const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
 
   const { data: pageData, mutate: mutatePageData } = useSWRxCurrentPage();
   const revision = pageData?.revision;
@@ -42,10 +44,8 @@ export const PageStatusAlert = (): JSX.Element => {
   }, [mutateEditingMarkdown, mutatePageData]);
 
   const onClickResolveConflict = useCallback(() => {
-    // this.props.pageContainer.setState({
-    //   isConflictDiffModalOpen: true,
-    // });
-  }, []);
+    openConflictDiffModal();
+  }, [openConflictDiffModal]);
 
   const getContentsForSomeoneEditingAlert = useCallback((): AlertComponentContents => {
     return {
@@ -119,6 +119,9 @@ export const PageStatusAlert = (): JSX.Element => {
     const isRevisionOutdated = revision?._id !== remoteRevisionId;
     const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
 
+    // 'revision?._id' and 'remoteRevisionId' are can not be undefined
+    if (revision?._id == null || remoteRevisionId == null) { return }
+
     // when remote revision is newer than both
     if (isHackmdDocumentOutdated && isRevisionOutdated) {
       return getContentsForUpdatedAlert();

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

@@ -103,7 +103,7 @@ const PutBackPageModal = () => {
     return (
       <>
         <ApiErrorMessageList errs={errs} targetPath={targetPath} />
-        <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler}>
+        <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler} data-testid="put-back-execution-button">
           <i className="icon-action-undo mr-2" aria-hidden="true"></i> { t('Put Back') }
         </button>
       </>
@@ -116,7 +116,7 @@ const PutBackPageModal = () => {
   }, [closePutBackPageModal]);
 
   return (
-    <Modal isOpen={isOpened} toggle={closeModalHandler}>
+    <Modal isOpen={isOpened} toggle={closeModalHandler} data-testid="put-back-page-modal">
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-info text-light">
         <HeaderContent/>
       </ModalHeader>

+ 1 - 1
packages/app/src/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -5,7 +5,7 @@ import EventEmitter from 'events';
 import {
   DrawioEditByViewerProps,
   DrawioViewer, DrawioViewerProps, extractCodeFromMxfile,
-} from '@growi/remark-drawio-plugin';
+} from '@growi/remark-drawio';
 import { useTranslation } from 'next-i18next';
 
 import { useIsGuestUser, useIsSharedUser, useShareLinkId } from '~/stores/context';

+ 1 - 1
packages/app/src/components/Script/DrawioViewerScript.tsx

@@ -1,6 +1,6 @@
 import { useCallback } from 'react';
 
-import type { IGraphViewerGlobal } from '@growi/remark-drawio-plugin';
+import type { IGraphViewerGlobal } from '@growi/remark-drawio';
 import Script from 'next/script';
 
 declare global {

+ 28 - 21
packages/app/src/components/Sidebar/PageTree/Item.tsx

@@ -14,6 +14,7 @@ import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/p
 import { toastWarning, toastError, toastSuccess } from '~/client/util/apiNotification';
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import TriangleIcon from '~/components/Icons/TriangleIcon';
+import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
@@ -481,34 +482,40 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
             <CountBadge count={descendantCount} />
           </div>
         )}
-        <div className="grw-pagetree-control d-flex">
-          <PageItemControl
-            pageId={page._id}
-            isEnableActions={isEnableActions}
-            onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
-            onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
-            onClickRenameMenuItem={renameMenuItemClickHandler}
-            onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
-            isInstantRename
-            // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-            operationProcessData={page.processData}
-          >
-            {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
-              <i className="icon-options fa fa-rotate-90 p-1"></i>
-            </DropdownToggle>
-          </PageItemControl>
-          {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
+        <NotAvailableForGuest>
+          <div className="grw-pagetree-control d-flex">
+            <PageItemControl
+              pageId={page._id}
+              isEnableActions={isEnableActions}
+              onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
+              onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
+              onClickRenameMenuItem={renameMenuItemClickHandler}
+              onClickDeleteMenuItem={deleteMenuItemClickHandler}
+              onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
+              isInstantRename
+              // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+              operationProcessData={page.processData}
+            >
+              {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
+              <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover mr-1">
+                <i id='option-button-in-page-tree' className="icon-options fa fa-rotate-90 p-1"></i>
+              </DropdownToggle>
+            </PageItemControl>
+          </div>
+        </NotAvailableForGuest>
+
+        {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
+          <NotAvailableForGuest>
             <button
+              id='page-create-button-in-page-tree'
               type="button"
               className="border-0 rounded btn btn-page-item-control p-0 grw-visible-on-hover"
               onClick={onClickPlusButton}
             >
               <i className="icon-plus d-block p-0" />
             </button>
-          )}
-        </div>
+          </NotAvailableForGuest>
+        )}
       </li>
 
       {isEnableActions && isNewPageInputShown && (

+ 7 - 5
packages/app/src/styles/molecules/slack-notification.scss → packages/app/src/components/SlackNotification.module.scss

@@ -1,6 +1,8 @@
-.grw-slack-notification {
-  $input-height-slack: $custom-control-indicator-size * 1.5;
-  border-color: $gray-200;
+@use '~/styles/bootstrap/init' as bs;
+
+.grw-slack-notification :global {
+  $input-height-slack: bs.$custom-control-indicator-size * 1.5;
+  border-color: bs.$gray-200;
 
   border-style: solid;
   border-width: 1px;
@@ -9,10 +11,10 @@
   .form-control {
     height: $input-height-slack;
     border: transparent;
-    @include media-breakpoint-up(sm) {
+    @include bs.media-breakpoint-up(sm) {
       width: 130px;
     }
-    @include media-breakpoint-up(md) {
+    @include bs.media-breakpoint-up(md) {
       width: 180px;
     }
   }

+ 3 - 1
packages/app/src/components/SlackNotification.tsx

@@ -4,6 +4,8 @@ import React, { FC } from 'react';
 import { useTranslation } from 'next-i18next';
 import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
 
+import styles from './SlackNotification.module.scss';
+
 
 type SlackNotificationProps = {
   id: string;
@@ -35,7 +37,7 @@ export const SlackNotification: FC<SlackNotificationProps> = ({
 
 
   return (
-    <div className="grw-slack-notification w-100">
+    <div className={`grw-slack-notification ${styles['grw-slack-notification']} w-100`}>
       <div className="grw-input-group-slack-notification input-group extended-setting">
         <label className="input-group-addon">
           <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">

+ 1 - 4
packages/app/src/components/SubscribeButton.tsx

@@ -20,15 +20,12 @@ const SubscribeButton: FC<Props> = (props: Props) => {
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
   const getTooltipMessage = useCallback(() => {
-    if (isGuestUser) {
-      return 'Not available for guest';
-    }
 
     if (isSubscribing) {
       return 'tooltip.stop_notification';
     }
     return 'tooltip.receive_notifications';
-  }, [isGuestUser, isSubscribing]);
+  }, [isSubscribing]);
 
   return (
     <>

+ 0 - 32
packages/app/src/components/Theme/ThemeAntarctic.tsx

@@ -1,32 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeAntarctic.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    default:
-      return '/images/themes/antarctic/bg.svg';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeAntarctic = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-antarctic" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeAntarctic;

+ 0 - 8
packages/app/src/components/Theme/ThemeBlackboard.tsx

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

+ 0 - 32
packages/app/src/components/Theme/ThemeChristmas.tsx

@@ -1,32 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeChristmas.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    default:
-      return '/images/themes/christmas/christmas.jpg';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeChristmas = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-christmas" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeChristmas;

+ 0 - 8
packages/app/src/components/Theme/ThemeDefault.tsx

@@ -1,8 +0,0 @@
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeDefault.module.scss';
-
-const ThemeDefault = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector>{children}</ThemeInjector>;
-};
-export default ThemeDefault;

+ 0 - 8
packages/app/src/components/Theme/ThemeFireRed.tsx

@@ -1,8 +0,0 @@
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeFireRed.module.scss';
-
-const ThemeFireRed = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector className="theme-fire-red">{children}</ThemeInjector>;
-};
-export default ThemeFireRed;

+ 0 - 8
packages/app/src/components/Theme/ThemeFuture.tsx

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

+ 0 - 32
packages/app/src/components/Theme/ThemeHalloween.tsx

@@ -1,32 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeHalloween.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    default:
-      return '/images/themes/halloween/halloween.jpg';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeHalloween = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-halloween" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeHalloween;

+ 0 - 36
packages/app/src/components/Theme/ThemeHufflepuff.tsx

@@ -1,36 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeHufflepuff.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    case Themes.light:
-      return '/images/themes/hufflepuff/badger-light3.png';
-    case Themes.dark:
-      return '/images/themes/hufflepuff/badger-dark.jpg';
-    default:
-      return '/images/themes/hufflepuff/badger-light3.png';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeHufflepuff = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-hufflepuff" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeHufflepuff;

+ 0 - 32
packages/app/src/components/Theme/ThemeIsland.tsx

@@ -1,32 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeIsland.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    default:
-      return '/images/themes/island/island.png';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeIsland = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-island" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeIsland;

+ 0 - 8
packages/app/src/components/Theme/ThemeJadeGreen.tsx

@@ -1,8 +0,0 @@
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeJadeGreen.module.scss';
-
-const ThemeJadeGreen = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector className="theme-jade-green">{children}</ThemeInjector>;
-};
-export default ThemeJadeGreen;

+ 0 - 8
packages/app/src/components/Theme/ThemeKibela.tsx

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

+ 0 - 8
packages/app/src/components/Theme/ThemeMonoBlue.tsx

@@ -1,8 +0,0 @@
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeMonoBlue.module.scss';
-
-const ThemeMonoBlue = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector className="theme-mono-blue">{children}</ThemeInjector>;
-};
-export default ThemeMonoBlue;

+ 0 - 8
packages/app/src/components/Theme/ThemeNature.tsx

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

+ 0 - 32
packages/app/src/components/Theme/ThemeSpring.tsx

@@ -1,32 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeSpring.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    default:
-      return '/images/themes/spring/spring02.svg';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeSpring = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-spring" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeSpring;

+ 0 - 32
packages/app/src/components/Theme/ThemeWood.tsx

@@ -1,32 +0,0 @@
-import Image from 'next/image';
-
-import { Themes } from '~/stores/use-next-themes';
-
-import { ThemeInjector } from './utils/ThemeInjector';
-
-// import styles from './ThemeWood.module.scss';
-
-export const getBackgroundImageSrc = (colorScheme: Themes): string => {
-  switch (colorScheme) {
-    default:
-      return '/images/themes/wood/wood.jpg';
-  }
-};
-
-type Props = {
-  children: JSX.Element,
-  colorScheme?: Themes,
-}
-
-const ThemeWood = ({ children, colorScheme }: Props): JSX.Element => {
-  const bgImageNode = (
-    <>
-      {colorScheme != null && (
-        <Image alt='background image' src={getBackgroundImageSrc(colorScheme)} layout='fill' quality="100" />
-      )}
-    </>
-  );
-  return <ThemeInjector className="theme-wood" bgImageNode={bgImageNode}>{children}</ThemeInjector>;
-};
-
-export default ThemeWood;

+ 0 - 36
packages/app/src/components/Theme/utils/ThemeInjector.tsx

@@ -1,36 +0,0 @@
-
-import React from 'react';
-
-import { useIsomorphicLayoutEffect } from 'usehooks-ts';
-
-type Props = {
-  children: JSX.Element,
-  bodyTagClassName?: string,
-  className?: string,
-  bgImageNode?: React.ReactNode,
-}
-
-export const ThemeInjector = ({
-  children, bodyTagClassName, className: childrenClassName, bgImageNode,
-}: Props): JSX.Element => {
-  const className = `${children.props.className ?? ''} ${childrenClassName ?? ''}`;
-
-  // add class name to <body>
-  useIsomorphicLayoutEffect(() => {
-    if (bodyTagClassName != null) {
-      document.body.classList.add(bodyTagClassName);
-    }
-
-    // clean up
-    return () => {
-      if (bodyTagClassName != null) {
-        document.body.classList.remove(bodyTagClassName);
-      }
-    };
-  });
-
-  return React.cloneElement(children, { className }, [
-    <div key="grw-bg-image-wrapper" className="grw-bg-image-wrapper">{bgImageNode}</div>,
-    children.props.children,
-  ]);
-};

+ 0 - 66
packages/app/src/components/Theme/utils/ThemeProvider.tsx

@@ -1,66 +0,0 @@
-
-import React from 'react';
-
-import dynamic from 'next/dynamic';
-
-import { GrowiThemes } from '~/interfaces/theme';
-import { Themes } from '~/stores/use-next-themes';
-
-
-// const ThemeAntarctic = dynamic(() => import('../ThemeAntarctic'));
-// const ThemeBlackboard = dynamic(() => import('../ThemeBlackboard'));
-// const ThemeChristmas = dynamic(() => import('../ThemeChristmas'));
-const ThemeDefault = dynamic(() => import('../ThemeDefault'));
-// const ThemeFireRed = dynamic(() => import('../ThemeFireRed'));
-// const ThemeFuture = dynamic(() => import('../ThemeFuture'));
-// const ThemeHalloween = dynamic(() => import('../ThemeHalloween'));
-// const ThemeHufflepuff = dynamic(() => import('../ThemeHufflepuff'));
-// const ThemeIsland = dynamic(() => import('../ThemeIsland'));
-// const ThemeJadeGreen = dynamic(() => import('../ThemeJadeGreen'));
-// const ThemeKibela = dynamic(() => import('../ThemeKibela'));
-// const ThemeMonoBlue = dynamic(() => import('../ThemeMonoBlue'));
-// const ThemeNature = dynamic(() => import('../ThemeNature'));
-// const ThemeSpring = dynamic(() => import('../ThemeSpring'));
-// const ThemeWood = dynamic(() => import('../ThemeWood'));
-
-
-type Props = {
-  children: JSX.Element,
-  theme?: GrowiThemes,
-  colorScheme?: Themes,
-}
-
-export const ThemeProvider = ({ theme, children, colorScheme }: Props): JSX.Element => {
-  switch (theme) {
-    // case GrowiThemes.ANTARCTIC:
-    //   return <ThemeAntarctic colorScheme={colorScheme}>{children}</ThemeAntarctic>;
-    // case GrowiThemes.BLACKBOARD:
-    //   return <ThemeBlackboard>{children}</ThemeBlackboard>;
-    // case GrowiThemes.CHRISTMAS:
-    //   return <ThemeChristmas colorScheme={colorScheme}>{children}</ThemeChristmas>;
-    // case GrowiThemes.FIRE_RED:
-    //   return <ThemeFireRed>{children}</ThemeFireRed>;
-    // case GrowiThemes.FUTURE:
-    //   return <ThemeFuture>{children}</ThemeFuture>;
-    // case GrowiThemes.HALLOWEEN:
-    //   return <ThemeHalloween colorScheme={colorScheme}>{children}</ThemeHalloween>;
-    // case GrowiThemes.HUFFLEPUFF:
-    //   return <ThemeHufflepuff colorScheme={colorScheme}>{children}</ThemeHufflepuff>;
-    // case GrowiThemes.ISLAND:
-    //   return <ThemeIsland colorScheme={colorScheme}>{children}</ThemeIsland>;
-    // case GrowiThemes.JADE_GREEN:
-    //   return <ThemeJadeGreen>{children}</ThemeJadeGreen>;
-    // case GrowiThemes.KIBELA:
-    //   return <ThemeKibela>{children}</ThemeKibela>;
-    // case GrowiThemes.MONO_BLUE:
-    //   return <ThemeMonoBlue>{children}</ThemeMonoBlue>;
-    // case GrowiThemes.NATURE:
-    //   return <ThemeNature>{children}</ThemeNature>;
-    // case GrowiThemes.SPRING:
-    //   return <ThemeSpring colorScheme={colorScheme}>{children}</ThemeSpring>;
-    // case GrowiThemes.WOOD:
-    //   return <ThemeWood colorScheme={colorScheme}>{children}</ThemeWood>;
-    default:
-      return <ThemeDefault>{children}</ThemeDefault>;
-  }
-};

+ 2 - 2
packages/app/src/components/User/SeenUserInfo.module.scss

@@ -1,12 +1,12 @@
 @use '~/styles/bootstrap/init' as bs;
-@use '~/styles/mixins';
+@use '~/styles/atoms/mixins/buttons' as mixins-buttons;
 
 .grw-seen-user-info :global {
   .btn.btn-seen-user {
     $color-seen-user: #549c79;
 
     @include bs.button-outline-variant($color-seen-user, $color-seen-user, rgba(lighten($color-seen-user, 10%), 0.15), rgba(lighten($color-seen-user, 10%), 0.5));
-    @include mixins.button-outline-svg-icon-variant($color-seen-user, $color-seen-user);
+    @include mixins-buttons.button-outline-svg-icon-variant($color-seen-user, $color-seen-user);
 
     &:not(:disabled):not(.disabled):active,
     &:not(:disabled):not(.disabled).active {

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

@@ -1,3 +1,7 @@
 export type IResLayoutSetting = {
   isContainerFluid: boolean,
 };
+
+export type IResGrowiTheme = {
+  theme: string,
+}

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

@@ -1,6 +1,7 @@
 export const GrowiPluginResourceType = {
   Template: 'template',
   Style: 'style',
+  Theme: 'theme',
   Script: 'script',
 } as const;
 export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];

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

@@ -22,6 +22,9 @@ export const SocketEventName = {
   PageUpdated: 'page:update',
   PageDeleted: 'page:delete',
 
+  // Hackmd
+  EditingWithHackmd: 'page:editingWithHackmd',
+
 } as const;
 export type SocketEventName = typeof SocketEventName[keyof typeof SocketEventName];
 

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

@@ -37,8 +37,10 @@ import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
 import { useEditingMarkdown } from '~/stores/editor';
+import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import { useSWRxCurrentPage, useSWRxIsGrantNormalized } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
+import { useRemoteRevisionId } from '~/stores/remote-latest-page';
 import {
   useEditorMode, useSelectedGrant,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -257,6 +259,10 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   const pagePath = pageWithMeta?.data.path ?? (!_isPermalink(props.currentPathname) ? props.currentPathname : undefined);
 
   useCurrentPageId(pageId ?? null);
+  useRevisionIdHackmdSynced(pageWithMeta?.data.revisionHackmdSynced);
+  useRemoteRevisionId(pageWithMeta?.data.revision._id);
+  usePageIdOnHackmd(pageWithMeta?.data.pageIdOnHackmd);
+  useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd);
   // useIsNotCreatable(props.isForbidden || !isCreatablePage(pagePath)); // TODO: need to include props.isIdentical
   useCurrentPathname(props.currentPathname);
 

+ 2 - 4
packages/app/src/pages/_app.page.tsx

@@ -10,7 +10,7 @@ import * as nextI18nConfig from '^/config/next-i18next.config';
 import { ActivatePluginService } from '~/client/services/activate-plugin';
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
-  useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
+  useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
 } from '~/stores/context';
 import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
 
@@ -18,8 +18,7 @@ import { SWRConfigValue, swrGlobalConfiguration } from '~/utils/swr-utils';
 import { CommonProps } from './utils/commons';
 import { registerTransformerForObjectId } from './utils/objectid-transformer';
 
-import '~/styles/style-next.scss';
-import '~/styles/style-themes.scss';
+import '~/styles/style-app.scss';
 
 
 const isDev = process.env.NODE_ENV === 'development';
@@ -56,7 +55,6 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
   useAppTitle(commonPageProps.appTitle);
   useSiteUrl(commonPageProps.siteUrl);
   useConfidential(commonPageProps.confidential);
-  useGrowiTheme(commonPageProps.theme);
   useGrowiVersion(commonPageProps.growiVersion);
   useCustomizedLogoSrc(commonPageProps.customizedLogoSrc);
 

+ 48 - 7
packages/app/src/pages/_document.page.tsx

@@ -1,6 +1,8 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
+import type { PresetThemesManifest } from '@growi/preset-themes';
+import { getManifestKeyFromTheme } from '@growi/preset-themes';
 import mongoose from 'mongoose';
 import Document, {
   DocumentContext, DocumentInitialProps,
@@ -8,13 +10,39 @@ import Document, {
 } from 'next/document';
 
 import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
-import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { GrowiPlugin, GrowiPluginResourceType } from '~/interfaces/plugin';
+import type { GrowiThemes } from '~/interfaces/theme';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:page:_document');
+
+type HeadersForPresetThemesProps = {
+  theme: GrowiThemes,
+  manifest: PresetThemesManifest,
+}
+const HeadersForPresetThemes = (props: HeadersForPresetThemesProps): JSX.Element => {
+  const { theme, manifest } = props;
+
+  let themeKey = getManifestKeyFromTheme(theme);
+  if (!(themeKey in manifest)) {
+    logger.warn(`The key for '${theme} does not exist in preset-themes manifest`);
+    themeKey = getManifestKeyFromTheme('default');
+  }
+  const href = `/static/preset-themes/${manifest[themeKey].file}`; // configured by express.static
+
+  const elements: JSX.Element[] = [];
+
+  elements.push(
+    <link rel="stylesheet" key={`link_preset-themes-${theme}`} href={href} />,
+  );
+
+  return <>{elements}</>;
+};
 
 type HeadersForGrowiPluginProps = {
   pluginManifestEntries: GrowiPluginManifestEntries;
 }
-
 const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element => {
   const { pluginManifestEntries } = props;
 
@@ -30,14 +58,14 @@ const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element =
           elements.push(<>
             {/* eslint-disable-next-line @next/next/no-sync-scripts */ }
             <script type="module" key={`script_${growiPlugin.installedPath}`}
-              src={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
+              src={`/static/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
           </>);
         }
         // add link
         if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
           elements.push(<>
             <link rel="stylesheet" key={`link_${growiPlugin.installedPath}`}
-              href={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
+              href={`/static/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
           </>);
         }
 
@@ -48,7 +76,9 @@ const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element =
 };
 
 interface GrowiDocumentProps {
+  theme: GrowiThemes,
   customCss: string;
+  presetThemesManifest: PresetThemesManifest,
   pluginManifestEntries: GrowiPluginManifestEntries;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
@@ -58,18 +88,28 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { customizeService } = crowi;
+    const { configManager, customizeService } = crowi;
+
+    const theme = configManager.getConfig('crowi', 'customize:theme');
     const customCss: string = customizeService.getCustomCss();
 
+    // import preset-themes manifest
+    const presetThemesManifest = await import('@growi/preset-themes/dist/themes/manifest.json').then(imported => imported.default);
+
+    // retrieve plugin manifests
     const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     const growiPlugins = await GrowiPlugin.find({ isEnabled: true });
     const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests(growiPlugins);
 
-    return { ...initialProps, customCss, pluginManifestEntries };
+    return {
+      ...initialProps, theme, customCss, presetThemesManifest, pluginManifestEntries,
+    };
   }
 
   override render(): JSX.Element {
-    const { customCss, pluginManifestEntries } = this.props;
+    const {
+      customCss, theme, presetThemesManifest, pluginManifestEntries,
+    } = this.props;
 
     return (
       <Html>
@@ -87,6 +127,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
+          <HeadersForPresetThemes theme={theme} manifest={presetThemesManifest} />
           <HeadersForGrowiPlugin pluginManifestEntries={pluginManifestEntries} />
         </Head>
         <body>

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

@@ -7,7 +7,6 @@ import { SSRConfig, UserConfig } from 'next-i18next';
 import * as nextI18NextConfig from '^/config/next-i18next.config';
 
 import { CrowiRequest } from '~/interfaces/crowi-request';
-import { GrowiThemes } from '~/interfaces/theme';
 
 export type CommonProps = {
   namespacesRequired: string[], // i18next
@@ -15,7 +14,6 @@ export type CommonProps = {
   appTitle: string,
   siteUrl: string,
   confidential: string,
-  theme: GrowiThemes,
   customTitleTemplate: string,
   csrfToken: string,
   isContainerFluid: boolean,
@@ -55,7 +53,6 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     appTitle: appService.getAppTitle(),
     siteUrl: configManager.getConfig('crowi', 'app:siteUrl'), // DON'T USE appService.getSiteUrl()
     confidential: appService.getAppConfidential() || '',
-    theme: configManager.getConfig('crowi', 'customize:theme'),
     customTitleTemplate: customizeService.customTitleTemplate,
     csrfToken: req.csrfToken(),
     isContainerFluid: configManager.getConfig('crowi', 'customize:isContainerFluid') ?? false,

+ 6 - 1
packages/app/src/server/crowi/express-init.js

@@ -1,9 +1,11 @@
+import { manifestPath as presetThemesManifestPath } from '@growi/preset-themes';
 import csrf from 'csurf';
 import mongoose from 'mongoose';
 
 import { i18n, localePath } from '^/config/next-i18next.config';
 
 import loggerFactory from '~/utils/logger';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 const logger = loggerFactory('growi:crowi:express-init');
 
@@ -115,7 +117,10 @@ module.exports = function(crowi, app) {
 
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   app.use(express.static(crowi.publicDir, staticOption));
-  app.use('/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
+  app.use('/static/preset-themes', express.static(
+    resolveFromRoot(`../../node_modules/@growi/preset-themes/${path.dirname(presetThemesManifestPath)}`),
+  ));
+  app.use('/static/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
 
   app.engine('html', swig.renderFile);
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei

+ 12 - 40
packages/app/src/server/routes/apiv3/customize-setting.js

@@ -108,11 +108,8 @@ module.exports = (crowi) => {
     layout: [
       body('isContainerFluid').isBoolean(),
     ],
-    themeAssetPath: [
-      query('themeName').isString(),
-    ],
     theme: [
-      body('themeType').isString(),
+      body('theme').isString(),
     ],
     sidebar: [
       body('isSidebarDrawerMode').isBoolean(),
@@ -175,7 +172,6 @@ module.exports = (crowi) => {
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
     const customizeParams = {
-      themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       isEnabledTimeline: await crowi.configManager.getConfig('crowi', 'customize:isEnabledTimeline'),
       isEnabledAttachTitleHeader: await crowi.configManager.getConfig('crowi', 'customize:isEnabledAttachTitleHeader'),
       pageLimitationS: await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS'),
@@ -272,41 +268,17 @@ module.exports = (crowi) => {
     }
   });
 
-  /**
-   * @swagger
-   *
-   *    /customize-setting/theme/asset-path:
-   *      put:
-   *        tags: [CustomizeSetting]
-   *        operationId: getThemeAssetPath
-   *        summary: /customize-setting/theme/asset-path
-   *        description: Get theme asset path
-   *        parameters:
-   *          - name: themeName
-   *            in: query
-   *            required: true
-   *            schema:
-   *              type: string
-   *        responses:
-   *          200:
-   *            description: Succeeded to get theme asset path
-   *            content:
-   *              application/json:
-   *                schema:
-   *                  properties:
-   *                    assetPath:
-   *                      type: string
-   */
-  router.get('/theme/asset-path', loginRequiredStrictly, adminRequired, validator.themeAssetPath, apiV3FormValidator, async(req, res) => {
-    const { themeName } = req.query;
-
-    const webpackAssetKey = `styles/theme-${themeName}.css`;
-    const assetPath = res.locals.webpack_asset(webpackAssetKey);
+  router.get('/theme', loginRequiredStrictly, adminRequired, async(req, res) => {
 
-    if (assetPath == null) {
-      return res.apiv3Err(new ErrorV3(`The asset for '${webpackAssetKey}' is undefined.`, 'invalid-asset'));
+    try {
+      const theme = await crowi.configManager.getConfig('crowi', 'customize:theme');
+      return res.apiv3({ theme });
+    }
+    catch (err) {
+      const msg = 'Error occurred in getting theme';
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg, 'get-theme-failed'));
     }
-    return res.apiv3({ assetPath });
   });
 
   /**
@@ -334,13 +306,13 @@ module.exports = (crowi) => {
    */
   router.put('/theme', loginRequiredStrictly, adminRequired, addActivity, validator.theme, apiV3FormValidator, async(req, res) => {
     const requestParams = {
-      'customize:theme': req.body.themeType,
+      'customize:theme': req.body.theme,
     };
 
     try {
       await crowi.configManager.updateConfigsInTheSameNamespace('crowi', requestParams);
       const customizedParams = {
-        themeType: await crowi.configManager.getConfig('crowi', 'customize:theme'),
+        theme: await crowi.configManager.getConfig('crowi', 'customize:theme'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_THEME_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);

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

@@ -104,7 +104,7 @@ module.exports = (crowi, app) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
-  router.use('/plugins-extension', require('./plugins-extension')(crowi));
+  router.use('/plugins', require('./plugins')(crowi));
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 

+ 1 - 2
packages/app/src/server/routes/apiv3/plugins-extension.ts → packages/app/src/server/routes/apiv3/plugins.ts

@@ -16,11 +16,10 @@ module.exports = (crowi: Crowi) => {
     }
 
     try {
-      await pluginService.install(crowi, req.body.pluginInstallerForm);
+      await pluginService.install(req.body.pluginInstallerForm);
       return res.apiv3({});
     }
     catch (err) {
-      // TODO: error handling
       return res.apiv3Err(err, 400);
     }
   });

+ 66 - 38
packages/app/src/server/service/plugin.ts

@@ -1,16 +1,16 @@
-import { execSync } from 'child_process';
 import fs from 'fs';
 import path from 'path';
 
+// eslint-disable-next-line no-restricted-imports
+import axios from 'axios';
 import mongoose from 'mongoose';
+import streamToPromise from 'stream-to-promise';
+import unzipper from 'unzipper';
 
-import type { GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin } from '~/interfaces/plugin';
+import type { GrowiPlugin, GrowiPluginOrigin } from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-// eslint-disable-next-line import/no-cycle
-import Crowi from '../crowi';
-
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
@@ -21,25 +21,12 @@ const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
 export class PluginService {
 
-  crowi: any;
-
-  growiBridgeService: any;
-
-  baseDir: any;
-
-  getFile:any;
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.growiBridgeService = crowi.growiBridgeService;
-    this.baseDir = path.join(crowi.tmpDir, 'plugins');
-    this.getFile = this.growiBridgeService.getFile.bind(this);
-  }
-
-  async install(crowi: Crowi, origin: GrowiPluginOrigin): Promise<void> {
+  async install(origin: GrowiPluginOrigin): Promise<void> {
     // download
     const ghUrl = new URL(origin.url);
     const ghPathname = ghUrl.pathname;
+    // TODO: Branch names can be specified.
+    const ghBranch = 'main';
 
     const match = ghPathname.match(githubReposIdPattern);
     if (ghUrl.hostname !== 'github.com' || match == null) {
@@ -48,13 +35,10 @@ export class PluginService {
 
     const ghOrganizationName = match[1];
     const ghReposName = match[2];
+    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
 
-    try {
-      await this.downloadZipFile(`${ghUrl.href}/archive/refs/heads/main.zip`, ghOrganizationName, ghReposName);
-    }
-    catch (err) {
-      console.log('downloadZipFile error', err);
-    }
+    // download github repository to local file system
+    await this.download(requestUrl, ghOrganizationName, ghReposName, ghBranch);
 
     // save plugin metadata
     const installedPath = `${ghOrganizationName}/${ghReposName}`;
@@ -64,18 +48,63 @@ export class PluginService {
     return;
   }
 
-  async downloadZipFile(url: string, ghOrganizationName: string, ghReposName: string): Promise<void> {
+  async download(requestUrl: string, ghOrganizationName: string, ghReposName: string, ghBranch: string): Promise<void> {
 
-    const downloadTargetPath = pluginStoringPath;
-    const zipFilePath = path.join(downloadTargetPath, 'main.zip');
-    const unzipTargetPath = path.join(pluginStoringPath, ghOrganizationName);
+    const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+    const unzippedPath = path.join(pluginStoringPath, ghOrganizationName);
 
-    const stdout1 = execSync(`wget ${url} -O ${zipFilePath}`);
-    const stdout2 = execSync(`mkdir -p ${ghOrganizationName}`);
-    const stdout3 = execSync(`rm -rf ${ghOrganizationName}/${ghReposName}`);
-    const stdout4 = execSync(`unzip ${zipFilePath} -d ${unzipTargetPath}`);
-    const stdout5 = execSync(`mv ${unzipTargetPath}/${ghReposName}-main ${unzipTargetPath}/${ghReposName}`);
-    const stdout6 = execSync(`rm ${zipFilePath}`);
+    const renamePath = async(oldPath: fs.PathLike, newPath: fs.PathLike) => {
+      fs.renameSync(oldPath, newPath);
+    };
+
+    const downloadFile = async(requestUrl: string, filePath: string) => {
+      return new Promise<void>((resolve, reject) => {
+        axios({
+          method: 'GET',
+          url: requestUrl,
+          responseType: 'stream',
+        })
+          .then((res) => {
+            if (res.status === 200) {
+              const file = fs.createWriteStream(filePath);
+              res.data.pipe(file)
+                .on('close', () => file.close())
+                .on('finish', () => {
+                  return resolve();
+                });
+            }
+            else {
+              return reject(res.status);
+            }
+          }).catch((err) => {
+            return reject(err);
+          });
+      });
+    };
+
+    const unzip = async(zipFilePath: fs.PathLike, unzippedPath: fs.PathLike) => {
+      const stream = fs.createReadStream(zipFilePath);
+      const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
+      const deleteZipFile = (path: fs.PathLike) => fs.unlink(path, (err) => { return err });
+
+      try {
+        await streamToPromise(unzipStream);
+        deleteZipFile(zipFilePath);
+      }
+      catch (err) {
+        return err;
+      }
+    };
+
+    try {
+      await downloadFile(requestUrl, zipFilePath);
+      await unzip(zipFilePath, unzippedPath);
+      await renamePath(`${unzippedPath}/${ghReposName}-${ghBranch}`, `${unzippedPath}/${ghReposName}`);
+    }
+    catch (err) {
+      logger.error(err);
+      throw new Error(err);
+    }
 
     return;
   }
@@ -135,5 +164,4 @@ export class PluginService {
     return [];
   }
 
-
 }

+ 3 - 2
packages/app/src/server/views/widget/page_alerts.html

@@ -28,7 +28,8 @@
       {% endif %}
     {% endif %}
 
-    {% if redirectFrom or req.query.redirectFrom %}
+    <!-- This code has been replaced to <PageRedirectedAlert /> -->
+    <!-- {% if redirectFrom or req.query.redirectFrom %}
     <div class="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
       <span>
         {% set fromPath = req.query.redirectFrom %}
@@ -44,7 +45,7 @@
         </button>
       {% endif %}
     </div>
-    {% endif %}
+    {% endif %} -->
 
     {% if req.query.unlinked %}
     <div class="alert alert-info d-edit-none py-3 px-4">

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

@@ -2,8 +2,8 @@
 import { ComponentType } from 'react';
 
 import { isClient } from '@growi/core';
-import * as drawioPlugin from '@growi/remark-drawio-plugin';
-import growiPlugin from '@growi/remark-growi-plugin';
+import * as drawioPlugin from '@growi/remark-drawio';
+import growiDirective from '@growi/remark-growi-directive';
 import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
 import * as lsxGrowiPlugin from '@growi/remark-lsx/services/renderer';
 import { Schema as SanitizeOption } from 'hast-util-sanitize';
@@ -296,7 +296,7 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
       gfm,
       emoji,
       pukiwikiLikeLinker,
-      growiPlugin,
+      growiDirective,
     ],
     rehypePlugins: [
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
@@ -335,6 +335,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     slug,
@@ -344,6 +348,7 @@ export const generateViewOptions = (
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
     [toc.rehypePluginStore, { storeTocNode }],
     // [autoLinkHeadings, {
@@ -373,7 +378,9 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
-  verifySanitizePlugin(options, false);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
   return options;
 };
 
@@ -386,16 +393,23 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
+
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [toc.rehypePluginRestore, { tocNode }],
-    [sanitize, commonSanitizeOption],
+    rehypeSanitizePlugin,
   );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   // }]);
 
-  verifySanitizePlugin(options);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options);
+  }
   return options;
 };
 
@@ -417,6 +431,10 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
@@ -426,6 +444,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
       drawioPlugin.sanitizeOption,
       lsxGrowiPlugin.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
   );
 
@@ -436,7 +455,9 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
     components.table = Table;
   }
 
-  verifySanitizePlugin(options, false);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
   return options;
 };
 
@@ -458,6 +479,10 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption, lsxGrowiPlugin.sanitizeOption, addLineNumberAttribute.sanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
     [lsxGrowiPlugin.rehypePlugin, { pagePath }],
@@ -468,6 +493,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       drawioPlugin.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
     )],
+    rehypeSanitizePlugin,
     katex,
   );
 
@@ -493,12 +519,18 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(commonSanitizeOption)]
+    : () => {};
+
   // add rehype plugins
   rehypePlugins.push(
-    [sanitize, commonSanitizeOption],
+    rehypeSanitizePlugin,
   );
 
-  verifySanitizePlugin(options);
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options);
+  }
   return options;
 };
 

+ 31 - 4
packages/app/src/stores/admin/customize.tsx

@@ -1,10 +1,11 @@
 import { useCallback } from 'react';
 
-import useSWR, { SWRResponse } from 'swr';
+import { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { updateConfigMethodForAdmin } from '~/interfaces/admin';
-import { IResLayoutSetting } from '~/interfaces/customize';
+import type { updateConfigMethodForAdmin } from '~/interfaces/admin';
+import type { IResLayoutSetting, IResGrowiTheme } from '~/interfaces/customize';
 
 export const useSWRxLayoutSetting = (): SWRResponse<IResLayoutSetting, Error> & updateConfigMethodForAdmin<IResLayoutSetting> => {
 
@@ -13,7 +14,7 @@ export const useSWRxLayoutSetting = (): SWRResponse<IResLayoutSetting, Error> &
     return res.data;
   }, []);
 
-  const swrResponse = useSWR('/customize-setting/layout', fetcher);
+  const swrResponse = useSWRImmutable('/customize-setting/layout', fetcher);
 
   const update = useCallback(async(layoutSetting: IResLayoutSetting) => {
     await apiv3Put('/customize-setting/layout', layoutSetting);
@@ -25,3 +26,29 @@ export const useSWRxLayoutSetting = (): SWRResponse<IResLayoutSetting, Error> &
     update,
   };
 };
+
+export const useSWRxGrowiTheme = (): SWRResponse<string, Error> => {
+
+  const fetcher = useCallback(async() => {
+    const res = await apiv3Get<IResGrowiTheme>('/customize-setting/theme');
+    return res.data.theme;
+  }, []);
+
+  const swrResponse = useSWRImmutable('/customize-setting/theme', fetcher);
+
+  const update = async(theme: string) => {
+    await apiv3Put('/customize-setting/layout', { theme });
+    await swrResponse.mutate();
+    // The updateFn should be a promise or asynchronous function to handle the remote mutation
+    // it should return updated data. see: https://swr.vercel.app/docs/mutation#optimistic-updates
+    // Moreover, `async() => false` does not work since it's too fast to be calculated.
+    await swrResponse.mutate(new Promise(r => setTimeout(() => r(theme), 10)), { optimisticData: () => theme });
+  };
+
+  return Object.assign(
+    swrResponse,
+    {
+      update,
+    },
+  );
+};

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

@@ -37,10 +37,6 @@ export const useConfidential = (initialData?: string): SWRResponse<string, Error
   return useContextSWR('confidential', initialData);
 };
 
-export const useGrowiTheme = (initialData?: GrowiThemes): SWRResponse<GrowiThemes, Error> => {
-  return useContextSWR('theme', initialData);
-};
-
 export const useCurrentUser = (initialData?: Nullable<IUser>): SWRResponse<Nullable<IUser>, Error> => {
   return useContextSWR<Nullable<IUser>, Error>('currentUser', initialData);
 };

+ 27 - 0
packages/app/src/stores/modal.tsx

@@ -553,3 +553,30 @@ export const useHandsontableModal = (status?: HandsontableModalStatus): SWRRespo
     close,
   };
 };
+
+/*
+ * ConflictDiffModal
+ */
+type ConflictDiffModalStatus = {
+  isOpened: boolean,
+}
+
+type ConflictDiffModalUtils = {
+  open(): void,
+  close(): void,
+}
+
+export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Error> & ConflictDiffModalUtils => {
+
+  const initialStatus: ConflictDiffModalStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<ConflictDiffModalStatus, Error>('conflictDiffModal', undefined, { fallbackData: initialStatus });
+
+  return Object.assign(swrResponse, {
+    open: () => {
+      swrResponse.mutate({ isOpened: true });
+    },
+    close: () => {
+      swrResponse.mutate({ isOpened: false });
+    },
+  });
+};

+ 45 - 3
packages/app/src/stores/remote-latest-page.ts

@@ -2,6 +2,7 @@ import { SWRResponse } from 'swr';
 
 import { IUser } from '~/interfaces/user';
 
+import { useRevisionIdHackmdSynced, useHasDraftOnHackmd } from './hackmd';
 import { useStaticSWR } from './use-static-swr';
 
 
@@ -10,9 +11,50 @@ export const useRemoteRevisionId = (initialData?: string): SWRResponse<string, E
 };
 
 export const useRemoteRevisionBody = (initialData?: string): SWRResponse<string, Error> => {
-  return useStaticSWR<string, Error>('remoteRevisionId', initialData);
+  return useStaticSWR<string, Error>('remoteRevisionBody', initialData);
+};
+
+export const useRemoteRevisionLastUpdateUser = (initialData?: IUser): SWRResponse<IUser, Error> => {
+  return useStaticSWR<IUser, Error>('remoteRevisionLastUpdateUser', initialData);
+};
+
+export const useRemoteRevisionLastUpdatedAt = (initialData?: Date): SWRResponse<Date, Error> => {
+  return useStaticSWR<Date, Error>('remoteRevisionLastUpdatedAt', initialData);
 };
 
-export const useRemoteRevisionLastUpdatUser = (initialData?: IUser): SWRResponse<IUser, Error> => {
-  return useStaticSWR<IUser, Error>('remoteRevisionLastUpdatUser', initialData);
+type RemoteRevisionData = {
+  remoteRevisionId: string,
+  remoteRevisionBody: string,
+  remoteRevisionLastUpdateUser: IUser,
+  remoteRevisionLastUpdatedAt: Date,
+  revisionIdHackmdSynced: string,
+  hasDraftOnHackmd: boolean,
+}
+
+
+// set remote data all at once
+export const useSetRemoteLatestPageData = (): { setRemoteLatestPageData: (pageData: RemoteRevisionData) => void } => {
+  const { mutate: mutateRemoteRevisionId } = useRemoteRevisionId();
+  const { mutate: mutateRemoteRevisionBody } = useRemoteRevisionBody();
+  const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
+  const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
+  const { mutate: mutateRevisionIdHackmdSynced } = useRevisionIdHackmdSynced();
+  const { mutate: mutateHasDraftOnHackmd } = useHasDraftOnHackmd();
+
+  const setRemoteLatestPageData = (remoteRevisionData: RemoteRevisionData) => {
+    const {
+      remoteRevisionId, remoteRevisionBody, remoteRevisionLastUpdateUser, remoteRevisionLastUpdatedAt, revisionIdHackmdSynced, hasDraftOnHackmd,
+    } = remoteRevisionData;
+    mutateRemoteRevisionId(remoteRevisionId);
+    mutateRemoteRevisionBody(remoteRevisionBody);
+    mutateRemoteRevisionLastUpdateUser(remoteRevisionLastUpdateUser);
+    mutateRemoteRevisionLastUpdatedAt(remoteRevisionLastUpdatedAt);
+    mutateRevisionIdHackmdSynced(revisionIdHackmdSynced);
+    mutateHasDraftOnHackmd(hasDraftOnHackmd);
+  };
+
+  return {
+    setRemoteLatestPageData,
+  };
+
 };

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

@@ -141,65 +141,6 @@
   }
 }
 
-@mixin button-svg-icon-variant($background, $hover-background: darken($background, 7.5%), $active-background: darken($background, 10%)) {
-  svg {
-    fill: color-yiq($background);
-  }
-
-  @include bs.hover() {
-    svg {
-      fill: color-yiq($hover-background);
-    }
-  }
-
-  &:focus,
-  &.focus {
-    svg {
-      fill: color-yiq($hover-background);
-    }
-  }
-
-  // Disabled comes first so active can properly restyle
-  &.disabled,
-  &:disabled {
-    svg {
-      fill: color-yiq($background);
-    }
-  }
-
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active,
-  .show > &.dropdown-toggle {
-    svg {
-      fill: color-yiq($active-background);
-    }
-  }
-}
-
-@mixin button-outline-svg-icon-variant($value, $color-hover: $value) {
-  svg {
-    fill: $value;
-  }
-  @include bs.hover() {
-    svg {
-      fill: $color-hover;
-    }
-  }
-  &.disabled,
-  &:disabled {
-    svg {
-      fill: $value;
-    }
-  }
-  &:not(:disabled):not(.disabled):active,
-  &:not(:disabled):not(.disabled).active,
-  .show > &.dropdown-toggle {
-    svg {
-      fill: $color-hover;
-    }
-  }
-}
-
 @mixin overlay-processing-style($additionalSelector, $contentFontSize: inherit, $contentPadding: inherit) {
   .overlay.#{$additionalSelector} {
     background: rgba(255, 255, 255, 0.5);

+ 0 - 43
packages/app/src/styles/_page.scss

@@ -1,43 +0,0 @@
-// // import diff2html styles
-// @import '~/diff2html/bundles/css/diff2html.min.css';
-
-/**
- * for table with handsontable modal button
- */
-.editable-with-handsontable {
-  position: relative;
-
-  .handsontable-modal-trigger {
-    position: absolute;
-    top: 11px;
-    right: 10px;
-    padding: 0;
-    font-size: 16px;
-    line-height: 1;
-    vertical-align: bottom;
-    background-color: transparent;
-    border: none;
-    opacity: 0;
-  }
-
-  .page-mobile & .handsontable-modal-trigger {
-    opacity: 0.3;
-  }
-
-  &:hover .handsontable-modal-trigger {
-    opacity: 1;
-  }
-}
-
-/**
- * for drawio with drawio iframe button
- */
-.editable-with-drawio {
-  .drawio-iframe-trigger {
-    top: 11px;
-    right: 10px;
-    z-index: 14;
-    font-size: 12px;
-    line-height: 1;
-  }
-}

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

@@ -1,32 +0,0 @@
-// import bootstrap configurations
-@import '~bootstrap/scss/functions';
-@import '~bootstrap/scss/variables';
-@import '~bootstrap/scss/mixins';
-@import '~bootstrap/scss/utilities';
-@import '~bootstrap/scss/root';
-
-// increase specificity with ':root' for GROWI theming
-:root {
-  // import bootstrap
-  @import '~bootstrap/scss/bootstrap';
-  // import toastr styles
-  @import '~toastr/build/toastr';
-}
-
-// import react-bootstrap-typeahead
-@import '~react-bootstrap-typeahead/css/Typeahead';
-
-// import CodeMirror styles
-@import '~codemirror/lib/codemirror.css';
-@import '~codemirror/addon/hint/show-hint.css';
-@import '~codemirror/theme/elegant.css';
-@import '~codemirror/theme/eclipse.css';
-
-// import Handsontable styles
-@import '~handsontable/dist/handsontable.full.css';
-
-// import SimpleBar styles
-@import '~simplebar/dist/simplebar.min.css';
-
-// Emoji-mart style
-@import '~emoji-mart/css/emoji-mart.css';

+ 60 - 0
packages/app/src/styles/atoms/mixins/_buttons.scss

@@ -0,0 +1,60 @@
+@use '../../bootstrap/init' as bs;
+
+@mixin button-svg-icon-variant($background, $hover-background: darken($background, 7.5%), $active-background: darken($background, 10%)) {
+  svg {
+    fill: color-yiq($background);
+  }
+
+  @include bs.hover() {
+    svg {
+      fill: color-yiq($hover-background);
+    }
+  }
+
+  &:focus,
+  &.focus {
+    svg {
+      fill: color-yiq($hover-background);
+    }
+  }
+
+  // Disabled comes first so active can properly restyle
+  &.disabled,
+  &:disabled {
+    svg {
+      fill: color-yiq($background);
+    }
+  }
+
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active,
+  .show > &.dropdown-toggle {
+    svg {
+      fill: color-yiq($active-background);
+    }
+  }
+}
+
+@mixin button-outline-svg-icon-variant($value, $color-hover: $value) {
+  svg {
+    fill: $value;
+  }
+  @include bs.hover() {
+    svg {
+      fill: $color-hover;
+    }
+  }
+  &.disabled,
+  &:disabled {
+    svg {
+      fill: $value;
+    }
+  }
+  &:not(:disabled):not(.disabled):active,
+  &:not(:disabled):not(.disabled).active,
+  .show > &.dropdown-toggle {
+    svg {
+      fill: $color-hover;
+    }
+  }
+}

+ 34 - 3
packages/app/src/styles/bootstrap/_apply.scss

@@ -1,9 +1,40 @@
 @use 'init' as *;
 
-@import '~bootstrap/scss/utilities';
+// apply bootstrap
 @import '~bootstrap/scss/root';
+@import '~bootstrap/scss/reboot';
+@import '~bootstrap/scss/type';
+@import '~bootstrap/scss/images';
+@import '~bootstrap/scss/code';
+@import '~bootstrap/scss/grid';
+@import '~bootstrap/scss/tables';
+@import '~bootstrap/scss/forms';
+@import '~bootstrap/scss/buttons';
+@import '~bootstrap/scss/transitions';
+@import '~bootstrap/scss/dropdown';
+@import '~bootstrap/scss/button-group';
+@import '~bootstrap/scss/input-group';
+@import '~bootstrap/scss/custom-forms';
+@import '~bootstrap/scss/nav';
+@import '~bootstrap/scss/navbar';
+@import '~bootstrap/scss/card';
+@import '~bootstrap/scss/breadcrumb';
+@import '~bootstrap/scss/pagination';
+@import '~bootstrap/scss/badge';
+@import '~bootstrap/scss/jumbotron';
+@import '~bootstrap/scss/alert';
+@import '~bootstrap/scss/progress';
+@import '~bootstrap/scss/media';
+@import '~bootstrap/scss/list-group';
+@import '~bootstrap/scss/close';
+@import '~bootstrap/scss/toasts';
+@import '~bootstrap/scss/modal';
+@import '~bootstrap/scss/tooltip';
+@import '~bootstrap/scss/popover';
+@import '~bootstrap/scss/carousel';
+@import '~bootstrap/scss/spinners';
+@import '~bootstrap/scss/utilities';
+@import '~bootstrap/scss/print';
 
-// import bootstrap
-@import '~bootstrap/scss/bootstrap';
 // override
 @import './override';

+ 6 - 0
packages/app/src/styles/bootstrap/_variables.scss

@@ -36,6 +36,12 @@ $red: #ff0a54 !default;
 
 $enable-shadows: true;
 
+// Links
+//
+// Style anchor elements.
+
+$link-hover-decoration: none !default;
+
 // Grid breakpoints
 //
 // Define the minimum dimensions at which your layout will change,

+ 36 - 58
packages/app/src/styles/style-app.scss

@@ -1,81 +1,59 @@
-// import variables
-@import 'variables';
+@import './bootstrap/apply';
 
 @import 'mixins';
-@import 'override-bootstrap-variables';
 
-// vendor
-@import 'vendor';
+// react-bootstrap-typeahead
+@import '~react-bootstrap-typeahead/css/Typeahead';
+@import 'override-rbt';
 
-// override bootstrap
-@import 'override-bootstrap';
+// SimpleBar
+@import '~simplebar/dist/simplebar.min.css';
+@import 'override-simplebar';
 
-// override codemirror
-@import 'override-codemirror';
+// KaTeX
+@import '~katex/dist/katex.min';
 
-// override react-bootstrap-typeahead styles
-@import 'override-rbt';
+// icons
 
-// override simplebar-react styles
-@import 'override-simplebar';
+// DO NOT CHANGE THER OERDER OF font-awesome AND simple-line-icons.
+// font-familiy used in simple-line-icons has to be prioritized than the one used in font-awesome.
+@import '~font-awesome';
+@import '~simple-line-icons';
+@import '~material-icons/iconfont/filled';
+@import '~@icon/themify-icons/themify-icons';
 
 // atoms
 @import 'atoms/buttons';
-@import 'atoms/code';
-@import 'atoms/nav';
-@import 'atoms/pre';
 @import 'atoms/spinners';
 @import 'atoms/custom_control';
+@import 'atoms/code';
 
 // molecules
-@import 'molecules/copy-dropdown';
-@import 'molecules/page-editor-mode-manager';
-@import 'molecules/slack-notification';
-@import 'molecules/duplicated-paths-table.scss';
-
-// growi component
-@import 'admin';
-@import 'attachments';
-@import 'comment';
-@import 'comment_growi';
-@import 'drawio';
-@import 'create-page';
-@import 'draft';
-@import 'editor-attachment';
-@import 'editor-navbar';
-@import 'page-content-footer';
-// @import 'handsontable';
+@import 'molecules/toastr';
+// @import 'molecules/slack-notification';
+// @import 'molecules/duplicated-paths-table.scss';
+
+// organisms
+@import 'organisms/wiki';
+
+// // growi component
+// @import 'attachments';
+// @import 'comment';
+// @import 'comment_growi';
+// @import 'draft';
+@import 'editor';
+@import 'fonts';
 @import 'layout';
-@import 'login';
-@import 'me';
+// @import 'me';
 @import 'mirror_mode';
 @import 'modal';
-@import 'navbar';
-@import 'old-ios';
-@import 'on-edit';
-@import 'page-duplicate-modal';
-@import 'page_list';
-@import 'page-accessories-control';
-@import 'page-accessories-modal';
+// @import 'old-ios';
+// @import 'page-duplicate-modal';
 @import 'page-path';
-@import 'page-tree';
-@import 'page';
-@import 'page-presentation';
-@import 'page-history';
-@import 'recent-changes';
 @import 'search';
-@import 'shortcuts';
-@import 'sidebar';
-@import 'sidebar-wiki';
-@import 'subnav';
 @import 'tag';
-@import 'toc';
-@import 'user';
-@import 'staff_credit';
-@import 'waves';
-@import 'wiki';
-@import 'sharelink';
-@import 'linkedit-preview';
+// @import 'user';
+
 
 /*
  * for Guest User Mode

+ 0 - 149
packages/app/src/styles/style-next.scss

@@ -1,149 +0,0 @@
-@import './bootstrap/apply';
-
-@import 'mixins';
-
-// // override codemirror
-// @import 'override-codemirror';
-
-// react-bootstrap-typeahead
-@import '~react-bootstrap-typeahead/css/Typeahead';
-@import 'override-rbt';
-
-// SimpleBar
-@import '~simplebar/dist/simplebar.min.css';
-@import 'override-simplebar';
-
-// KaTeX
-@import '~katex/dist/katex.min';
-
-// icons
-
-// DO NOT CHANGE THER OERDER OF font-awesome AND simple-line-icons.
-// font-familiy used in simple-line-icons has to be prioritized than the one used in font-awesome.
-@import '~font-awesome';
-@import '~simple-line-icons';
-@import '~material-icons/iconfont/filled';
-@import '~@icon/themify-icons/themify-icons';
-
-// atoms
-@import 'atoms/buttons';
-@import 'atoms/spinners';
-@import 'atoms/custom_control';
-@import 'atoms/code';
-
-// molecules
-@import 'molecules/toastr';
-// @import 'molecules/copy-dropdown';
-// @import 'molecules/slack-notification';
-// @import 'molecules/duplicated-paths-table.scss';
-
-// organisms
-@import 'organisms/wiki';
-
-// // growi component
-// @import 'attachments';
-// @import 'comment';
-// @import 'comment_growi';
-// @import 'create-page';
-// @import 'draft';
-@import 'editor';
-@import 'fonts';
-// @import 'handsontable';
-@import 'layout';
-// @import 'me';
-@import 'mirror_mode';
-@import 'modal';
-// @import 'old-ios';
-// @import 'page-duplicate-modal';
-@import 'page-path';
-// @import 'page';
-@import 'search';
-@import 'tag';
-// @import 'user';
-
-
-/*
- * for Guest User Mode
- */
-// TODO: reactify and replace with `grw-not-available-for-guest`
-.dropdown-toggle.dropdown-toggle-disabled {
-  cursor: not-allowed;
-}
-
-// TODO: reactify and replace with `grw-not-available-for-guest`
-.edit-button.edit-button-disabled {
-  cursor: not-allowed;
-}
-
-.grw-not-available-for-guest {
-  cursor: not-allowed !important;
-}
-
-/*
- * Helper Classes
- */
-
-.mw-0 {
-  min-width: 0;
-}
-
-.flex-basis-0 {
-  flex-basis: 0;
-}
-
-.picture {
-  width: 24px;
-  height: 24px;
-
-  // size list
-  &.picture-lg {
-    width: 48px;
-    height: 48px;
-  }
-
-  &.picture-md {
-    width: 24px;
-    height: 24px;
-  }
-
-  &.picture-sm {
-    width: 18px;
-    height: 18px;
-  }
-
-  &.picture-xs {
-    width: 14px;
-    height: 14px;
-  }
-}
-
-// transplant from FontAwesome
-.icon-fw {
-  display: inline-block;
-  width: 1.4em;
-  text-align: left;
-}
-
-.cmd-key.mac {
-  &:after {
-    content: '⌘';
-  }
-}
-
-.cmd-key.win {
-  &:after {
-    content: 'Ctrl';
-  }
-}
-
-.grw-page-control-dropdown-item {
-  display: flex !important;
-  align-items: center;
-
-  .grw-page-control-dropdown-icon {
-    display: flex;
-    justify-content: center;
-    width: 25px;
-  }
-
-}

+ 0 - 15
packages/app/src/styles/style-themes.scss

@@ -1,15 +0,0 @@
-// @import '~/components/Theme/ThemeAntarctic.global.scss';
-// @import '~/components/Theme/ThemeBlackboard.global.scss';
-// @import '~/components/Theme/ThemeChristmas.global.scss';
-@import '~/components/Theme/ThemeDefault.global.scss';
-// @import '~/components/Theme/ThemeFireRed.global.scss';
-// @import '~/components/Theme/ThemeFuture.global.scss';
-// @import '~/components/Theme/ThemeHalloween.global.scss';
-// @import '~/components/Theme/ThemeHufflepuff.global.scss';
-// @import '~/components/Theme/ThemeIsland.global.scss';
-// @import '~/components/Theme/ThemeJadeGreen.global.scss';
-// @import '~/components/Theme/ThemeKibela.global.scss';
-// @import '~/components/Theme/ThemeMonoBlue.global.scss';
-// @import '~/components/Theme/ThemeNature.global.scss';
-// @import '~/components/Theme/ThemeSpring.global.scss';
-// @import '~/components/Theme/ThemeWood.global.scss';

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

@@ -1,6 +1,6 @@
 @use '../variables' as var;
 @use '../bootstrap/init' as *;
-@use '../mixins';
+@use '../atoms/mixins/buttons' as mixins-buttons;
 @use './mixins/count-badge';
 
 // determine optional variables
@@ -301,7 +301,7 @@ ul.pagination {
       lighten($bgcolor-sidebar-context, 24%)
     );
     .grw-pagetree-triangle-btn {
-      @include mixins.button-outline-svg-icon-variant($secondary, $gray-200);
+      @include mixins-buttons.button-outline-svg-icon-variant($secondary, $gray-200);
     }
     .btn-page-item-control {
       @include button-outline-variant($gray-500, $gray-500, $secondary, transparent);

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

@@ -1,6 +1,6 @@
 @use '../variables' as var;
 @use '../bootstrap/init' as *;
-@use '../mixins';
+@use '../atoms/mixins/buttons' as mixins-buttons;
 @use './mixins/count-badge';
 
 // determine optional variables
@@ -201,7 +201,7 @@ $dropdown-link-active-bg: $bgcolor-dropdown-link-active;
       darken($bgcolor-sidebar-context, 24%)
     );
     .grw-pagetree-triangle-btn {
-      @include mixins.button-outline-svg-icon-variant($gray-400, $primary);
+      @include mixins-buttons.button-outline-svg-icon-variant($gray-400, $primary);
     }
   }
   .private-legacy-pages-link {

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

@@ -553,11 +553,11 @@ body.editing-sidebar {
 }
 
 .grid-preview-col-1 {
-  background: $info;
+  background: var(--info);
 }
 
 .grid-preview-col-2 {
-  background: $success;
+  background: var(--success);
 }
 
 .grid-preview-col-3 {
@@ -570,7 +570,7 @@ body.editing-sidebar {
 .page-comment-meta .page-comment-revision svg {
   fill: $color-link;
 
-  &:hover() {
+  &:hover {
     fill: $color-link-hover;
   }
 }

+ 1 - 1
packages/app/src/styles/theme/_reboot-bootstrap-border-colors.scss

@@ -25,5 +25,5 @@
 }
 
 .border-info {
-  border-color: $info !important;
+  border-color: ver(--info) !important;
 }

+ 1 - 1
packages/app/src/styles/theme/_reboot-bootstrap-colors.scss

@@ -14,7 +14,7 @@
 // 3. Set an explicit initial text-align value so that we can later use
 //    the `inherit` value on things like `<th>` elements.
 
-& {
+&, body {
   color: $body-color;
   background-color: $body-bg; // 2
 

+ 3 - 3
packages/app/src/styles/theme/_reboot-bootstrap-theme-colors.scss

@@ -1,5 +1,5 @@
 @use '../bootstrap/init' as *;
-@use '../mixins';
+@use '../atoms/mixins/buttons' as mixins-buttons;
 
 @each $color, $value in $theme-colors {
   @include bg-variant('.bg-#{$color}', $value);
@@ -18,7 +18,7 @@
 @each $color, $value in $theme-colors {
   .btn-#{$color} {
     @include button-variant($value, $value);
-    @include mixins.button-svg-icon-variant($value, $value);
+    @include mixins-buttons.button-svg-icon-variant($value, $value);
     box-shadow: none !important;
   }
 }
@@ -26,7 +26,7 @@
 @each $color, $value in $theme-colors {
   .btn-outline-#{$color} {
     @include button-outline-variant($value, $color-hover: $value, $active-background: rgba($value, 0.1), $active-border: $value);
-    @include mixins.button-outline-svg-icon-variant($value, $color-hover: $value);
+    @include mixins-buttons.button-outline-svg-icon-variant($value, $color-hover: $value);
 
     &:not(:disabled):not(.disabled):active,
     &:not(:disabled):not(.disabled).active {

+ 4 - 4
packages/app/src/styles/theme/_reboot-toastr-colors.scss

@@ -1,15 +1,15 @@
 .toast-success {
-  background-color: $success;
+  background-color: var(--success);
 }
 
 .toast-error {
-  background-color: $danger;
+  background-color: var(--danger);
 }
 
 .toast-info {
-  background-color: $info;
+  background-color: var(--info);
 }
 
 .toast-warning {
-  background-color: $warning;
+  background-color: var(--warning);
 }

+ 17 - 5
packages/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts

@@ -132,16 +132,28 @@ context('Modal for page operation', () => {
     cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
   });
 
-  it('PageDeleteModal is shown successfully', () => {
+  it('Page Deletion and PutBack is executed successfully', () => {
     cy.visit('/Sandbox/Bootstrap4');
     cy.waitUntilSkeletonDisappear();
 
-     cy.get('#grw-subnav-container').within(() => {
-       cy.getByTestid('open-page-item-control-btn').click({force: true});
-       cy.getByTestid('open-page-delete-modal-btn').click({force: true});
+    cy.get('#grw-subnav-container').within(() => {
+      cy.getByTestid('open-page-item-control-btn').click({force: true});
+      cy.getByTestid('open-page-delete-modal-btn').click({force: true});
     });
 
-     cy.getByTestid('page-delete-modal').should('be.visible').screenshot(`${ssPrefix}-delete-bootstrap4`);
+    cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}-delete-modal`);
+      cy.getByTestid('delete-page-button').click();
+    });
+    cy.getByTestid('trash-page-alert').should('be.visible');
+    cy.screenshot(`${ssPrefix}-bootstrap4-is-in-garbage-box`);
+
+    cy.getByTestid('put-back-button').click();
+    cy.getByTestid('put-back-page-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}-put-back-modal`);
+      cy.getByTestid('put-back-execution-button').should('be.visible').click();
+    });
+    cy.screenshot(`${ssPrefix}-put-backed-bootstrap4-page`);
   });
 
   it('PageDuplicateModal is shown successfully', () => {

+ 4 - 1
packages/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.spec.ts

@@ -29,7 +29,10 @@ context('Access to page by guest', () => {
     cy.collapseSidebar(true, true);
 
     cy.get('.math').should('be.visible');
-    cy.screenshot(`${ssPrefix}-sandbox-math`);
+
+    cy.screenshot(`${ssPrefix}-sandbox-math`, {
+      blackout: ['.revision-toc', '[data-hide-in-vrt=true]']
+    });
   });
 
   it('/Sandbox with edit is successfully loaded', () => {

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

@@ -32,6 +32,8 @@ context('Access to Admin page', () => {
   it('/admin/app is successfully loaded', () => {
     cy.visit('/admin/app');
     cy.getByTestid('admin-app-settings').should('be.visible');
+    cy.getByTestid('v5-page-migration').should('be.visible');
+    cy.get('#cbFileUpload').should('be.checked')
     cy.screenshot(`${ssPrefix}-admin-app`);
   });
 

+ 1 - 2
packages/hackmd/package.json

@@ -10,7 +10,6 @@
   "dependencies": {},
   "devDependencies": {
     "penpal": "^4.0.0",
-    "throttle-debounce": "^3.0.1",
-    "vite": "^3.1.0"
+    "throttle-debounce": "^3.0.1"
   }
 }

+ 2 - 0
packages/preset-themes/.eslintignore

@@ -0,0 +1,2 @@
+/dist/**
+*.d.ts

+ 0 - 0
packages/remark-drawio-plugin/.eslintrc.js → packages/preset-themes/.eslintrc.js


+ 0 - 0
packages/remark-drawio-plugin/.gitignore → packages/preset-themes/.gitignore


+ 1 - 0
packages/preset-themes/README.md

@@ -0,0 +1 @@
+# GROWI Preset Themes

+ 27 - 0
packages/preset-themes/package.json

@@ -0,0 +1,27 @@
+{
+  "name": "@growi/preset-themes",
+  "description": "GROWI preset themes",
+  "version": "6.0.0-RC.9",
+  "license": "MIT",
+  "main": "dist/libs/index.js",
+  "files": ["dist"],
+  "scripts": {
+    "build": "yarn build:libs & yarn build:themes",
+    "build:w": "yarn build:libs -w & yarn build:themes -w",
+    "build:libs": "vite -c vite.libs.config.ts build",
+    "build:themes": "vite -c vite.themes.config.ts build",
+    "lint:eslint": "eslint --quiet \"**/*.{js,jsx,ts,tsx}\"",
+    "lint:styles": "stylelint src/**/*.scss",
+    "lint": "run-p lint:*",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0",
+    "react-innertext": "^1.1.5"
+  },
+  "devDependencies": {
+    "bootstrap": "^4.6.1",
+    "sass": "^1.55.0"
+  }
+}

+ 0 - 0
packages/app/public/images/themes/antarctic/bg.svg → packages/preset-themes/public/images/antarctic/bg.svg


+ 0 - 0
packages/app/public/images/themes/antarctic/topimage.svg → packages/preset-themes/public/images/antarctic/topimage.svg


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików