satof3 2 лет назад
Родитель
Сommit
8551803947
70 измененных файлов с 781 добавлено и 694 удалено
  1. 1 2
      apps/app/package.json
  2. 3 0
      apps/app/public/images/icons/slack/slack-logo-background.svg
  3. 3 0
      apps/app/public/images/icons/slack/slack-logo-dark-background.svg
  4. 2 1
      apps/app/public/static/locales/en_US/translation.json
  5. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  6. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  7. 0 5
      apps/app/src/client/services/page-operation.ts
  8. 47 11
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  9. 47 11
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  10. 22 0
      apps/app/src/client/services/update-page/conflict.ts
  11. 9 0
      apps/app/src/client/services/update-page/index.ts
  12. 2 1
      apps/app/src/components/Admin/App/QuestionnaireSettings.tsx
  13. 1 1
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  14. 6 6
      apps/app/src/components/Admin/AuditLogManagement.tsx
  15. 3 2
      apps/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx
  16. 3 1
      apps/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx
  17. 3 3
      apps/app/src/components/Admin/G2GDataTransfer.tsx
  18. 4 2
      apps/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  19. 1 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  20. 2 1
      apps/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  21. 2 1
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  22. 2 1
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  23. 5 4
      apps/app/src/components/DescendantsPageList.tsx
  24. 5 2
      apps/app/src/components/InAppNotification/InAppNotificationList.tsx
  25. 4 4
      apps/app/src/components/InAppNotification/InAppNotificationPage.tsx
  26. 5 4
      apps/app/src/components/InfiniteScroll.tsx
  27. 11 4
      apps/app/src/components/InstallerForm.tsx
  28. 9 1
      apps/app/src/components/InvitedForm.tsx
  29. 0 7
      apps/app/src/components/LoadingSpinner.jsx
  30. 7 0
      apps/app/src/components/LoadingSpinner.tsx
  31. 1 2
      apps/app/src/components/LoginForm.tsx
  32. 3 1
      apps/app/src/components/Me/QuestionnaireSettings.tsx
  33. 20 5
      apps/app/src/components/Page/PageContentsUtilities.tsx
  34. 4 1
      apps/app/src/components/Page/RevisionLoader.tsx
  35. 2 1
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  36. 2 1
      apps/app/src/components/PageControls/BookmarkButtons.tsx
  37. 38 142
      apps/app/src/components/PageEditor/ConflictDiffModal.tsx
  38. 3 1
      apps/app/src/components/PageEditor/DrawioModal.tsx
  39. 3 15
      apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss
  40. 5 98
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  41. 9 4
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  42. 2 13
      apps/app/src/components/PageEditor/PageEditor.tsx
  43. 4 3
      apps/app/src/components/PageList/PageList.tsx
  44. 2 1
      apps/app/src/components/PagePresentationModal.tsx
  45. 1 1
      apps/app/src/components/PageRenameModal.tsx
  46. 1 1
      apps/app/src/components/PageStatusAlert.tsx
  47. 11 9
      apps/app/src/components/PrivateLegacyPages.tsx
  48. 205 55
      apps/app/src/components/SavePageControls.tsx
  49. 18 14
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  50. 2 1
      apps/app/src/components/SearchPage/SearchPageBase.tsx
  51. 0 19
      apps/app/src/components/SlackLogo.jsx
  52. 55 39
      apps/app/src/components/SlackNotification.module.scss
  53. 31 31
      apps/app/src/components/SlackNotification.tsx
  54. 2 1
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  55. 2 1
      apps/app/src/components/TreeItem/SimpleItem.tsx
  56. 4 0
      apps/app/src/interfaces/apiv3/page.ts
  57. 2 0
      apps/app/src/pages/[[...path]].page.tsx
  58. 2 1
      apps/app/src/pages/tags.page.tsx
  59. 2 4
      apps/app/src/server/routes/apiv3/page/update-page.ts
  60. 9 5
      apps/app/src/stores/modal.tsx
  61. 0 27
      apps/app/src/styles/_draft.scss
  62. 0 8
      apps/app/src/styles/_editor.scss
  63. 95 92
      apps/app/src/styles/atoms/_custom_control.scss
  64. 1 1
      apps/app/src/styles/style-app.scss
  65. 16 0
      packages/core/scss/_rotate.scss
  66. 1 1
      packages/core/scss/bootstrap/_variables.scss
  67. 0 8
      packages/core/src/interfaces/revision.ts
  68. 2 0
      packages/editor/src/@types/y-codemirror.next.d.ts
  69. 10 6
      packages/editor/src/stores/use-collaborative-editor-mode.ts
  70. 0 5
      packages/editor/src/stores/use-default-extensions.ts

+ 1 - 2
apps/app/package.json

@@ -280,7 +280,6 @@
     "socket.io-client": "^4.2.0",
     "source-map-loader": "^4.0.1",
     "swagger2openapi": "^7.0.8",
-    "tsc-alias": "^1.2.9",
-    "y-codemirror.next": "^0.3.2"
+    "tsc-alias": "^1.2.9"
   }
 }

+ 3 - 0
apps/app/public/images/icons/slack/slack-logo-background.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
+  <circle fill="white" cx="300" cy="300" r="300" />
+</svg>

+ 3 - 0
apps/app/public/images/icons/slack/slack-logo-dark-background.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600">
+  <circle fill="#370f38" cx="300" cy="300" r="300" />
+</svg>

+ 2 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -311,7 +311,7 @@
     }
   },
   "page_edit": {
-    "input_channels": "Input channels",
+    "input_channels": "Slack channel name...",
     "theme": "Theme",
     "keymap": "Keymap",
     "indent": "Indent",
@@ -438,6 +438,7 @@
     }
   },
   "modal_resolve_conflict": {
+    "conflicts_with_new_body_on_server_side": "Conflict with new body on server side. Please select or edit the page body to resolve the conflict.",
     "file_conflicting_with_newer_remote": "This file is conflicting with newer remote file",
     "resolve_conflict_message": "Please select page body",
     "resolve_conflict": "Resolve Conflict",

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

@@ -471,6 +471,7 @@
     }
   },
   "modal_resolve_conflict": {
+    "conflicts_with_new_body_on_server_side": "サーバー側の新しい本文と衝突します。ページ本文を選択または編集して衝突を解消してください。",
     "file_conflicting_with_newer_remote": "サーバー側の新しいファイルと衝突します。",
     "resolve_conflict_message": "ページ本文を選んでください",
     "resolve_conflict": "衝突を解消",

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

@@ -427,6 +427,7 @@
     }
   },
   "modal_resolve_conflict": {
+    "conflicts_with_new_body_on_server_side": "与服务器端的新正文文本冲突。 请选择或编辑页面正文以解决冲突",
     "file_conflicting_with_newer_remote": "此文件与较新的远程文件冲突",
     "resolve_conflict_message": "选择页面正文",
     "resolve_conflict": "解决冲突",

+ 0 - 5
apps/app/src/client/services/page-operation.ts

@@ -96,11 +96,6 @@ export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3P
   return res.data;
 };
 
-export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
-  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
-  return res.data;
-};
-
 export type UpdateStateAfterSaveOption = {
   supressEditingMarkdownMutation: boolean,
 }

+ 47 - 11
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -5,14 +5,14 @@ import type EventEmitter from 'events';
 import { Origin } from '@growi/core';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
+import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
 import { useShareLinkId } from '~/stores/context';
-import { useDrawioModal } from '~/stores/modal';
+import { useConflictDiffModal, useDrawioModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
+import { type RemoteRevisionData, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { updatePage } from '../page-operation';
-
 
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 
@@ -34,31 +34,67 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openDrawioModal } = useDrawioModal();
 
-  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // eslint-disable-next-line max-len
+  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
     if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
-
     try {
-      const currentRevisionId = currentPage.revision._id;
-      await updatePage({
+      await _updatePage({
         pageId: currentPage._id,
-        revisionId: currentRevisionId,
+        revisionId,
         body: newMarkdown,
         origin: Origin.View,
       });
 
+      closeConflictDiffModal();
       opts?.onSaveSuccess?.();
     }
     catch (error) {
+      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevidsionData != null) {
+        onConflict(remoteRevidsionData, newMarkdown);
+      }
+
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, shareLinkId]);
+  }, [closeConflictDiffModal, currentPage, opts, shareLinkId]);
+
+  // eslint-disable-next-line max-len
+  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
+    return async(newMarkdown: string) => {
+      await updatePage(revisionId, newMarkdown, onConflict);
+    };
+  }, [updatePage]);
+
+  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+    setRemoteLatestPageData(remoteRevidsionData);
+
+    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
+    if (resolveConflictHandler == null) {
+      return;
+    }
+
+    openConflictDiffModal(newMarkdown, resolveConflictHandler);
+  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+
+  const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
+    if (currentPage == null || currentPage.revision == null) {
+      return;
+    }
+
+    const currentRevisionId = currentPage.revision._id;
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
+    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+  }, [currentPage, onConflictHandler, updatePage]);
 
   // set handler to open DrawioModal
   useEffect(() => {

+ 47 - 11
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -5,14 +5,14 @@ import type EventEmitter from 'events';
 import { Origin } from '@growi/core';
 
 import type MarkdownTable from '~/client/models/MarkdownTable';
+import { extractRemoteRevisionDataFromErrorObj, updatePage as _updatePage } from '~/client/services/update-page';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { useShareLinkId } from '~/stores/context';
-import { useHandsontableModal } from '~/stores/modal';
+import { useHandsontableModal, useConflictDiffModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
+import { type RemoteRevisionData, useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { updatePage } from '../page-operation';
-
 
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 
@@ -34,31 +34,67 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
-  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+  const { open: openConflictDiffModal, close: closeConflictDiffModal } = useConflictDiffModal();
+
+  const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
+
+  // eslint-disable-next-line max-len
+  const updatePage = useCallback(async(revisionId:string, newMarkdown: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
     if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
-    const currentMarkdown = currentPage.revision.body;
-    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
-
     try {
-      const currentRevisionId = currentPage.revision._id;
-      await updatePage({
+      await _updatePage({
         pageId: currentPage._id,
-        revisionId: currentRevisionId,
+        revisionId,
         body: newMarkdown,
         origin: Origin.View,
       });
 
+      closeConflictDiffModal();
       opts?.onSaveSuccess?.();
     }
     catch (error) {
+      const remoteRevidsionData = extractRemoteRevisionDataFromErrorObj(error);
+      if (remoteRevidsionData != null) {
+        onConflict?.(remoteRevidsionData, newMarkdown);
+      }
+
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, shareLinkId]);
+  }, [closeConflictDiffModal, currentPage, opts, shareLinkId]);
+
+  // eslint-disable-next-line max-len
+  const generateResolveConflictHandler = useCallback((revisionId: string, onConflict: (conflictData: RemoteRevisionData, newMarkdown: string) => void) => {
+    return async(newMarkdown: string) => {
+      await updatePage(revisionId, newMarkdown, onConflict);
+    };
+  }, [updatePage]);
+
+  const onConflictHandler = useCallback((remoteRevidsionData: RemoteRevisionData, newMarkdown: string) => {
+    setRemoteLatestPageData(remoteRevidsionData);
+
+    const resolveConflictHandler = generateResolveConflictHandler(remoteRevidsionData.remoteRevisionId, onConflictHandler);
+    if (resolveConflictHandler == null) {
+      return;
+    }
+
+    openConflictDiffModal(newMarkdown, resolveConflictHandler);
+  }, [generateResolveConflictHandler, openConflictDiffModal, setRemoteLatestPageData]);
+
+  const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
+    if (currentPage == null || currentPage.revision == null) {
+      return;
+    }
+
+    const currentRevisionId = currentPage.revision._id;
+    const currentMarkdown = currentPage.revision.body;
+    const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
+    await updatePage(currentRevisionId, newMarkdown, onConflictHandler);
+  }, [currentPage, onConflictHandler, updatePage]);
 
   // set handler to open HandsonTableModal
   useEffect(() => {

+ 22 - 0
apps/app/src/client/services/update-page/conflict.ts

@@ -0,0 +1,22 @@
+import type { ErrorV3 } from '@growi/core/dist/models';
+
+import { PageUpdateErrorCode } from '~/interfaces/apiv3';
+import { type RemoteRevisionData } from '~/stores/remote-latest-page';
+
+export const extractRemoteRevisionDataFromErrorObj = (errors: Array<ErrorV3>): RemoteRevisionData | undefined => {
+  for (const error of errors) {
+    if (error.code === PageUpdateErrorCode.CONFLICT) {
+
+      const latestRevision = error.args.returnLatestRevision;
+
+      const remoteRevidsionData = {
+        remoteRevisionId: latestRevision.revisionId,
+        remoteRevisionBody: latestRevision.revisionBody,
+        remoteRevisionLastUpdateUser: latestRevision.user,
+        remoteRevisionLastUpdatedAt: latestRevision.createdAt,
+      };
+
+      return remoteRevidsionData;
+    }
+  }
+};

+ 9 - 0
apps/app/src/client/services/update-page/index.ts

@@ -0,0 +1,9 @@
+import { apiv3Put } from '~/client/util/apiv3-client';
+import type { IApiv3PageUpdateParams, IApiv3PageUpdateResponse } from '~/interfaces/apiv3';
+
+export * from './conflict';
+
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
+};

+ 2 - 1
apps/app/src/components/Admin/App/QuestionnaireSettings.tsx

@@ -6,6 +6,7 @@ import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { useSWRxAppSettings } from '~/stores/admin/app-settings';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
@@ -65,7 +66,7 @@ const QuestionnaireSettings = (): JSX.Element => {
 
       {isLoading && (
         <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       )}
 

+ 1 - 1
apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -55,7 +55,7 @@ export const AuditLogSettings: FC = () => {
       </p>
       <p className="mt-1">
         <button type="button" className="btn btn-link p-0" aria-expanded="false" onClick={() => setIsExpandActionList(!isExpandActionList)}>
-          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'fa-rotate-90' : ''}`}>navigate_next</span>
+          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'rotate-90' : ''}`}>navigate_next</span>
           { t('admin:audit_log_management.action_list') }
         </button>
       </p>

+ 6 - 6
apps/app/src/components/Admin/AuditLogManagement.tsx

@@ -1,16 +1,16 @@
-import React, {
-  FC, useState, useCallback, useRef,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback, useRef } from 'react';
 
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
 
-import { IClearable } from '~/client/interfaces/clearable';
+import type { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/toastr';
-import { SupportedActionType } from '~/interfaces/activity';
+import type { SupportedActionType } from '~/interfaces/activity';
 import { useSWRxActivity } from '~/stores/activity';
 import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores/context';
 
+import { LoadingSpinner } from '../LoadingSpinner';
 import PaginationWrapper from '../PaginationWrapper';
 
 import { ActivityTable } from './AuditLog/ActivityTable';
@@ -213,7 +213,7 @@ export const AuditLogManagement: FC = () => {
           { isLoading
             ? (
               <div className="text-muted text-center mb-5">
-                <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+                <LoadingSpinner className="me-1 fs-3" />
               </div>
             )
             : (

+ 3 - 2
apps/app/src/components/Admin/Customize/CustomizeLayoutSetting.tsx

@@ -5,6 +5,7 @@ import React, {
 import { useTranslation } from 'next-i18next';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { useSWRxLayoutSetting } from '~/stores/admin/customize';
 import { useNextThemes } from '~/stores/use-next-themes';
 
@@ -44,8 +45,8 @@ const CustomizeLayoutSetting = (): JSX.Element => {
 
   if (isContainerFluid == null) {
     return (
-      <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse"></i>
+      <div className="text-muted text-center fs-3">
+        <LoadingSpinner />
       </div>
     );
   }

+ 3 - 1
apps/app/src/components/Admin/ElasticsearchManagement/ReconnectControls.tsx

@@ -2,6 +2,8 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
+
 type Props = {
   isEnabled?: boolean,
   isProcessing?: boolean,
@@ -21,7 +23,7 @@ const ReconnectControls = (props: Props): JSX.Element => {
         onClick={() => { props.onReconnectingRequested() }}
         disabled={!isEnabled}
       >
-        { isProcessing && <i className="fa fa-spinner fa-pulse me-2"></i> }
+        { isProcessing && <LoadingSpinner className="me-2" /> }
         { t('full_text_search_management.reconnect_button') }
       </button>
 

+ 3 - 3
apps/app/src/components/Admin/G2GDataTransfer.tsx

@@ -247,10 +247,10 @@ const G2GDataTransfer = (): JSX.Element => {
 
       {isTransferring && (
         <div className="border rounded p-4">
-          <div>
-            <G2GDataTransferStatusIcon className="me-2 mb-2" status={g2gProgress.mongo} /> MongoDB
+          <div className="my-2">
+            <G2GDataTransferStatusIcon className="me-2" status={g2gProgress.mongo} /> MongoDB
           </div>
-          <div>
+          <div className="my-2">
             <G2GDataTransferStatusIcon className="me-2" status={g2gProgress.attachments} /> Attachments
           </div>
         </div>

+ 4 - 2
apps/app/src/components/Admin/G2GDataTransferStatusIcon.tsx

@@ -2,10 +2,12 @@ import React, { type ComponentPropsWithoutRef } from 'react';
 
 import { G2G_PROGRESS_STATUS, type G2GProgressStatus } from '~/interfaces/g2g-transfer';
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 /**
  * Props for {@link G2GDataTransferStatusIcon}
  */
-interface Props extends ComponentPropsWithoutRef<'i'>{
+interface Props extends ComponentPropsWithoutRef<'span'>{
   status: G2GProgressStatus;
 }
 
@@ -15,7 +17,7 @@ interface Props extends ComponentPropsWithoutRef<'i'>{
 const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.Element => {
   if (status === G2G_PROGRESS_STATUS.IN_PROGRESS) {
     return (
-      <i className={`fa fa-spinner fa-pulse fa-fw ${className}`} aria-label="in progress" {...props} />
+      <LoadingSpinner className={`${className}`} aria-label="in progress" {...props} />
     );
   }
 

+ 1 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -296,7 +296,7 @@ class SecuritySetting extends React.Component {
                     aria-expanded="false"
                     onClick={() => this.setExpantOtherDeleteOptionsState(deletionType, !expantDeleteOptionsState)}
                   >
-                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}>navigate_next</span>
+                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'rotate-90' : ''}`}>navigate_next</span>
                     { t('security_settings.other_options') }
                   </button>
                   <Collapse isOpen={expantDeleteOptionsState}>

+ 2 - 1
apps/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx

@@ -8,6 +8,7 @@ import {
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 
 import BotTypeCard from './BotTypeCard';
 import ConfirmBotChangeModal from './ConfirmBotChangeModal';
@@ -187,7 +188,7 @@ const SlackIntegration = () => {
   if (isLoading) {
     return (
       <div className="text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+        <LoadingSpinner className="me-1 fs-3" />
       </div>
     );
   }

+ 2 - 1
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -10,6 +10,7 @@ import {
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { useIsMailerSetup } from '~/stores/context';
 
 class PasswordResetModal extends React.Component {
@@ -53,7 +54,7 @@ class PasswordResetModal extends React.Component {
           onClick={this.onClickSendNewPasswordButton}
           disabled={!isMailerSetup || isEmailSending || isEmailSent}
         >
-          {isEmailSending && <i className="fa fa-spinner fa-pulse mx-2" />}
+          {isEmailSending && <LoadingSpinner className="mx-2" />}
           {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
         </button>
         <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>

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

@@ -10,6 +10,7 @@ import {
   Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
 } from 'reactstrap';
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
 import type { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
@@ -133,7 +134,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   if (isLoading) {
     contents = (
       <div className="text-muted text-center my-2">
-        <i className="fa fa-spinner fa-pulse"></i>
+        <LoadingSpinner />
       </div>
     );
   }

+ 5 - 4
apps/app/src/components/DescendantsPageList.tsx

@@ -8,15 +8,16 @@ import type {
 import { useTranslation } from 'next-i18next';
 
 import { toastSuccess } from '~/client/util/toastr';
-import { IPagingResult } from '~/interfaces/paging-result';
-import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import type { IPagingResult } from '~/interfaces/paging-result';
+import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores/context';
 import {
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList,
 } from '~/stores/page-listing';
 
-import { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
+import type { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from './LoadingSpinner';
 import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
@@ -86,7 +87,7 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       </div>
     );

+ 5 - 2
apps/app/src/components/InAppNotification/InAppNotificationList.tsx

@@ -1,9 +1,12 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import type { HasObjectId } from '@growi/core';
 
 import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 import InAppNotificationElm from './InAppNotificationElm';
 
 
@@ -19,7 +22,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       </div>
     );

+ 4 - 4
apps/app/src/components/InAppNotification/InAppNotificationPage.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useEffect, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -11,6 +10,7 @@ import loggerFactory from '~/utils/logger';
 
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '../../stores/in-app-notification';
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
+import { LoadingSpinner } from '../LoadingSpinner';
 import PaginationWrapper from '../PaginationWrapper';
 
 import InAppNotificationList from './InAppNotificationList';
@@ -66,7 +66,7 @@ export const InAppNotificationPage: FC = () => {
       return (
         <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
           <div className="text-muted text-center">
-            <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+            <LoadingSpinner className="me-1 fs-3" />
           </div>
         </div>
       );

+ 5 - 4
apps/app/src/components/InfiniteScroll.tsx

@@ -1,9 +1,10 @@
-import React, {
-  Ref, useEffect, useState,
-} from 'react';
+import type { Ref } from 'react';
+import React, { useEffect, useState } from 'react';
 
 import type { SWRInfiniteResponse } from 'swr/infinite';
 
+import { LoadingSpinner } from './LoadingSpinner';
+
 type Props<T> = {
   swrInifiniteResponse: SWRInfiniteResponse<T>
   children: React.ReactNode,
@@ -32,7 +33,7 @@ const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
 const LoadingIndicator = (): React.ReactElement => {
   return (
     <div className="text-muted text-center">
-      <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+      <LoadingSpinner className="me-1 fs-3" />
     </div>
   );
 };

+ 11 - 4
apps/app/src/components/InstallerForm.tsx

@@ -1,6 +1,5 @@
-import {
-  FormEventHandler, memo, useCallback, useState,
-} from 'react';
+import type { FormEventHandler } from 'react';
+import { memo, useCallback, useState } from 'react';
 
 import { Lang, AllLang } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -11,6 +10,8 @@ import { i18n as i18nConfig } from '^/config/next-i18next.config';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 
+import { LoadingSpinner } from './LoadingSpinner';
+
 const InstallerForm = memo((): JSX.Element => {
   const { t, i18n } = useTranslation();
 
@@ -203,7 +204,13 @@ const InstallerForm = memo((): JSX.Element => {
               disabled={isLoading}
             >
               <div className="eff"></div>
-              <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse me-1' : 'icon-user-follow'} /></span>
+              <span className="btn-label">
+                {isLoading ? (
+                  <LoadingSpinner />
+                ) : (
+                  <i className="icon-user-follow" />
+                )}
+              </span>
               <span className="btn-label-text">{ t('Create') }</span>
             </button>
           </div>

+ 9 - 1
apps/app/src/components/InvitedForm.tsx

@@ -7,6 +7,8 @@ import { apiv3Post } from '~/client/util/apiv3-client';
 
 import { useCurrentUser } from '../stores/context';
 
+import { LoadingSpinner } from './LoadingSpinner';
+
 
 export type InvitedFormProps = {
   invitedFormUsername: string,
@@ -141,7 +143,13 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         <div className="input-group justify-content-center d-flex mt-4">
           <button type="submit" className="btn btn-fill" id="register" disabled={isLoading}>
             <div className="eff"></div>
-            <span className="btn-label"><i className={isLoading ? 'fa fa-spinner fa-pulse me-1' : 'icon-user-follow'} /></span>
+            <span className="btn-label">
+              {isLoading ? (
+                <LoadingSpinner />
+              ) : (
+                <i className="icon-user-follow" />
+              )}
+            </span>
             <span className="btn-label-text">{t('Create')}</span>
           </button>
         </div>

+ 0 - 7
apps/app/src/components/LoadingSpinner.jsx

@@ -1,7 +0,0 @@
-import React from 'react';
-
-import styles from './LoadingSpinner.module.scss';
-
-export const LoadingSpinner = () => (
-  <span className={`material-symbols-outlined pb-0 ${styles.spinner}`}>progress_activity</span>
-);

+ 7 - 0
apps/app/src/components/LoadingSpinner.tsx

@@ -0,0 +1,7 @@
+import React, { type ComponentPropsWithoutRef } from 'react';
+
+import styles from './LoadingSpinner.module.scss';
+
+export const LoadingSpinner = ({ className = '' }: ComponentPropsWithoutRef<'span'>): JSX.Element => (
+  <span className={`material-symbols-outlined pb-0 ${styles.spinner} ${className}`}>progress_activity</span>
+);

+ 1 - 2
apps/app/src/components/LoginForm.tsx

@@ -181,8 +181,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
         {/* !! - DO NOT DELETE HIDDEN ELEMENT - !! -- 7.12 ryoji-s */}
         {/* Import font-awesome to prevent MongoStore.js "Unable to find the session to touch" error */}
         <div className="visually-hidden">
-          {/* Unsettled 11.17 meiri-k */}
-          <i className="fa fa-spinner fa-pulse" />
+          <LoadingSpinner />
         </div>
         {/* !! - END OF HIDDEN ELEMENT - !! */}
         {isLdapSetupFailed && (

+ 3 - 1
apps/app/src/components/Me/QuestionnaireSettings.tsx

@@ -8,6 +8,8 @@ import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useSWRxIsQuestionnaireEnabled } from '~/features/questionnaire/client/stores/questionnaire';
 import { useCurrentUser } from '~/stores/context';
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 
 export const QuestionnaireSettings = (): JSX.Element => {
   const { t } = useTranslation();
@@ -45,7 +47,7 @@ export const QuestionnaireSettings = (): JSX.Element => {
 
       {isLoadingCurrentUser && (
         <div className="text-muted text-center mb-5">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1" />
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       )}
 

+ 20 - 5
apps/app/src/components/Page/PageContentsUtilities.tsx

@@ -3,7 +3,8 @@ import { useTranslation } from 'next-i18next';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { useDrawioModalLauncherForView } from '~/client/services/side-effects/drawio-modal-launcher-for-view';
 import { useHandsontableModalLauncherForView } from '~/client/services/side-effects/handsontable-modal-launcher-for-view';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { PageUpdateErrorCode } from '~/interfaces/apiv3';
 import { useCurrentPageId } from '~/stores/page';
 
 
@@ -19,8 +20,15 @@ export const PageContentsUtilities = (): null => {
 
       updateStateAfterSave?.();
     },
-    onSaveError: (error) => {
-      toastError(error);
+    onSaveError: (errors) => {
+      for (const error of errors) {
+        if (error.code === PageUpdateErrorCode.CONFLICT) {
+          toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+          return;
+        }
+      }
+
+      toastError(errors);
     },
   });
 
@@ -30,8 +38,15 @@ export const PageContentsUtilities = (): null => {
 
       updateStateAfterSave?.();
     },
-    onSaveError: (error) => {
-      toastError(error);
+    onSaveError: (errors) => {
+      for (const error of errors) {
+        if (error.code === PageUpdateErrorCode.CONFLICT) {
+          toastWarning(t('modal_resolve_conflict.conflicts_with_new_body_on_server_side'));
+          return;
+        }
+      }
+
+      toastError(errors);
     },
   });
 

+ 4 - 1
apps/app/src/components/Page/RevisionLoader.tsx

@@ -7,6 +7,9 @@ import type { RendererOptions } from '~/interfaces/renderer-options';
 import { useSWRxPageRevision } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+
+import { LoadingSpinner } from '../LoadingSpinner';
+
 import RevisionRenderer from './RevisionRenderer';
 
 export const ROOT_ELEM_ID = 'revision-loader' as const;
@@ -64,7 +67,7 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       </div>
     );

+ 2 - 1
apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx

@@ -9,6 +9,7 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useDeleteAttachmentModal } from '~/stores/modal';
 import { useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
 
+import { LoadingSpinner } from '../LoadingSpinner';
 import { PageAttachmentList } from '../PageAttachment/PageAttachmentList';
 import PaginationWrapper from '../PaginationWrapper';
 
@@ -63,7 +64,7 @@ const PageAttachment = (): JSX.Element => {
     if (dataAttachments == null || inUseAttachmentsMap == null) {
       return (
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       );
     }

+ 2 - 1
apps/app/src/components/PageControls/BookmarkButtons.tsx

@@ -12,6 +12,7 @@ import { useIsGuestUser } from '~/stores/context';
 
 import { BookmarkFolderMenu } from '../Bookmarks/BookmarkFolderMenu';
 import UserPictureList from '../Common/UserPictureList';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 import styles from './BookmarkButtons.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
@@ -94,7 +95,7 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
       </button>
       <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
         <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
-          { isLoadingBookmarkedUsers && <i className="fa fa-spinner fa-pulse"></i> }
+          { isLoadingBookmarkedUsers && <LoadingSpinner /> }
           { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
             <>
               { bookmarkedUsers.length > 0

+ 38 - 142
apps/app/src/components/PageEditor/ConflictDiffModal.tsx

@@ -1,9 +1,8 @@
-
 import React, {
   useState, useEffect, useCallback, useMemo,
 } from 'react';
 
-import type { IRevisionOnConflict } from '@growi/core';
+import type { IUser } from '@growi/core';
 import {
   MergeViewer, CodeMirrorEditorDiff, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated,
 } from '@growi/editor';
@@ -14,32 +13,32 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useCurrentPathname, useCurrentUser } from '~/stores/context';
+import { useCurrentUser } from '~/stores/context';
 import { useConflictDiffModal } from '~/stores/modal';
-import { useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import {
-  useRemoteRevisionBody, useRemoteRevisionId, useRemoteRevisionLastUpdatedAt, useRemoteRevisionLastUpdateUser, useSetRemoteLatestPageData,
+  useRemoteRevisionBody, useRemoteRevisionId, useRemoteRevisionLastUpdatedAt, useRemoteRevisionLastUpdateUser,
 } from '~/stores/remote-latest-page';
 
 import styles from './ConflictDiffModal.module.scss';
 
+type IRevisionOnConflict = {
+  revisionBody: string
+  createdAt: Date
+  user: IUser
+}
+
 type ConflictDiffModalCoreProps = {
-  // optionsToSave: OptionsToSave | undefined;
-  request: IRevisionOnConflictWithStringDate,
-  latest: IRevisionOnConflictWithStringDate,
-  onClose?: () => void,
-  onResolved?: () => void,
+  request: IRevisionOnConflict
+  latest: IRevisionOnConflict
 };
 
-type IRevisionOnConflictWithStringDate = Omit<IRevisionOnConflict, 'createdAt'> & {
-  createdAt: string
-}
+const formatedDate = (date: Date): string => {
+  return format(date, 'yyyy/MM/dd HH:mm:ss');
+};
 
 const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element => {
-  const {
-    request, latest, onClose, onResolved,
-  } = props;
+  const { request, latest } = props;
 
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
@@ -50,12 +49,6 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
   const { data: conflictDiffModalStatus, close: closeConflictDiffModal } = useConflictDiffModal();
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.DIFF);
 
-  // const { data: remoteRevisionId } = useRemoteRevisionId();
-  // const { setRemoteLatestPageData } = useSetRemoteLatestPageData();
-  // const { data: pageId } = useCurrentPageId();
-  // const { data: currentPagePath } = useCurrentPagePath();
-  // const { data: currentPathname } = useCurrentPathname();
-
   const selectRevisionHandler = useCallback((selectedRevision: string) => {
     setResolvedRevision(selectedRevision);
     setRevisionSelectedToggler(prev => !prev);
@@ -65,17 +58,14 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
     }
   }, [isRevisionselected]);
 
-  const closeModalHandler = useCallback(() => {
-    closeConflictDiffModal();
-    onClose?.();
-  }, [closeConflictDiffModal, onClose]);
-
   const resolveConflictHandler = useCallback(async() => {
     const newBody = codeMirrorEditor?.getDoc();
+    if (newBody == null) {
+      return;
+    }
 
-    // TODO: impl
-    onResolved?.();
-  }, [codeMirrorEditor, onResolved]);
+    await conflictDiffModalStatus?.onResolve?.(newBody);
+  }, [codeMirrorEditor, conflictDiffModalStatus]);
 
   useEffect(() => {
     codeMirrorEditor?.initDoc(resolvedRevision);
@@ -87,11 +77,11 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
       <button type="button" className="btn" onClick={() => setIsModalExpanded(prev => !prev)}>
         <span className="material-symbols-outlined">{isModalExpanded ? 'close_fullscreen' : 'open_in_full'}</span>
       </button>
-      <button type="button" className="btn" onClick={closeModalHandler} aria-label="Close">
+      <button type="button" className="btn" onClick={closeConflictDiffModal} aria-label="Close">
         <span className="material-symbols-outlined">close</span>
       </button>
     </div>
-  ), [closeModalHandler, isModalExpanded]);
+  ), [closeConflictDiffModal, isModalExpanded]);
 
   return (
     <Modal isOpen={conflictDiffModalStatus?.isOpened} className={`${styles['conflict-diff-modal']} ${isModalExpanded ? ' grw-modal-expanded' : ''}`} size="xl">
@@ -114,7 +104,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {request.user.username}</p>
-                <p className="my-0">{request.createdAt}</p>
+                <p className="my-0">{ formatedDate(request.createdAt) }</p>
               </div>
             </div>
           </div>
@@ -127,7 +117,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {latest.user.username}</p>
-                <p className="my-0">{latest.createdAt}</p>
+                <p className="my-0">{ formatedDate(latest.createdAt) }</p>
               </div>
             </div>
           </div>
@@ -176,7 +166,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
         <button
           type="button"
           className="btn btn-outline-secondary"
-          onClick={closeModalHandler}
+          onClick={closeConflictDiffModal}
         >
           {t('Cancel')}
         </button>
@@ -194,86 +184,10 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
 };
 
 
-const dummyTest1 = `# :tada: グローウィ へようこそ
-[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
-[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/weseek/growi/blob/master/LICENSE)
-
-グローウィ は個人・法人向けの Wiki | ナレッジベースツールです。
-会社や大学の研究室、サークルでのナレッジ情報を簡単に共有でき、作られたページは誰でも編集が可能です。
-
-知っている情報をカジュアルに書き出しみんなで編集することで、**チーム内での暗黙知を減らす**ことができます。
-当たり前に共有される情報を日々増やしていきましょう。
-
-### :beginner: 簡単なページの作り方
-
-- 右上の "**作成**"ボタンまたは右下の**鉛筆アイコン**のボタンからページを書き始めることができます
-    - ページタイトルは後から変更できますので、適当に入力しても大丈夫です
-        - タイトル入力欄では、半角の / (スラッシュ) でページ階層を作れます
-        - (例)/カテゴリ1/カテゴリ2/作りたいページタイトル のように入力してみてください
-- \`\`- \` を行頭につけると、この文章のような箇条書きを書くことができます\`\`
-- 画像やPDF、Word/Excel/PowerPointなどの添付ファイルも、コピー&ペースト、ドラッグ&ドロップで貼ることができます
-- 書けたら "**更新**" ボタンを押してページを公開しましょう
-    - \`Ctrl(⌘) + S\` でも保存できます
-
-さらに詳しくはこちら: [ページを作成する](https://docs.growi.org/ja/guide/features/create_page.html)
-
-<div class="mt-4 card border-primary">
-  <div class="card-header bg-primary text-light">Tips</div>
-  <div class="card-body"><ul>
-    <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
-    <li>HTML/CSS の記述には、<a href="https://getbootstrap.com/docs/4.6/components/">Bootstrap 4</a> を利用できます</li>
-  </ul></div>
-</div>
-`;
-
-const dummyTest2 = `# :tada: GROWI へようこそ
-[![GitHub Releases](https://img.shields.io/github/release/weseek/growi.svg)](https://github.com/weseek/growi/releases/latest)
-[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](https://github.com/weseek/growi/blob/master/LICENSE)
-
-GROWI は個人・法人向けの Wiki | ナレッジベースツールです。
-会社や大学の研究室、サークルでのナレッジ情報を簡単に共有でき、作られたページは誰でも編集が可能です。
-
-知っている情報をカジュアルに書き出しみんなで編集することで、**チーム内での暗黙知を減らす**ことができます。
-当たり前に共有される情報を日々増やしていきましょう。
-
-### :beginner: 簡単なページの作り方
-
-- 右上の "**作成**"ボタンまたは右下の**鉛筆アイコン**のボタンからページを書き始めることができます
-    - ページタイトルは後から変更できますので、適当に入力しても大丈夫です
-        - タイトル入力欄では、半角の / (スラッシュ) でページ階層を作れます
-        - (例)/カテゴリ1/カテゴリ2/作りたいページタイトル のように入力してみてください
-- \`\`- \` を行頭につけると、この文章のような箇条書きを書くことができます\`\`
-- 画像やPDF、Word/Excel/PowerPointなどの添付ファイルも、コピー&ペースト、ドラッグ&ドロップで貼ることができます
-- 書けたら "**更新**" ボタンを押してページを公開しましょう
-    - \`Ctrl(⌘) + S\` でも保存できます
-
-さらに詳しくはこちら: [ページを作成する](https://docs.growi.org/ja/guide/features/create_page.html)
-
-<div class="mt-4 card border-primary">
-  <div class="card-header bg-primary text-light">Tips</div>
-  <div class="card-body"><ul>
-    <li>Ctrl(⌘) + "/" でショートカットヘルプを表示します</li>
-    <li>HTML/CSS の記述には、<a href="https://getbootstrap.com/docs/4.6/components/">Bootstrap 4</a> を利用できます</li>
-  </ul></div>
-</div>
-`;
-
-type ConflictDiffModalProps = {
-  onClose?: () => void,
-  onResolved?: () => void,
-  // optionsToSave: OptionsToSave | undefined;
-  // afterResolvedHandler: () => void,
-};
-
-
-export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element => {
-  const {
-    onClose, onResolved,
-  } = props;
+export const ConflictDiffModal = (): JSX.Element => {
   const { data: currentUser } = useCurrentUser();
-
-  // state for current page
   const { data: currentPage } = useSWRxCurrentPage();
+  const { data: conflictDiffModalStatus } = useConflictDiffModal();
 
   // state for latest page
   const { data: remoteRevisionId } = useRemoteRevisionId();
@@ -281,43 +195,25 @@ export const ConflictDiffModal = (props: ConflictDiffModalProps): JSX.Element =>
   const { data: remoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: remoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
 
-  const { data: conflictDiffModalStatus } = useConflictDiffModal();
-
-  const currentTime: Date = new Date();
-
   const isRemotePageDataInappropriate = remoteRevisionId == null || remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
 
-  if (!conflictDiffModalStatus?.isOpened || currentUser == null || currentPage == null) {
+  if (!conflictDiffModalStatus?.isOpened || currentUser == null || currentPage == null || isRemotePageDataInappropriate) {
     return <></>;
   }
 
-  const request: IRevisionOnConflictWithStringDate = {
-    revisionId: '',
-    revisionBody: dummyTest1,
-    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
-    user: currentUser,
-  };
+  const currentTime: Date = new Date();
 
-  const latest: IRevisionOnConflictWithStringDate = {
-    revisionId: '',
-    revisionBody: dummyTest2,
-    createdAt: format(currentTime, 'yyyy/MM/dd HH:mm:ss'),
+  const request: IRevisionOnConflict = {
+    revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
+    createdAt: currentTime,
     user: currentUser,
   };
 
-  // const latest: IRevisionOnConflictWithStringDate = {
-  //   revisionId: remoteRevisionId,
-  //   revisionBody: remoteRevisionBody,
-  //   createdAt: format(new Date(remoteRevisionLastUpdatedAt || currentTime.toString()), 'yyyy/MM/dd HH:mm:ss'),
-  //   user: remoteRevisionLastUpdateUser,
-  // };
-
-  const propsForCore = {
-    onResolved,
-    onClose,
-    request,
-    latest,
+  const latest: IRevisionOnConflict = {
+    revisionBody: remoteRevisionBody,
+    createdAt: new Date(remoteRevisionLastUpdatedAt ?? currentTime.toString()),
+    user: remoteRevisionLastUpdateUser,
   };
 
-  return <ConflictDiffModalCore {...propsForCore} />;
+  return <ConflictDiffModalCore request={request} latest={latest} />;
 };

+ 3 - 1
apps/app/src/components/PageEditor/DrawioModal.tsx

@@ -18,6 +18,8 @@ import { useDrawioModal } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import loggerFactory from '~/utils/logger';
 
+import { LoadingSpinner } from '../LoadingSpinner';
+
 import { type DrawioConfig, DrawioCommunicationHelper } from './DrawioCommunicationHelper';
 
 const logger = loggerFactory('growi:components:DrawioModal');
@@ -133,7 +135,7 @@ export const DrawioModal = (): JSX.Element => {
         {/* Loading spinner */}
         <div className="w-100 h-100 position-absolute d-flex">
           <div className="mx-auto my-auto">
-            <i className="fa fa-3x fa-spinner fa-pulse mx-auto text-muted"></i>
+            <LoadingSpinner className="mx-auto text-muted fs-2" />
           </div>
         </div>
         {/* iframe */}

+ 3 - 15
apps/app/src/components/PageEditor/EditorNavbarBottom.module.scss

@@ -5,23 +5,11 @@
 @include mixins.editing() {
   .grw-editor-navbar-bottom :global {
     .grw-grant-selector {
-      @include bs.media-breakpoint-down(sm) {
-        .btn .label {
-          display: none;
-        }
-      }
-      @include bs.media-breakpoint-up(md) {
-        .dropdown-toggle {
-          min-width: 100px;
-
-          // caret
-          &::after {
-            margin-left: 1em;
-          }
-        }
+      .material-symbols-outlined  {
+        padding-bottom: 2px;
+        font-size: 19px;
       }
     }
-
     .btn-submit {
       width: 100px;
     }

+ 5 - 98
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -1,114 +1,21 @@
-import React, { useCallback, useState, useEffect } from 'react';
-
 import dynamic from 'next/dynamic';
-import { Collapse, Button } from 'reactstrap';
-
-
-import type { SavePageControlsProps } from '~/components/SavePageControls';
-import { useIsSlackConfigured } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useCurrentPagePath } from '~/stores/page';
-import {
-  useEditorMode, useIsDeviceLargerThanLg, useIsDeviceLargerThanMd,
-} from '~/stores/ui';
-
 
 import styles from './EditorNavbarBottom.module.scss';
 
 const moduleClass = styles['grw-editor-navbar-bottom'];
 
-
-const SavePageControls = dynamic<SavePageControlsProps>(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
-const SlackLogo = dynamic(() => import('~/components/SlackLogo').then(mod => mod.SlackLogo), { ssr: false });
-const SlackNotification = dynamic(() => import('~/components/SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
+const SavePageControls = dynamic(() => import('~/components/SavePageControls').then(mod => mod.SavePageControls), { ssr: false });
 const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSelector').then(mod => mod.OptionsSelector), { ssr: false });
 
-
 const EditorNavbarBottom = (): JSX.Element => {
-
-  const [isSlackExpanded, setSlackExpanded] = useState(false);
-
-  const { data: editorMode } = useEditorMode();
-  const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-
-  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
-
-  const [slackChannelsStr, setSlackChannelsStr] = useState<string>('');
-
-  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
-  const slackChannelsDataString = slackChannelsData?.toString();
-  useEffect(() => {
-    if (editorMode === 'editor') {
-      setSlackChannelsStr(slackChannelsDataString ?? '');
-      mutateIsSlackEnabled(false);
-    }
-  }, [editorMode, mutateIsSlackEnabled, slackChannelsDataString]);
-
-  const isSlackEnabledToggleHandler = (bool: boolean) => {
-    mutateIsSlackEnabled(bool, false);
-  };
-
-  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
-    setSlackChannelsStr(slackChannels);
-  }, []);
-
   return (
     <div className="border-top" data-testid="grw-editor-navbar-bottom">
-      {/* Collapsed SlackNotification */}
-      {isSlackConfigured && (
-        <Collapse isOpen={isSlackExpanded && !isDeviceLargerThanLg}>
-          <nav className={`navbar navbar-expand-lg border-top ${moduleClass}`}>
-            {isSlackEnabled != null
-            && (
-              <SlackNotification
-                isSlackEnabled={isSlackEnabled}
-                slackChannels={slackChannelsStr}
-                onEnabledFlagChange={isSlackEnabledToggleHandler}
-                onChannelChange={slackChannelsChangedHandler}
-                id="idForEditorNavbarBottomForMobile"
-              />
-            )
-            }
-          </nav>
-        </Collapse>
-      )
-      }
       <div className={`flex-expand-horiz align-items-center px-2 py-1 py-md-2 px-md-3 ${moduleClass}`}>
-        <form>
-          <OptionsSelector collapsed={!isDeviceLargerThanMd} />
+        <form className="m-2 me-auto">
+          <OptionsSelector />
         </form>
-        <form className="row row-cols-lg-auto g-3 align-items-center ms-auto">
-          {/* Responsive Design for the SlackNotification */}
-          {/* Button or the normal Slack banner */}
-          {isSlackConfigured && (!isDeviceLargerThanMd ? (
-            <Button
-              className="grw-btn-slack border me-2"
-              onClick={() => (setSlackExpanded(!isSlackExpanded))}
-            >
-              <div className="grw-slack-logo">
-                <SlackLogo />
-                <span className="grw-btn-slack-triangle material-symbols-outlined ms-2">arrow_drop_up</span>
-              </div>
-            </Button>
-          ) : (
-            <div className="me-2">
-              {isSlackEnabled != null
-              && (
-                <SlackNotification
-                  isSlackEnabled={isSlackEnabled}
-                  slackChannels={slackChannelsStr}
-                  onEnabledFlagChange={isSlackEnabledToggleHandler}
-                  onChannelChange={slackChannelsChangedHandler}
-                  id="idForEditorNavbarBottom"
-                />
-              )}
-            </div>
-          ))}
-          <SavePageControls slackChannels={slackChannelsStr} />
+        <form className="m-2">
+          <SavePageControls />
         </form>
       </div>
     </div>

+ 9 - 4
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -13,6 +13,9 @@ import {
 
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
+import {
+  useIsDeviceLargerThanMd,
+} from '~/stores/ui';
 
 type RadioListItemProps = {
   onClick: () => void,
@@ -256,7 +259,7 @@ const OptionsStatus = {
 } as const;
 type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
 
-export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Element => {
+export const OptionsSelector = (): JSX.Element => {
 
   const { t } = useTranslation();
 
@@ -266,6 +269,7 @@ export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Eleme
   const { data: editorSettings } = useEditorSettings();
   const { data: currentIndentSize } = useCurrentIndentSize();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
   if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) {
     return <></>;
@@ -275,14 +279,15 @@ export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Eleme
     <Dropdown isOpen={dropdownOpen} toggle={() => { setStatus(OptionsStatus.Home); setDropdownOpen(!dropdownOpen) }} direction="up" className="">
       <DropdownToggle
         className={`btn btn-sm btn-outline-neutral-secondary d-flex align-items-center justify-content-center
-              ${collapsed ? 'border-0' : 'border border-secondary'}
+              ${isDeviceLargerThanMd ? '' : 'border-0'}
               ${dropdownOpen ? 'active' : ''}
               `}
       >
         <span className="material-symbols-outlined py-0 fs-5"> settings </span>
         {
-          collapsed ? <></>
-            : <label className="ms-1 me-1">{t('page_edit.editor_config')}</label>
+          isDeviceLargerThanMd
+            ? <label className="ms-1 me-1">{t('page_edit.editor_config')}</label>
+            : <></>
         }
       </DropdownToggle>
       <DropdownMenu container="body">

+ 2 - 13
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -18,9 +18,9 @@ import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
 import { throttle, debounce } from 'throttle-debounce';
 
-
 import { useShouldExpandContent } from '~/client/services/layout';
-import { useUpdateStateAfterSave, updatePage } from '~/client/services/page-operation';
+import { useUpdateStateAfterSave } from '~/client/services/page-operation';
+import { updatePage } from '~/client/services/update-page';
 import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { SocketEventName } from '~/interfaces/websocket';
@@ -64,8 +64,6 @@ import { scrollEditor, scrollPreview } from './ScrollSyncHelper';
 
 import '@growi/editor/dist/style.css';
 
-// import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
-// import { ConflictDiffModal } from './ConflictDiffModal';
 
 const logger = loggerFactory('growi:PageEditor');
 
@@ -466,15 +464,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             style={pastEndStyle}
           />
         </div>
-        {/*
-        <ConflictDiffModal
-          isOpen={conflictDiffModalStatus?.isOpened}
-          onClose={() => closeConflictDiffModal()}
-          markdownOnEdit={markdownToPreview}
-          optionsToSave={optionsToSave}
-          afterResolvedHandler={afterResolvedHandler}
-        />
-        */}
       </div>
 
       <EditorNavbarBottom />

+ 4 - 3
apps/app/src/components/PageList/PageList.tsx

@@ -3,9 +3,10 @@ import React from 'react';
 import type { IPageInfoForEntity, IPageWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
-import { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
+import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 
-import { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 import { PageListItemL } from './PageListItemL';
 
@@ -30,7 +31,7 @@ const PageList = (props: Props<IPageInfoForEntity>): JSX.Element => {
     return (
       <div className="wiki">
         <div className="text-muted text-center">
-          <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+          <LoadingSpinner className="me-1 fs-3" />
         </div>
       </div>
     );

+ 2 - 1
apps/app/src/components/PagePresentationModal.tsx

@@ -14,6 +14,7 @@ import { useSWRxCurrentPage } from '~/stores/page';
 import { usePresentationViewOptions } from '~/stores/renderer';
 import { useNextThemes } from '~/stores/use-next-themes';
 
+import { LoadingSpinner } from './LoadingSpinner';
 
 import styles from './PagePresentationModal.module.scss';
 
@@ -21,7 +22,7 @@ import styles from './PagePresentationModal.module.scss';
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
   ssr: false,
   loading: () => (
-    <i className="fa fa-4x fa-spinner fa-pulse text-muted"></i>
+    <LoadingSpinner className="text-muted fs-1" />
   ),
 });
 

+ 1 - 1
apps/app/src/components/PageRenameModal.tsx

@@ -287,7 +287,7 @@ const PageRenameModal = (): JSX.Element => {
 
         <p className="mt-2">
           <button type="button" className="btn btn-link mt-2 p-0" aria-expanded="false" onClick={() => setExpandOtherOptions(!expandOtherOptions)}>
-            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'fa-rotate-90' : ''}`}>navigate_next</span>
+            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'rotate-90' : ''}`}>navigate_next</span>
             { t('modal_rename.label.Other options') }
           </button>
         </p>

+ 1 - 1
apps/app/src/components/PageStatusAlert.tsx

@@ -42,7 +42,7 @@ export const PageStatusAlert = (): JSX.Element => {
   }, [mutateEditingMarkdown, mutatePageData]);
 
   const onClickResolveConflict = useCallback(() => {
-    openConflictDiffModal();
+    // openConflictDiffModal();
   }, [openConflictDiffModal]);
 
   // TODO: re-impl for builtin editor

+ 11 - 9
apps/app/src/components/PrivateLegacyPages.tsx

@@ -8,28 +8,30 @@ import {
   UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import type { ISelectableAll, ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import { V5ConversionErrCode } from '~/interfaces/errors/v5-conversion-error';
-import { V5MigrationStatus } from '~/interfaces/page-listing-results';
-import { IFormattedSearchResult } from '~/interfaces/search';
-import { PageMigrationErrorData, SocketEventName } from '~/interfaces/websocket';
+import type { V5MigrationStatus } from '~/interfaces/page-listing-results';
+import type { IFormattedSearchResult } from '~/interfaces/search';
+import type { PageMigrationErrorData } from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
 import { useIsAdmin } from '~/stores/context';
-import {
-  ILegacyPrivatePage, usePrivateLegacyPagesMigrationModal,
-} from '~/stores/modal';
+import type { ILegacyPrivatePage } from '~/stores/modal';
+import { usePrivateLegacyPagesMigrationModal } from '~/stores/modal';
 import { mutatePageTree, useSWRxV5MigrationStatus } from '~/stores/page-listing';
 import {
   useSWRxSearch,
 } from '~/stores/search';
 
 import { MenuItemType } from './Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from './LoadingSpinner';
 import PaginationWrapper from './PaginationWrapper';
 import { PrivateLegacyPagesMigrationModal } from './PrivateLegacyPagesMigrationModal';
 import { OperateAllControl } from './SearchPage/OperateAllControl';
 import SearchControl from './SearchPage/SearchControl';
-import { IReturnSelectedPageIds, SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
+import type { IReturnSelectedPageIds } from './SearchPage/SearchPageBase';
+import { SearchPageBase, usePageDeleteModalForBulkDeletion } from './SearchPage/SearchPageBase';
 
 
 // TODO: replace with "customize:showPageLimitationS"
@@ -61,7 +63,7 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   if (migrationStatus == null) {
     return (
       <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
-        <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+        <LoadingSpinner className="me-1 fs-3" />
       </div>
     );
   }

+ 205 - 55
apps/app/src/components/SavePageControls.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useState, useEffect } from 'react';
 
 import type EventEmitter from 'events';
 
@@ -6,23 +6,31 @@ import { isTopPage, isUsersProtectedPages } from '@growi/core/dist/utils/page-pa
 import { useTranslation } from 'next-i18next';
 import {
   UncontrolledButtonDropdown, Button,
-  DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownToggle, DropdownMenu, DropdownItem, Modal,
 } from 'reactstrap';
 
 import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IPageGrantData } from '~/interfaces/page';
 import {
   useIsEditable, useIsAclEnabled,
+  useIsSlackConfigured,
 } from '~/stores/context';
-import { useWaitingSaveProcessing } from '~/stores/editor';
-import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { useWaitingSaveProcessing, useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage, useCurrentPagePath } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
-import { useSelectedGrant } from '~/stores/ui';
+import {
+  useSelectedGrant,
+  useEditorMode, useIsDeviceLargerThanMd,
+  EditorMode,
+} from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
+
 import { unpublish } from '../client/services/page-operation';
 
+import { LoadingSpinner } from './LoadingSpinner';
 import { GrantSelector } from './SavePageControls/GrantSelector';
+import { SlackNotification } from './SlackNotification';
 
 
 declare global {
@@ -33,25 +41,19 @@ declare global {
 
 const logger = loggerFactory('growi:SavePageControls');
 
-export type SavePageControlsProps = {
-  slackChannels: string
-}
 
-export const SavePageControls = (props: SavePageControlsProps): JSX.Element | null => {
-  const { slackChannels } = props;
+const SavePageButton = (props: {slackChannels: string, isDeviceLargerThanMd?: boolean}) => {
+
   const { t } = useTranslation();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: isEditable } = useIsEditable();
-  const { data: isAclEnabled } = useIsAclEnabled();
-  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { mutate: mutateEditorMode } = useEditorMode();
+  const [isSavePageModalShown, setIsSavePageModalShown] = useState<boolean>(false);
 
-  const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
+  const { slackChannels, isDeviceLargerThanMd } = props;
 
-  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
-    mutateGrant(grantData);
-  }, [mutateGrant]);
+  const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
   const save = useCallback(async(): Promise<void> => {
     // save
@@ -74,46 +76,21 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
       await unpublish(pageId);
       await mutateCurrentPage();
       await mutatePageTree();
+      await mutateEditorMode(EditorMode.View);
       toastSuccess(t('wip_page.success_save_as_wip'));
     }
     catch (err) {
       logger.error(err);
       toastError(t('wip_page.fail_save_as_wip'));
     }
-  }, [currentPage?._id, mutateCurrentPage, t]);
-
-
-  if (isEditable == null || isAclEnabled == null || grantData == null) {
-    return null;
-  }
+  }, [currentPage?._id, mutateCurrentPage, mutateEditorMode, t]);
 
-  if (!isEditable) {
-    return null;
-  }
-
-  const { grant, userRelatedGrantedGroups } = grantData;
-
-  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
   const labelUnpublishPage = t('wip_page.save_as_wip');
 
   return (
-    <div className="d-flex align-items-center flex-nowrap">
-
-      {isAclEnabled
-        && (
-          <div className="me-2">
-            <GrantSelector
-              grant={grant}
-              disabled={isGrantSelectorDisabledPage}
-              userRelatedGrantedGroups={userRelatedGrantedGroups}
-              onUpdateGrant={updateGrantHandler}
-            />
-          </div>
-        )
-      }
-
+    <>
       <UncontrolledButtonDropdown direction="up" size="sm">
         <Button
           id="caret"
@@ -124,21 +101,194 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           disabled={isWaitingSaveProcessing}
         >
           {isWaitingSaveProcessing && (
-            <i className="fa fa-spinner fa-pulse me-1"></i>
+            <LoadingSpinner />
           )}
           {labelSubmitButton}
         </Button>
-        <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
-        <DropdownMenu container="body" end>
-          <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
-            {labelOverwriteScopes}
-          </DropdownItem>
-          <DropdownItem onClick={clickUnpublishButtonHandler}>
-            {labelUnpublishPage}
-          </DropdownItem>
-        </DropdownMenu>
+        {
+          isDeviceLargerThanMd ? (
+            <>
+              <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} />
+              <DropdownMenu container="body" end>
+                <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
+                  {labelOverwriteScopes}
+                </DropdownItem>
+                <DropdownItem onClick={clickUnpublishButtonHandler}>
+                  {labelUnpublishPage}
+                </DropdownItem>
+              </DropdownMenu>
+            </>
+          ) : (
+            <>
+              <DropdownToggle caret color="primary" disabled={isWaitingSaveProcessing} onClick={() => setIsSavePageModalShown(true)} />
+              <Modal
+                centered
+                isOpen={isSavePageModalShown}
+                toggle={() => setIsSavePageModalShown(false)}
+              >
+                <div className="d-flex flex-column pt-4 pb-3 px-4 gap-4">
+                  <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); saveAndOverwriteScopesOfDescendants() }}>
+                    {labelOverwriteScopes}
+                  </button>
+                  <button type="button" className="btn btn-primary" onClick={() => { setIsSavePageModalShown(false); clickUnpublishButtonHandler() }}>
+                    {labelUnpublishPage}
+                  </button>
+                  <button type="button" className="btn btn-outline-neutral-secondary mx-auto mt-1" onClick={() => setIsSavePageModalShown(false)}>
+                    <label className="mx-2">
+                      {t('Cancel')}
+                    </label>
+                  </button>
+                </div>
+              </Modal>
+            </>
+          )
+        }
       </UncontrolledButtonDropdown>
+    </>
+  );
+};
+
 
+export const SavePageControls = (): JSX.Element | null => {
+  const { t } = useTranslation('commons');
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { data: isEditable } = useIsEditable();
+  const { data: isAclEnabled } = useIsAclEnabled();
+  const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
+
+  const { data: editorMode } = useEditorMode();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: isSlackConfigured } = useIsSlackConfigured();
+  const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+
+  const [slackChannels, setSlackChannels] = useState<string>('');
+  const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] = useState<boolean>(false);
+
+  // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
+  const slackChannelsDataString = slackChannelsData?.toString();
+  useEffect(() => {
+    if (editorMode === 'editor') {
+      setSlackChannels(slackChannelsDataString ?? '');
+      mutateIsSlackEnabled(false);
+    }
+  }, [editorMode, mutateIsSlackEnabled, slackChannelsDataString]);
+
+
+  const isSlackEnabledToggleHandler = (bool: boolean) => {
+    mutateIsSlackEnabled(bool, false);
+  };
+
+  const slackChannelsChangedHandler = useCallback((slackChannels: string) => {
+    setSlackChannels(slackChannels);
+  }, []);
+
+  const updateGrantHandler = useCallback((grantData: IPageGrantData): void => {
+    mutateGrant(grantData);
+  }, [mutateGrant]);
+
+  if (isEditable == null || isAclEnabled == null || grantData == null) {
+    return null;
+  }
+
+  if (!isEditable) {
+    return null;
+  }
+
+  const { grant, userRelatedGrantedGroups } = grantData;
+
+  const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
+
+  return (
+    <div className="d-flex align-items-center flex-nowrap">
+      {
+        isDeviceLargerThanMd ? (
+          <>
+            {
+              isSlackConfigured && (
+                <div className="me-2">
+                  {isSlackEnabled != null && (
+                    <SlackNotification
+                      isSlackEnabled={isSlackEnabled}
+                      slackChannels={slackChannels}
+                      onEnabledFlagChange={isSlackEnabledToggleHandler}
+                      onChannelChange={slackChannelsChangedHandler}
+                      id="idForEditorNavbarBottom"
+                    />
+                  )}
+                </div>
+              )
+            }
+
+            {
+              isAclEnabled && (
+                <div className="me-2">
+                  <GrantSelector
+                    grant={grant}
+                    disabled={isGrantSelectorDisabledPage}
+                    userRelatedGrantedGroups={userRelatedGrantedGroups}
+                    onUpdateGrant={updateGrantHandler}
+                  />
+                </div>
+              )
+            }
+
+            <SavePageButton slackChannels={slackChannels} isDeviceLargerThanMd />
+          </>
+        ) : (
+          <>
+            <SavePageButton slackChannels={slackChannels} />
+            <button
+              type="button"
+              className="btn btn-outline-neutral-secondary border-0 fs-5 p-0 ms-1 text-muted"
+              onClick={() => setIsSavePageControlsModalShown(true)}
+            >
+              <span className="material-symbols-outlined">more_vert</span>
+            </button>
+            <Modal
+              className="save-page-controls-modal"
+              centered
+              isOpen={isSavePageControlsModalShown}
+            >
+              <div className="d-flex flex-column pt-5 pb-3 px-4 gap-3">
+                {
+                  isAclEnabled && (
+                    <>
+                      <GrantSelector
+                        grant={grant}
+                        disabled={isGrantSelectorDisabledPage}
+                        openInModal
+                        userRelatedGrantedGroups={userRelatedGrantedGroups}
+                        onUpdateGrant={updateGrantHandler}
+                      />
+                    </>
+                  )
+                }
+
+                {
+                  isSlackConfigured && isSlackEnabled != null && (
+                    <>
+                      <SlackNotification
+                        isSlackEnabled={isSlackEnabled}
+                        slackChannels={slackChannels}
+                        onEnabledFlagChange={isSlackEnabledToggleHandler}
+                        onChannelChange={slackChannelsChangedHandler}
+                        id="idForEditorNavbarBottom"
+                      />
+                    </>
+                  )
+                }
+                <div className="d-flex">
+                  <button type="button" className="mx-auto btn btn-primary rounded-1" onClick={() => setIsSavePageControlsModalShown(false)}>
+                    {t('Done')}
+                  </button>
+                </div>
+              </div>
+            </Modal>
+          </>
+        )
+      }
     </div>
   );
 };

+ 18 - 14
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -11,6 +11,7 @@ import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import type { IPageGrantData } from '~/interfaces/page';
 import { useCurrentUser } from '~/stores/context';
 
@@ -21,7 +22,7 @@ const AVAILABLE_GRANTS = [
     grant: PageGrant.GRANT_PUBLIC, iconName: 'group', btnStyleClass: 'outline-info', label: 'Public',
   },
   {
-    grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-success', label: 'Anyone with the link',
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
@@ -30,7 +31,7 @@ const AVAILABLE_GRANTS = [
   {
     grant: PageGrant.GRANT_USER_GROUP,
     iconName: 'more_horiz',
-    btnStyleClass: 'outline-purple',
+    btnStyleClass: 'outline-warning',
     label: 'Only inside the group',
     reselectLabel: 'Reselect the group',
   },
@@ -39,6 +40,7 @@ const AVAILABLE_GRANTS = [
 
 type Props = {
   disabled?: boolean,
+  openInModal?: boolean,
   grant: PageGrant,
   userRelatedGrantedGroups?: {
     id: string,
@@ -57,6 +59,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
   const {
     disabled,
+    openInModal,
     userRelatedGrantedGroups,
     onUpdateGrant,
     grant: currentGrant,
@@ -118,7 +121,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
         : opt.label;
 
       const labelElm = (
-        <span>
+        <span className={openInModal ? 'py-2' : ''}>
           <span className="material-symbols-outlined me-2">{opt.iconName}</span>
           <span className="label">{t(label)}</span>
         </span>
@@ -158,17 +161,17 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
     return (
       <div className="grw-grant-selector mb-0" data-testid="grw-grant-selector">
-        <UncontrolledDropdown direction="up" size="sm">
-          <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
+        <UncontrolledDropdown direction={openInModal ? 'down' : 'up'} size="sm">
+          <DropdownToggle color={dropdownToggleBtnColor} caret className="w-100 d-flex justify-content-between align-items-center" disabled={disabled}>
             {dropdownToggleLabelElm}
           </DropdownToggle>
-          <DropdownMenu container="body">
+          <DropdownMenu container={openInModal ? '' : 'body'}>
             {dropdownMenuElems}
           </DropdownMenu>
         </UncontrolledDropdown>
       </div>
     );
-  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t]);
+  }, [changeGrantHandler, currentGrant, disabled, userRelatedGrantedGroups, t, openInModal]);
 
   /**
    * Render select grantgroup modal.
@@ -182,7 +185,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     if (myUserGroups == null) {
       return (
         <div className="my-3 text-center">
-          <i className="fa fa-lg fa-spinner fa-pulse mx-auto text-muted"></i>
+          <LoadingSpinner className="mx-auto text-muted fs-4" />
         </div>
       );
     }
@@ -199,7 +202,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <>
+      <div className="d-flex flex-column">
         { myUserGroups.map((group) => {
           const groupIsGranted = userRelatedGrantedGroups?.find(g => g.id === group.item._id) != null;
           const activeClass = groupIsGranted ? 'active' : '';
@@ -212,14 +215,14 @@ export const GrantSelector = (props: Props): JSX.Element => {
               onClick={() => groupListItemClickHandler(group)}
             >
               <span className="align-middle"><input type="checkbox" checked={groupIsGranted} /></span>
-              <h5 className="d-inline-block ml-3">{group.item.name}</h5>
-              {group.type === GroupType.externalUserGroup && <span className="ml-2 badge badge-pill badge-info">{group.item.provider}</span>}
+              <h5 className="d-inline-block ms-3">{group.item.name}</h5>
+              {group.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{group.item.provider}</span>}
               {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
             </button>
           );
         }) }
-        <button type="button" className="btn btn-primary mt-2 float-right" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
-      </>
+        <button type="button" className="btn btn-primary mt-2 mx-auto" onClick={() => setIsSelectGroupModalShown(false)}>{t('Done')}</button>
+      </div>
     );
 
   }, [currentUser?.admin, groupListItemClickHandler, myUserGroups, shouldFetch, t, userRelatedGrantedGroups]);
@@ -233,8 +236,9 @@ export const GrantSelector = (props: Props): JSX.Element => {
         <Modal
           isOpen={isSelectGroupModalShown}
           toggle={() => setIsSelectGroupModalShown(false)}
+          centered
         >
-          <ModalHeader tag="h4" toggle={() => setIsSelectGroupModalShown(false)} className="bg-purple text-light">
+          <ModalHeader tag="h4" toggle={() => setIsSelectGroupModalShown(false)} className="bg-purple text-muted">
             {t('user_group.select_group')}
           </ModalHeader>
           <ModalBody>

+ 2 - 1
apps/app/src/components/SearchPage/SearchPageBase.tsx

@@ -17,6 +17,7 @@ import { usePageDeleteModal } from '~/stores/modal';
 import { mutatePageTree } from '~/stores/page-listing';
 
 import type { ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 // Do not import with next/dynamic
 // see: https://github.com/weseek/growi/pull/7923
@@ -181,7 +182,7 @@ const SearchPageBaseSubstance: ForwardRefRenderFunction<ISelectableAll & IReturn
           {/* Loading */}
           { pages == null && (
             <div className="mw-0 flex-grow-1 flex-basis-0 m-5 text-muted text-center">
-              <i className="fa fa-2x fa-spinner fa-pulse me-1"></i>
+              <LoadingSpinner className="me-1 fs-3" />
             </div>
           ) }
 

+ 0 - 19
apps/app/src/components/SlackLogo.jsx

@@ -1,19 +0,0 @@
-import React from 'react';
-
-export const SlackLogo = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 448 448"
-    height="20"
-    width="20"
-  >
-    <path
-      d="M94.12,283.1A47.06,47.06,0,1,1,47.06,236H94.12Zm23.72,
-      0a47.06,47.06,0,1,1,94.12,0V400.94a47.06,47.06,0,1,1-94.12,0Zm47.06-189A47.06,
-      47.06,0,1,1,212,47.06V94.12Zm0,23.72a47.06,47.06,0,0,1,0,94.12H47.06a47.06,47.06,
-      0,0,1,0-94.12Zm189,47.06A47.06,47.06,0,1,1,400.94,212H353.88V164.9Zm-23.72,0a47.06,
-      47.06,0,1,1-94.12,0V47.06a47.06,47.06,0,1,1,94.12,0V164.9Zm-47.06,189A47.06,47.06,
-      0,1,1,236,400.94V353.88Zm0-23.72a47.06,47.06,0,0,1,0-94.12H400.94a47.06,47.06,0,0,1,0,94.12Z"
-    />
-  </svg>
-);

+ 55 - 39
apps/app/src/components/SlackNotification.module.scss

@@ -1,45 +1,61 @@
 @use '@growi/core/scss/bootstrap/init' as bs;
 
-// TODO: activate (https://redmine.weseek.co.jp/issues/128307)
-.grw-slack-notification :global {
-  // $input-height-slack: bs.$form-check-indicator-size * 1.5;
-  // border-color: bs.$gray-200;
 
-  // border-style: solid;
-  // border-width: 1px;
-  // border-radius: $input-height-slack/2 2px 2px $input-height-slack/2;
+.grw-slack-switch :global {
+  .input-group-text {
+    background-color: inherit;
+  }
+  .form-check-input {
+    cursor: pointer;
+    background-repeat: no-repeat;
+    background-attachment:scroll;
+    background-clip: border-box;
+    background-origin: padding-box;
+    background-size: 30%, 45%;
+    box-shadow: none;
+    transition: all 0.4s ease-out;
+  }
+  .form-control::placeholder {
+    color: bs.$gray-500
+  }
+}
+
+:root[data-bs-theme='light'] {
+  .grw-slack-switch :global {
+    .form-check-input:not(:checked) {
+      background-color: bs.$gray-200;
+      background-image:
+        url('/images/icons/slack/slack-logo-off.svg'),
+        url('/images/icons/slack/slack-logo-background.svg');
+      background-position: 15%, 5%, 50%, 50%;
+    }
+
+    .form-check-input:checked {
+      background-color: #E7A9E8;
+      background-image:
+        url('/images/icons/slack/slack-logo-on.svg'),
+        url('/images/icons/slack/slack-logo-background.svg');
+      background-position: 85%, 95%, 50%, 50%;
+    }
+  }
+}
 
-  // .form-control {
-  //   height: $input-height-slack;
-  //   border: transparent;
-  //   @include bs.media-breakpoint-up(sm) {
-  //     width: 130px;
-  //   }
-  //   @include bs.media-breakpoint-up(md) {
-  //     width: 180px;
-  //   }
-  // }
-  // // height settings for slack button's responsive design
-  // // in the input and form-control element
-  // .grw-form-control-slack-notification.form-control {
-  //   height: $input-height-slack;
-  // }
-  // .grw-input-group-slack-notification {
-  //   height: $input-height-slack;
-  //   label {
-  //     display: flex;
-  //     align-items: center;
-  //     justify-content: center;
-  //     margin-bottom: 0;
-  //   }
-  // }
+:root[data-bs-theme='dark'] {
+  .grw-slack-switch :global {
+    .form-check-input:not(:checked) {
+      background-color: bs.$gray-200;
+      background-image:
+        url('/images/icons/slack/slack-logo-dark-off.svg'),
+        url('/images/icons/slack/slack-logo-dark-background.svg');
+      background-position: 14%, 4%, 50%, 50%;
+    }
 
-  // .form-check-label {
-  //   &::before {
-  //     border: transparent;
-  //   }
-  // }
+    .form-check-input:checked {
+      background-color: #731f74;
+      background-image:
+        url('/images/icons/slack/slack-logo-dark-on.svg'),
+        url('/images/icons/slack/slack-logo-dark-background.svg');
+      background-position: 86%, 95%, 50%, 50%;
+    }
+  }
 }
-// TODO デザインの使用が確定して実装、本タスクのスコープ外
-// .grw-slack-notification-xd {
-// }

+ 31 - 31
apps/app/src/components/SlackNotification.tsx

@@ -1,9 +1,11 @@
 /* eslint-disable react/prop-types */
 import type { FC } from 'react';
-import React from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
+import {
+  FormGroup, Input, InputGroup, InputGroupText,
+  PopoverBody, PopoverHeader, UncontrolledPopover,
+} from 'reactstrap';
 
 import styles from './SlackNotification.module.scss';
 
@@ -19,6 +21,7 @@ type SlackNotificationProps = {
 export const SlackNotification: FC<SlackNotificationProps> = ({
   id, isSlackEnabled, slackChannels, onEnabledFlagChange, onChannelChange,
 }) => {
+
   const { t } = useTranslation();
   const idForSlackPopover = `${id}ForSlackPopover`;
 
@@ -38,34 +41,31 @@ export const SlackNotification: FC<SlackNotificationProps> = ({
 
 
   return (
-    <div className={`grw-slack-notification ${styles['grw-slack-notification']} w-100`}>
-      <div className="grw-input-group-slack-notification input-group extended-setting">
-        <label className="form-label input-group-addon">
-          <div className="form-check form-switch form-switch-lg form-switch-slack">
-            <input
-              type="checkbox"
-              className="form-check-input border-0"
-              id={id}
-              checked={isSlackEnabled}
-              onChange={updateCheckboxHandler}
-            />
-            <label className="form-label form-check-label align-center" htmlFor={id}></label>
-          </div>
-        </label>
-        <input
-          className="grw-form-control-slack-notification form-control align-top ps-0"
-          id={idForSlackPopover}
-          type="text"
-          value={slackChannels}
-          placeholder={t('page_edit.input_channels', 'Input channels')}
-          onChange={updateSlackChannelsHandler}
-        />
-        <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
-          <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
-          <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
-        </UncontrolledPopover>
-      </div>
-    </div>
-
+    <InputGroup className={`d-flex align-items-center ${styles['grw-slack-switch']}`}>
+      <InputGroupText className="rounded-pill rounded-end border-end-0 p-0 pe-1 grw-slack-switch">
+        <FormGroup switch className="position-relative pe-4 py-3 m-0 me-2">
+          <Input
+            className="position-absolute bottom-0 start-0 p-0 m-0 w-100 h-100 border-0"
+            type="switch"
+            role="switch"
+            id={id}
+            checked={isSlackEnabled}
+            onChange={updateCheckboxHandler}
+          />
+        </FormGroup>
+      </InputGroupText>
+      <Input
+        className="rounded-pill rounded-start border-start-0 py-1"
+        id={idForSlackPopover}
+        type="text"
+        value={slackChannels}
+        placeholder="Input channels"
+        onChange={updateSlackChannelsHandler}
+      />
+      <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>
+        <PopoverHeader>{t('slack_notification.popover_title')}</PopoverHeader>
+        <PopoverBody>{t('slack_notification.popover_desc')}</PopoverBody>
+      </UncontrolledPopover>
+    </InputGroup>
   );
 };

+ 2 - 1
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -26,6 +26,7 @@ import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
+import { LoadingSpinner } from '../LoadingSpinner';
 import Preview from '../PageEditor/Preview';
 
 import { useFormatter } from './use-formatter';
@@ -186,7 +187,7 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
 
             { isLoading && (
               <div className="h-100 d-flex justify-content-center align-items-center">
-                <i className="fa fa-2x fa-spinner fa-pulse text-muted mx-auto"></i>
+                <LoadingSpinner className="mx-auto text-muted fs-3" />
               </div>
             ) }
 

+ 2 - 1
apps/app/src/components/TreeItem/SimpleItem.tsx

@@ -15,6 +15,7 @@ import { usePageTreeDescCountMap } from '~/stores/ui';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import CountBadge from '../Common/CountBadge';
+import { LoadingSpinner } from '../LoadingSpinner';
 
 import { ItemNode } from './ItemNode';
 import { useNewPageInput } from './NewPageInput';
@@ -248,7 +249,7 @@ export const SimpleItem: FC<SimpleItemProps> = (props) => {
               <ItemClassFixed {...itemProps} />
               {isProcessingSubmission && (currentChildren.length - 1 === index) && (
                 <div className="text-muted text-center">
-                  <i className="fa fa-spinner fa-pulse mr-1"></i>
+                  <LoadingSpinner className="mr-1" />
                 </div>
               )}
             </div>

+ 4 - 0
apps/app/src/interfaces/apiv3/page.ts

@@ -38,3 +38,7 @@ export type IApiv3PageUpdateResponse = {
   page: IPageHasId,
   revision: IRevisionHasId,
 };
+
+export const PageUpdateErrorCode = {
+  CONFLICT: 'conflict',
+} as const;

+ 2 - 0
apps/app/src/pages/[[...path]].page.tsx

@@ -80,6 +80,7 @@ const LinkEditModal = dynamic(() => import('../components/PageEditor/LinkEditMod
 const PageStatusAlert = dynamic(() => import('../components/PageStatusAlert').then(mod => mod.PageStatusAlert), { ssr: false });
 const QuestionnaireModalManager = dynamic(() => import('~/features/questionnaire/client/components/QuestionnaireModalManager'), { ssr: false });
 const TagEditModal = dynamic(() => import('../components/PageTags/TagEditModal').then(mod => mod.TagEditModal), { ssr: false });
+const ConflictDiffModal = dynamic(() => import('../components/PageEditor/ConflictDiffModal').then(mod => mod.ConflictDiffModal), { ssr: false });
 
 const logger = loggerFactory('growi:pages:all');
 
@@ -382,6 +383,7 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
       <TemplateModal />
       <LinkEditModal />
       <TagEditModal />
+      <ConflictDiffModal />
     </>
   );
 };

+ 2 - 1
apps/app/src/pages/tags.page.tsx

@@ -8,6 +8,7 @@ import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
 import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
+import { LoadingSpinner } from '~/components/LoadingSpinner';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IDataTagCount } from '~/interfaces/tag';
@@ -90,7 +91,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
           { isLoading
             ? (
               <div className="text-muted text-center">
-                <i className="fa fa-2x fa-spinner fa-pulse mt-3"></i>
+                <LoadingSpinner className="mt-3 fs-3" />
               </div>
             )
             : (

+ 2 - 4
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -9,7 +9,7 @@ import { body } from 'express-validator';
 import mongoose from 'mongoose';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
-import { type IApiv3PageUpdateParams } from '~/interfaces/apiv3';
+import { type IApiv3PageUpdateParams, PageUpdateErrorCode } from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type Crowi from '~/server/crowi';
@@ -145,9 +145,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
           createdAt: latestRevision?.createdAt,
           user: serializeUserSecurely(latestRevision?.author),
         };
-        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', 'conflict'), 409, {
-          returnLatestRevision,
-        });
+        return res.apiv3Err(new ErrorV3('Posted param "revisionId" is outdated.', PageUpdateErrorCode.CONFLICT, undefined, { returnLatestRevision }), 409);
       }
 
       let updatedPage;

+ 9 - 5
apps/app/src/stores/modal.tsx

@@ -566,13 +566,17 @@ export const useHandsontableModal = (status?: HandsontableModalStatus): SWRRespo
 /*
  * ConflictDiffModal
  */
+type ResolveConflictHandler = (newMarkdown: string) => Promise<void> | void;
+
 type ConflictDiffModalStatus = {
-  isOpened: boolean,
+ isOpened: boolean,
+ requestRevisionBody?: string,
+ onResolve?: ResolveConflictHandler
 }
 
 type ConflictDiffModalUtils = {
-  open(): void,
-  close(): void,
+ open(requestRevisionBody: string, onResolveConflict: ResolveConflictHandler): void,
+ close(): void,
 }
 
 export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Error> & ConflictDiffModalUtils => {
@@ -581,8 +585,8 @@ export const useConflictDiffModal = (): SWRResponse<ConflictDiffModalStatus, Err
   const swrResponse = useStaticSWR<ConflictDiffModalStatus, Error>('conflictDiffModal', undefined, { fallbackData: initialStatus });
 
   return Object.assign(swrResponse, {
-    open: () => {
-      swrResponse.mutate({ isOpened: true });
+    open: (requestRevisionBody: string, onResolve: ResolveConflictHandler) => {
+      swrResponse.mutate({ isOpened: true, requestRevisionBody, onResolve });
     },
     close: () => {
       swrResponse.mutate({ isOpened: false });

+ 0 - 27
apps/app/src/styles/_draft.scss

@@ -1,27 +0,0 @@
-.draft-list-item {
-  .panel-heading {
-    .icon-container {
-      a:hover {
-        text-decoration: unset;
-      }
-      i {
-        opacity: 0.5;
-      }
-    }
-
-    &:hover {
-      .icon-container {
-        i {
-          opacity: 1;
-        }
-      }
-    }
-  }
-
-  .draft-copy {
-    cursor: pointer;
-  }
-  .draft-path {
-    cursor: pointer;
-  }
-}

+ 0 - 8
apps/app/src/styles/_editor.scss

@@ -95,14 +95,6 @@
       }
     }
 
-    .grw-editor-configuration-dropdown {
-      .icon-container {
-        width: 20px;
-      }
-      .menuitem-label {
-        min-width: 130px;
-      }
-    }
   }
 
   // .builtin-editor .tab-pane#edit

+ 95 - 92
apps/app/src/styles/atoms/_custom_control.scss

@@ -2,95 +2,98 @@
 
 // TODO: activate (https://redmine.weseek.co.jp/issues/128307)
 
-// .form-check .form-check-label::before {
-//   border-radius: $border-radius !important;
-// }
-
-// label.form-check-label {
-//   font-weight: normal;
-// }
-
-// .form-switch.form-switch-sm {
-//   $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
-//   $form-switch-width-sm: $form-check-indicator-size-sm * 1.75;
-//   $form-check-gutter-sm: $form-check-gutter * 0.8;
-//   $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
-//   $form-switch-indicator-size-sm: subtract($form-check-indicator-size-sm, $form-check-indicator-border-width * 4);
-
-//   padding-left: $form-switch-width-sm + $form-check-gutter-sm;
-
-//   .form-check-label {
-//     &::before {
-//       left: -($form-switch-width-sm + $form-check-gutter-sm);
-//       width: $form-switch-width-sm;
-//       height: $form-check-indicator-size-sm;
-//     }
-
-//     &::after {
-//       top: add(($font-size-base * $line-height-base - $form-check-indicator-size) / 2, $form-check-indicator-border-width * 2);
-//       left: add(-($form-switch-width-sm + $form-check-gutter-sm), $form-check-indicator-border-width * 2);
-//       width: $form-switch-indicator-size-sm;
-//       height: $form-switch-indicator-size-sm;
-//     }
-//   }
-
-//   .form-check-input:checked ~ .form-check-label {
-//     &::after {
-//       transform: translateX($form-switch-width-sm - $form-check-indicator-size-sm);
-//     }
-//   }
-// }
-
-// //lg
-// .form-switch.form-switch-lg {
-//   $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
-//   $form-switch-width-lg: $form-check-indicator-size-lg * 1.75;
-//   $form-check-gutter-lg: $form-check-gutter * 1.5;
-//   $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
-//   $form-switch-indicator-size-lg: subtract($form-check-indicator-size-lg, $form-check-indicator-border-width * 4);
-
-//   padding-left: $form-switch-width-lg + $form-check-gutter-lg;
-
-//   line-height: $form-check-indicator-size-lg;
-//   .form-check-label {
-//     &::before {
-//       top: ($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2;
-
-//       left: -($form-switch-width-lg + $form-check-gutter-lg);
-//       width: $form-switch-width-lg;
-//       height: $form-check-indicator-size-lg;
-//       border-radius: $form-check-indicator-size-lg/2;
-//     }
-
-//     &::after {
-//       top: add(($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2, $form-check-indicator-border-width * 2);
-//       left: add(-($form-switch-width-lg + $form-check-gutter-lg), $form-check-indicator-border-width * 2);
-//       width: $form-switch-indicator-size-lg;
-//       height: $form-switch-indicator-size-lg;
-//       border-radius: $form-check-indicator-size-lg/2;
-//     }
-//   }
-
-//   .form-check-input:checked ~ .form-check-label {
-//     &::after {
-//       transform: translateX($form-switch-width-lg - $form-check-indicator-size-lg);
-//     }
-//   }
-// }
-
-// .form-switch.form-switch-slack {
-//   .form-check-label {
-//     &::before {
-//       background-color: $gray-200;
-//       border-color: transparent;
-//     }
-//     &::after {
-//       background-size: 15px;
-//     }
-//   }
-//   .input-group-addon {
-//     input {
-//       vertical-align: middle;
-//     }
-//   }
-// }
+$form-check-gutter: .5rem;
+$form-check-indicator-border-width: 1px;
+
+.form-check .form-check-label::before {
+  border-radius: $border-radius !important;
+}
+
+label.form-check-label {
+  font-weight: normal;
+}
+
+.form-switch.form-switch-sm {
+  $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
+  $form-switch-width-sm: $form-check-indicator-size-sm * 1.75;
+  $form-check-gutter-sm: $form-check-gutter * 0.8;
+  $form-check-indicator-size-sm: $form-check-indicator-size * 0.8;
+  $form-switch-indicator-size-sm: subtract($form-check-indicator-size-sm, $form-check-indicator-border-width * 4);
+
+  padding-left: $form-switch-width-sm + $form-check-gutter-sm;
+
+  .form-check-label {
+    &::before {
+      left: -($form-switch-width-sm + $form-check-gutter-sm);
+      width: $form-switch-width-sm;
+      height: $form-check-indicator-size-sm;
+    }
+
+    &::after {
+      top: add(($font-size-base * $line-height-base - $form-check-indicator-size) / 2, $form-check-indicator-border-width * 2);
+      left: add(-($form-switch-width-sm + $form-check-gutter-sm), $form-check-indicator-border-width * 2);
+      width: $form-switch-indicator-size-sm;
+      height: $form-switch-indicator-size-sm;
+    }
+  }
+
+  .form-check-input:checked ~ .form-check-label {
+    &::after {
+      transform: translateX($form-switch-width-sm - $form-check-indicator-size-sm);
+    }
+  }
+}
+
+//lg
+.form-switch.form-switch-lg {
+  $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
+  $form-switch-width-lg: $form-check-indicator-size-lg * 1.75;
+  $form-check-gutter-lg: $form-check-gutter * 1.5;
+  $form-check-indicator-size-lg: $form-check-indicator-size * 1.5;
+  $form-switch-indicator-size-lg: subtract($form-check-indicator-size-lg, $form-check-indicator-border-width * 4);
+
+  padding-left: $form-switch-width-lg + $form-check-gutter-lg;
+
+  line-height: $form-check-indicator-size-lg;
+  .form-check-label {
+    &::before {
+      top: ($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2;
+
+      left: -($form-switch-width-lg + $form-check-gutter-lg);
+      width: $form-switch-width-lg;
+      height: $form-check-indicator-size-lg;
+      border-radius: $form-check-indicator-size-lg/2;
+    }
+
+    &::after {
+      top: add(($font-size-base * $line-height-base - $form-check-indicator-size-lg) / 2, $form-check-indicator-border-width * 2);
+      left: add(-($form-switch-width-lg + $form-check-gutter-lg), $form-check-indicator-border-width * 2);
+      width: $form-switch-indicator-size-lg;
+      height: $form-switch-indicator-size-lg;
+      border-radius: $form-check-indicator-size-lg/2;
+    }
+  }
+
+  .form-check-input:checked ~ .form-check-label {
+    &::after {
+      transform: translateX($form-switch-width-lg - $form-check-indicator-size-lg);
+    }
+  }
+}
+
+.form-switch.form-switch-slack {
+  .form-check-label {
+    &::before {
+      background-color: $gray-200;
+      border-color: transparent;
+    }
+    &::after {
+      background-size: 15px;
+    }
+  }
+  .input-group-addon {
+    input {
+      vertical-align: middle;
+    }
+  }
+}

+ 1 - 1
apps/app/src/styles/style-app.scss

@@ -1,4 +1,5 @@
 @import '@growi/core/scss/flex-expand';
+@import '@growi/core/scss/rotate';
 
 @import 'mixins';
 
@@ -19,7 +20,6 @@
 @import 'organisms/wiki';
 
 // // growi component
-// @import 'draft';
 @import 'editor';
 @import 'fonts';
 @import 'layout';

+ 16 - 0
packages/core/scss/_rotate.scss

@@ -0,0 +1,16 @@
+// refs: https://fastbootstrap.com/docs/rotate/
+$rotate-degrees: [0, 1, 2, 3, 6, 12, 45, 90, 180];
+
+.rotate-none {
+  transform: none;
+}
+
+@each $degree in $rotate-degrees {
+  .rotate-#{$degree} {
+    transform: rotate(#{$degree}deg);
+  }
+  // negative rotation
+  .-rotate-#{$degree} {
+    transform: rotate(-#{$degree}deg);
+  }
+}

+ 1 - 1
packages/core/scss/bootstrap/_variables.scss

@@ -164,4 +164,4 @@ $pre-color: dummyinvalildcolor; // disable pre color specification with invalid
 //== Custom Checkbox
 // $form-check-indicator-border-radius: 0px;
 // $form-check-indicator-focus-box-shadow: none;
-// $form-check-indicator-size: 1.2rem;
+$form-check-indicator-size: 1.2rem;

+ 0 - 8
packages/core/src/interfaces/revision.ts

@@ -31,14 +31,6 @@ export type IRevisionsForPagination = {
   revisions: IRevisionHasPageId[], // revisions in one pagination
   totalCounts: number // total counts
 }
-
-export type IRevisionOnConflict = {
-  revisionId: string,
-  revisionBody: string,
-  createdAt: Date,
-  user: IUser
-}
-
 export type HasRevisionShortbody = {
   revisionShortBody?: string,
 }

+ 2 - 0
packages/editor/src/@types/y-codemirror.next.d.ts

@@ -0,0 +1,2 @@
+// https://github.com/yjs/y-codemirror.next/issues/27
+declare module 'y-codemirror.next';

+ 10 - 6
packages/editor/src/stores/use-collaborative-editor-mode.ts

@@ -1,11 +1,9 @@
 import { useEffect, useState } from 'react';
 
+import { keymap } from '@codemirror/view';
 import { GlobalSocketEventName, type IUserHasId } from '@growi/core/dist/interfaces';
 import { useGlobalSocket, GLOBAL_SOCKET_NS } from '@growi/core/dist/swr';
-// see: https://github.com/yjs/y-codemirror.next#example
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore
-import { yCollab } from 'y-codemirror.next';
+import { yCollab, yUndoManagerKeymap } from 'y-codemirror.next';
 import { SocketIOProvider } from 'y-socket.io';
 import * as Y from 'yjs';
 
@@ -118,11 +116,17 @@ export const useCollaborativeEditorMode = (
 
     codeMirrorEditor.initDoc(ytext.toString());
 
-    const cleanup = codeMirrorEditor.appendExtensions([
+    const cleanupYUndoManagerKeymap = codeMirrorEditor.appendExtensions([
+      keymap.of(yUndoManagerKeymap),
+    ]);
+    const cleanupYCollab = codeMirrorEditor.appendExtensions([
       yCollab(ytext, provider.awareness, { undoManager }),
     ]);
 
-    return cleanup;
+    return () => {
+      cleanupYUndoManagerKeymap?.();
+      cleanupYCollab?.();
+    };
   };
 
   useEffect(cleanupYDoc, [cPageId, onEditorsUpdated, pageId, provider, socket, ydoc]);

+ 0 - 5
packages/editor/src/stores/use-default-extensions.ts

@@ -9,10 +9,6 @@ import {
 } from '@codemirror/state';
 import { keymap, EditorView, KeyBinding } from '@codemirror/view';
 import { tags } from '@lezer/highlight';
-// see: https://github.com/yjs/y-codemirror.next#example
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-ignore
-import { yUndoManagerKeymap } from 'y-codemirror.next';
 
 import type { UseCodeMirrorEditor } from '../services';
 import { emojiAutocompletionSettings } from '../services/extensions/emojiAutocompletionSettings';
@@ -39,7 +35,6 @@ const defaultExtensions: Extension[] = [
   keymap.of(markdownKeymap),
   keymap.of([indentWithTab]),
   Prec.lowest(keymap.of(defaultKeymap)),
-  keymap.of(yUndoManagerKeymap),
   syntaxHighlighting(markdownHighlighting),
   Prec.lowest(syntaxHighlighting(defaultHighlightStyle)),
   emojiAutocompletionSettings,