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

Merge branch 'dev/7.0.x' into feat/page-bulk-export

Futa Arai 2 лет назад
Родитель
Сommit
dea6f5a353
100 измененных файлов с 1224 добавлено и 368 удалено
  1. 1 1
      .github/workflows/ci-app-prod.yml
  2. 1 1
      .mergify.yml
  3. 2 2
      apps/app/_obsolete/src/components/PageEditor/Editor.tsx
  4. 1 1
      apps/app/package.json
  5. 7 2
      apps/app/public/static/locales/en_US/translation.json
  6. 7 2
      apps/app/public/static/locales/ja_JP/translation.json
  7. 7 2
      apps/app/public/static/locales/zh_CN/translation.json
  8. 3 1
      apps/app/src/client/services/create-page/use-create-template-page.ts
  9. 2 0
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  10. 3 0
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  11. 18 0
      apps/app/src/client/services/use-toastr-on-error.tsx
  12. 2 2
      apps/app/src/components/Admin/AdminHome/AdminHome.jsx
  13. 1 1
      apps/app/src/components/Admin/App/AppSettingsPageContents.tsx
  14. 2 2
      apps/app/src/components/Admin/App/MaskedInput.tsx
  15. 4 3
      apps/app/src/components/Admin/AuditLog/ActivityTable.tsx
  16. 3 2
      apps/app/src/components/Admin/AuditLog/AuditLogSettings.tsx
  17. 3 2
      apps/app/src/components/Admin/AuditLog/DateRangePicker.tsx
  18. 5 3
      apps/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx
  19. 1 1
      apps/app/src/components/Admin/Customize/CustomizeNoscriptSetting.tsx
  20. 1 1
      apps/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx
  21. 21 10
      apps/app/src/components/Admin/Customize/ThemeColorBox.tsx
  22. 2 2
      apps/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  23. 2 2
      apps/app/src/components/Admin/G2GDataTransferExportForm.tsx
  24. 4 4
      apps/app/src/components/Admin/G2GDataTransferStatusIcon.tsx
  25. 2 2
      apps/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  26. 7 2
      apps/app/src/components/Admin/Notification/NotificationTypeIcon.tsx
  27. 1 1
      apps/app/src/components/Admin/Notification/UserTriggerNotification.jsx
  28. 2 2
      apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx
  29. 4 4
      apps/app/src/components/Admin/Security/SecurityManagementContents.jsx
  30. 1 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  31. 4 4
      apps/app/src/components/Admin/SlackIntegration/Bridge.jsx
  32. 3 3
      apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  33. 2 2
      apps/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  34. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  35. 8 4
      apps/app/src/components/Admin/Users/SortIcons.tsx
  36. 2 2
      apps/app/src/components/Admin/Users/UserMenu.module.scss
  37. 5 2
      apps/app/src/components/Admin/Users/UserMenu.tsx
  38. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  39. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  40. 1 1
      apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  41. 3 2
      apps/app/src/components/Common/CustomCopyToClipBoard.tsx
  42. 1 1
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  43. 4 0
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.module.scss
  44. 2 3
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  45. 2 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  46. 2 2
      apps/app/src/components/Me/AssociateModal.tsx
  47. 4 0
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.module.scss
  48. 3 1
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  49. 3 1
      apps/app/src/components/PageComment/CommentEditor.tsx
  50. 2 3
      apps/app/src/components/PageControls/BookmarkButtons.tsx
  51. 4 2
      apps/app/src/components/PageControls/LikeButtons.tsx
  52. 2 1
      apps/app/src/components/PageControls/SeenUserInfo.tsx
  53. 1 1
      apps/app/src/components/PageControls/_button-styles.scss
  54. 3 10
      apps/app/src/components/PageControls/user-list-popover.module.scss
  55. 59 90
      apps/app/src/components/PageCreateModal.tsx
  56. 5 0
      apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.module.scss
  57. 57 0
      apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx
  58. 3 0
      apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.module.scss
  59. 22 0
      apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  60. 1 0
      apps/app/src/components/PageEditor/EditorNavbar/index.ts
  61. 1 1
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  62. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  63. 2 2
      apps/app/src/components/PageEditor/LinkEditModal.tsx
  64. 19 15
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  65. 15 15
      apps/app/src/components/PageEditor/PageEditor.tsx
  66. 6 4
      apps/app/src/components/PageHeader/PageHeader.tsx
  67. 1 1
      apps/app/src/components/PageHeader/PagePathHeader.module.scss
  68. 1 0
      apps/app/src/components/PageHeader/index.ts
  69. 1 1
      apps/app/src/components/PageManagement/ApiErrorMessage.jsx
  70. 1 1
      apps/app/src/components/PageRenameModal.tsx
  71. 9 5
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  72. 2 1
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  73. 2 17
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  74. 8 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  75. 2 1
      apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  76. 3 2
      apps/app/src/components/SlackNotification.tsx
  77. 1 1
      apps/app/src/components/SystemVersion.tsx
  78. 7 3
      apps/app/src/components/TableOfContents.tsx
  79. 4 1
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  80. 1 1
      apps/app/src/components/TreeItem/SimpleItem.tsx
  81. 1 1
      apps/app/src/components/UsersHomepageFooter.tsx
  82. 2 2
      apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx
  83. 5 2
      apps/app/src/interfaces/apiv3/page.ts
  84. 0 11
      apps/app/src/interfaces/editor-settings.ts
  85. 3 1
      apps/app/src/interfaces/page.ts
  86. 2 1
      apps/app/src/pages/trash.page.tsx
  87. 4 4
      apps/app/src/server/models/editor-settings.ts
  88. 9 2
      apps/app/src/server/models/obsolete-page.js
  89. 5 1
      apps/app/src/server/models/revision.js
  90. 4 2
      apps/app/src/server/routes/apiv3/page/create-page.ts
  91. 12 6
      apps/app/src/server/routes/apiv3/page/update-page.ts
  92. 5 5
      apps/app/src/server/service/page/index.ts
  93. 4 4
      apps/app/src/stores/editor.tsx
  94. 33 0
      apps/app/src/stores/use-editing-users.ts
  95. 2 0
      bin/data-migrations/README.md
  96. 682 0
      bin/data-migrations/src/migrations/v70x/bootstrap5.js
  97. 3 0
      bin/data-migrations/src/migrations/v70x/index.js
  98. 7 4
      packages/core/src/interfaces/growi-theme-metadata.ts
  99. 10 0
      packages/core/src/interfaces/revision.ts
  100. 13 49
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

+ 1 - 1
.github/workflows/ci-app-prod.yml

@@ -48,7 +48,7 @@ concurrency:
 
 jobs:
 
-  test-prod-node16:
+  test-prod-node18:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@dev/7.0.x
     with:
       node-version: 18.x

+ 1 - 1
.mergify.yml

@@ -6,8 +6,8 @@ pull_request_rules:
       - check-success = "lint (20.x)"
       - check-success = "test (20.x)"
       - check-success = "launch-dev (20.x)"
-      - check-success = "test-prod-node16 / launch-prod"
       - check-success = "test-prod-node18 / launch-prod"
+      - check-success = "test-prod-node20 / launch-prod"
     actions:
       merge:
         method: merge

+ 2 - 2
apps/app/_obsolete/src/components/PageEditor/Editor.tsx

@@ -5,6 +5,7 @@ import React, {
   useEffect,
 } from 'react';
 
+import type { EditorSettings } from '@growi/editor';
 import Dropzone from 'react-dropzone';
 import { useTranslation } from 'react-i18next';
 import {
@@ -12,7 +13,6 @@ import {
 } from 'reactstrap';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IEditorSettings } from '~/interfaces/editor-settings';
 import { useDefaultIndentSize } from '~/stores/context';
 import { useEditorSettings } from '~/stores/editor';
 import { useIsMobile } from '~/stores/ui';
@@ -36,7 +36,7 @@ export type EditorPropsType = {
   isUploadAllFileAllowed?: boolean,
   onChange?: (newValue: string, isClean?: boolean) => void,
   onUpload?: (file) => void,
-  editorSettings?: IEditorSettings,
+  editorSettings?: EditorSettings,
   indentSize?: number,
   onDragEnter?: (event: any) => void,
   onMarkdownHelpButtonClicked?: () => void,

+ 1 - 1
apps/app/package.json

@@ -269,7 +269,7 @@
     "pretty-bytes": "^6.1.1",
     "react-codemirror2": "^6.0.0",
     "react-copy-to-clipboard": "^5.0.1",
-    "react-dropzone": "^11.2.4",
+    "react-dropzone": "^14.2.3",
     "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
     "rehype-rewrite": "^3.0.6",

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

@@ -311,6 +311,11 @@
     }
   },
   "page_edit": {
+    "input_channels": "Input channels",
+    "theme": "Theme",
+    "keymap": "Keymap",
+    "indent": "Indent",
+    "editor_config": "Editor Config",
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
@@ -833,10 +838,10 @@
     "select_page_location": "Select page location"
   },
   "wip_page": {
-    "save_as_wip": "Save as WIP (Currently drafting)",
+    "save_as_wip": "Save as WIP (still being written)",
     "success_save_as_wip": "Successfully saved as a WIP page",
     "fail_save_as_wip": "Failed to save as a WIP page",
-    "alert": "This page is a work in progress",
+    "alert": "This page is still being written",
     "publish_page": "Publish page",
     "success_publish_page": "Page has been published",
     "fail_publish_page": "Failed to publish the Page"

+ 7 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -344,6 +344,11 @@
     }
   },
   "page_edit": {
+    "input_channels": "チャンネル名",
+    "theme": "テーマ",
+    "keymap": "キーマップ",
+    "indent": "インデント",
+    "editor_config": "エディタ設定",
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
@@ -866,10 +871,10 @@
     "select_page_location": "ページの場所を選択"
   },
   "wip_page": {
-    "save_as_wip": "WIP (執筆中) として保存",
+    "save_as_wip": "WIP (執筆中) として保存",
     "success_save_as_wip": "WIP ページとして保存しました",
     "fail_save_as_wip": "WIP ページとして保存できませんでした",
-    "alert": "このページは作業途中です",
+    "alert": "このページは執筆途中です",
     "publish_page": "WIP を解除",
     "success_publish_page": "WIP を解除しました",
     "fail_publish_page": "WIP を解除できませんでした"

+ 7 - 2
apps/app/public/static/locales/zh_CN/translation.json

@@ -301,6 +301,11 @@
 		}
 	},
 	"page_edit": {
+    "input_channels": "频道名",
+    "theme": "主题",
+    "keymap": "键表",
+    "indent": "缩进",
+    "editor_config": "编辑器配置",
 		"Show active line": "显示活动行",
 		"auto_format_table": "自动格式化表格",
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
@@ -836,10 +841,10 @@
     "select_page_location": "选择页面位置"
   },
   "wip_page": {
-    "save_as_wip": "保存为 WIP(书面)",
+    "save_as_wip": "保存为 WIP(仍在撰写中)",
     "success_save_as_wip": "成功保存为 WIP 页面",
     "fail_save_as_wip": "保存为 WIP 页失败",
-    "alert": "本页面正在制作中",
+    "alert": "本页仍在编写中",
     "publish_page": "发布 WIP",
     "success_publish_page": "WIP 已停用",
     "fail_publish_page": "无法停用 WIP"

+ 3 - 1
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -1,11 +1,13 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 
 import type { LabelType } from '~/interfaces/template';
 import { useCurrentPagePath } from '~/stores/page';
 
+
 import { useCreatePageAndTransit } from './use-create-page-and-transit';
 
 type UseCreateTemplatePage = () => {
@@ -25,7 +27,7 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
     if (isLoadingPagePath || !isCreatable) return;
 
     return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`), wip: false },
+      { path: normalizePath(`${currentPagePath}/${label}`), wip: false, origin: Origin.View },
       { shouldCheckPageExists: true },
     );
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);

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

@@ -2,6 +2,7 @@ import { useCallback, useEffect } from 'react';
 
 import type EventEmitter from 'events';
 
+import { Origin } from '@growi/core';
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
@@ -47,6 +48,7 @@ export const useDrawioModalLauncherForView = (opts?: {
         pageId: currentPage._id,
         revisionId: currentRevisionId,
         body: newMarkdown,
+        origin: Origin.View,
       });
 
       opts?.onSaveSuccess?.();

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

@@ -2,6 +2,8 @@ import { useCallback, useEffect } from 'react';
 
 import type EventEmitter from 'events';
 
+import { Origin } from '@growi/core';
+
 import type MarkdownTable from '~/client/models/MarkdownTable';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
 import { useShareLinkId } from '~/stores/context';
@@ -46,6 +48,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
         pageId: currentPage._id,
         revisionId: currentRevisionId,
         body: newMarkdown,
+        origin: Origin.View,
       });
 
       opts?.onSaveSuccess?.();

+ 18 - 0
apps/app/src/client/services/use-toastr-on-error.tsx

@@ -0,0 +1,18 @@
+import { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastError } from '~/client/util/toastr';
+
+export const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
+  const { t } = useTranslation('commons');
+
+  return useCallback(async(param) => {
+    try {
+      return await method?.(param);
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: 'a page' }));
+    }
+  }, [method, t]);
+};

+ 2 - 2
apps/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -52,7 +52,7 @@ const AdminHome = (props) => {
             </p>
             <hr />
             <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-              <i className="fa fa-link ms-1" aria-hidden="true"></i>
+              <span className="material-symbols-outlined ms-1" aria-hidden="true">link</span>
               <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
             </a>
           </div>
@@ -65,7 +65,7 @@ const AdminHome = (props) => {
           <div className={`alert ${migrationStatus.isV5Compatible == null ? 'alert-warning' : 'alert-info'}`}>
             {t('admin:v5_page_migration.migration_desc')}
             <a className="btn-link" href="/admin/app" rel="noopener noreferrer">
-              <i className="fa fa-link ms-1" aria-hidden="true"></i>
+              <span className="material-symbols-outlined ms-1" aria-hidden="true">link</span>
               <strong>{t('admin:v5_page_migration.upgrade_to_v5')}</strong>
             </a>
           </div>

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

@@ -62,7 +62,7 @@ const AppSettingsPageContents = (props: Props) => {
             </p>
             <hr />
             <a className="btn-link" href="#maintenance-mode" rel="noopener noreferrer">
-              <i className="fa fa-fw fa-arrow-down ms-1" aria-hidden="true"></i>
+              <span className="material-symbols-outlined ms-1" aria-hidden="true">expand_more</span>
               <strong>{t('admin:maintenance_mode.end_maintenance_mode')}</strong>
             </a>
           </div>

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

@@ -33,9 +33,9 @@ export default function MaskedInput(props: Props): JSX.Element {
       />
       <span onClick={togglePassword} className={styles.PasswordReveal}>
         {passwordShown ? (
-          <i className="fa fa-eye" />
+          <span className="material-symbols-outlined">visibility</span>
         ) : (
-          <i className="fa fa-eye-slash" />
+          <span className="material-symbols-outlined">visibility_off</span>
         )}
       </span>
     </div>

+ 4 - 3
apps/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
@@ -7,7 +8,7 @@ import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
 import { Tooltip } from 'reactstrap';
 
-import { IActivityHasId } from '~/interfaces/activity';
+import type { IActivityHasId } from '~/interfaces/activity';
 
 type Props = {
   activityList: IActivityHasId[]
@@ -64,7 +65,7 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                   {activity.endpoint}
                   <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
                     <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
-                      <i className="fa fa-clipboard" aria-hidden="true"></i>
+                      <span className="material-symbols-outlined" aria-hidden="true">content_paste</span>
                     </button>
                   </CopyToClipboard>
                   <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">

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

@@ -1,4 +1,5 @@
-import React, { FC, useState } from 'react';
+import type { FC } from 'react';
+import React, { useState } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
@@ -54,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)}>
-          <i className={`fa fa-fw fa-arrow-right ${isExpandActionList ? 'fa-rotate-90' : ''}`}></i>
+          <span className={`material-symbols-outlined me-1 ${isExpandActionList ? 'fa-rotate-90' : ''}`}>navigate_next</span>
           { t('admin:audit_log_management.action_list') }
         </button>
       </p>

+ 3 - 2
apps/app/src/components/Admin/AuditLog/DateRangePicker.tsx

@@ -1,4 +1,5 @@
-import React, { FC, forwardRef, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { forwardRef, useCallback } from 'react';
 
 import { addDays, format } from 'date-fns';
 import DatePicker from 'react-datepicker';
@@ -19,7 +20,7 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>((props: Custo
   return (
     <div className="input-group admin-audit-log">
       <span className="input-group-text">
-        <i className="fa fa-fw fa-calendar" />
+        <span className="material-symbols-outlined me-1">calendar_month</span>
       </span>
       <input
         ref={ref}

+ 5 - 3
apps/app/src/components/Admin/AuditLog/SelectActionDropdown.tsx

@@ -1,9 +1,11 @@
-import React, { FC, useMemo, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useMemo, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 
+import type { SupportedActionType, SupportedActionCategoryType } from '~/interfaces/activity';
 import {
-  SupportedActionType, SupportedActionCategoryType, SupportedActionCategory,
+  SupportedActionCategory,
   PageActions, CommentActions, TagActions, ShareLinkActions, AttachmentActions, InAppNotificationActions, SearchActions, UserActions, AdminActions,
 } from '~/interfaces/activity';
 
@@ -78,7 +80,7 @@ export const SelectActionDropdown: FC<Props> = (props: Props) => {
   return (
     <div className="btn-group me-2 admin-audit-log">
       <button className="btn btn-outline-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown">
-        <i className="fa fa-fw fa-bolt" />{t('admin:audit_log_management.action')}
+        <span className="material-symbols-outlined me-1">bolt</span>{t('admin:audit_log_management.action')}
       </button>
       <ul className="dropdown-menu select-action-dropdown" aria-labelledby="dropdownMenuButton">
         {dropdownItems.map(item => (

+ 1 - 1
apps/app/src/components/Admin/Customize/CustomizeNoscriptSetting.tsx

@@ -69,7 +69,7 @@ const CustomizeNoscriptSetting = (props: Props): JSX.Element => {
             aria-expanded="false"
             aria-controls="collapseExampleHtml"
           >
-            <i className="fa fa-fw fa-chevron-right" aria-hidden="true"></i>
+            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
             Example for Google Tag Manager
           </a>
           <div className="collapse" id="collapseExampleHtml">

+ 1 - 1
apps/app/src/components/Admin/Customize/CustomizeScriptSetting.tsx

@@ -66,7 +66,7 @@ const CustomizeScriptSetting = (props: Props): JSX.Element => {
             aria-expanded="false"
             aria-controls="collapseExampleScript"
           >
-            <i className="fa fa-fw fa-chevron-right" aria-hidden="true"></i>
+            <span className="material-symbols-outlined me-1" aria-hidden="true">navigate_next</span>
             Example for Google Tag Manager
           </a>
           <div className="collapse" id="collapseExampleScript">

+ 21 - 10
apps/app/src/components/Admin/Customize/ThemeColorBox.tsx

@@ -15,26 +15,37 @@ export const ThemeColorBox = (props: Props): JSX.Element => {
     isSelected, metadata, onSelected,
   } = props;
   const {
-    name, bg, topbar, sidebar, accent, isPresetTheme,
+    name, lightBg, darkBg, lightSidebar, darkSidebar, lightIcon, darkIcon, createBtn, isPresetTheme,
   } = metadata;
 
   return (
+    // TODO: Display a primary color border when icon is selected
     <div
       id={`theme-option-${name}`}
       className={`theme-option-container d-flex flex-column align-items-center ${isSelected ? 'active' : ''}`}
       onClick={onSelected}
     >
-      <a id={name} role="button" className={`m-0 ${name} theme-button`}>
-        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
-          <g>
-            <path d="M -1 -1 L65 -1 L65 65 L-1 65 L-1 -1 Z" fill={bg}></path>
-            <path d="M -1 -1 L65 -1 L65 15 L-1 15 L-1 -1 Z" fill={topbar}></path>
-            <path d="M -1 15 L15 15 L15 65 L-1 65 L-1 15 Z" fill={sidebar}></path>
-            <path d="M 65 45 L65 65 L45 65 L65 45 Z" fill={accent}></path>
-          </g>
+      <a id={name} role="button" className={`m-0 rounded ${name} theme-button`}>
+        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" className="rounded">
+          <path d="M32.5,0V36.364L64,20.437V0Z" fill={lightBg} />
+          <path d="M32.5,36.364V64H64V20.438Z" fill={darkBg} />
+          <path
+            d="M4.077,20.648,10.164,10.1H22.338l6.087,10.544L22.338,31.19H10.164ZM0,0V52.8l6.436-3.255v-1.8H10L17.189,44.1H6.436V42.044H21.267L32.5,36.364V0Z"
+            fill={lightSidebar}
+          />
+          <path
+            d="M6.436,53.44H26.065V55.5H6.436Zm14.831-11.4h4.8v2.061H17.189L10,47.743H26.065V49.8l-19.629,0v-.259L0,52.8V64H32.5V36.364Z"
+            fill={darkSidebar}
+          />
+          <path d="M22.338,31.19l6.087-10.543L22.338,10.1H10.163L4.077,20.647,10.163,31.19Z" fill={createBtn} />
+          <path d="M6.436,49.543,10,47.742H6.436Z" fill={lightIcon} />
+          <path d="M6.436,44.106H17.189l4.078-2.062H6.436Z" fill={lightIcon} />
+          <path d="M6.436,49.8l19.629,0V47.742H10l-3.561,1.8Z" fill={darkIcon} />
+          <path d="M26.065,44.106V42.044h-4.8L17.19,44.106Z" fill={darkIcon} />
+          <rect width="19.629" height="2.062" transform="translate(6.436 53.439)" fill={darkIcon} />
         </svg>
       </a>
-      <span className="theme-option-name"><b>{ name }</b></span>
+      <span className="theme-option-name mt-2"><b>{ name }</b></span>
       { !isPresetTheme && <span className="theme-option-badge badge bg-primary mt-1">Plugin</span> }
     </div>
   );

+ 2 - 2
apps/app/src/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -166,10 +166,10 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
           <div className="row">
             <div className="col-sm-12">
               <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={checkAll}>
-                <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+                <span className="material-symbols-outlined">check_box</span> {t('admin:export_management.check_all')}
               </button>
               <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={uncheckAll}>
-                <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+                <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
               </button>
             </div>
           </div>

+ 2 - 2
apps/app/src/components/Admin/G2GDataTransferExportForm.tsx

@@ -202,12 +202,12 @@ const G2GDataTransferExportForm = (props: Props): JSX.Element => {
       <form className="mt-3 row row-cols-lg-auto g-3 align-items-center">
         <div className="col-12">
           <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={checkAll}>
-            <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+            <span className="material-symbols-outlined">check_box</span>, {t('admin:export_management.check_all')}
           </button>
         </div>
         <div className="col-12">
           <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={uncheckAll}>
-            <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+            <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
           </button>
         </div>
       </form>

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

@@ -21,23 +21,23 @@ const G2GDataTransferStatusIcon = ({ status, className, ...props }: Props): JSX.
 
   if (status === G2G_PROGRESS_STATUS.COMPLETED) {
     return (
-      <i className={`fa fa-check-circle-o fa-fw text-info ${className}`} aria-label="completed" {...props} />
+      <span className={`material-symbols-outlined text-info ${className}`} aria-label="completed" {...props}>check_circle</span>
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.ERROR) {
     return (
-      <i className={`fa fa-exclamation-circle fa-fw text-danger ${className}`} aria-label="error" {...props} />
+      <span className={`material-symbols-outlined text-danger ${className}`} aria-label="error" {...props}>error</span>
     );
   }
 
   if (status === G2G_PROGRESS_STATUS.SKIPPED) {
     return (
-      <i className={`fa fa-ban fa-fw ${className}`} aria-label="skipped" {...props} />
+      <span className={`material-symbols-outlined ${className}`} aria-label="skipped" {...props}>block</span>
     );
   }
 
-  return <i className={`fa fa-circle-o fa-fw ${className}`} aria-label="pending" {...props} />;
+  return <span className={`material-symbols-outlined ${className}`} aria-label="pending" {...props}>circle</span>;
 };
 
 export default G2GDataTransferStatusIcon;

+ 2 - 2
apps/app/src/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -449,12 +449,12 @@ class ImportForm extends React.Component {
         <form className="row row-cols-lg-auto g-3 align-items-center">
           <div className="col-12">
             <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={this.checkAll}>
-              <i className="fa fa-check-square-o"></i> {t('admin:export_management.check_all')}
+              <span className="material-symbols-outlined">check_box</span> {t('admin:export_management.check_all')}
             </button>
           </div>
           <div className="col-12">
             <button type="button" className="btn btn-sm btn-outline-secondary me-2" onClick={this.uncheckAll}>
-              <i className="fa fa-square-o"></i> {t('admin:export_management.uncheck_all')}
+              <span className="material-symbols-outlined">check_box_outline_blank</span> {t('admin:export_management.uncheck_all')}
             </button>
           </div>
         </form>

+ 7 - 2
apps/app/src/components/Admin/Notification/NotificationTypeIcon.tsx

@@ -23,8 +23,13 @@ export const NotificationTypeIcon = (props: NotificationTypeIconProps): JSX.Elem
   }
 
   const elemId = `notification-${type}-${_id}`;
-  const className = type === 'mail' ? 'icon-fw fa fa-envelope-o' : 'icon-fw fa fa-hashtag';
+  const iconName = type === 'mail' ? 'mail' : 'tag';
   const toolChip = type === 'mail' ? 'Mail' : 'Slack';
 
-  return <><i id={elemId} className={className}></i><UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip></>;
+  return (
+    <>
+      <span id={elemId} className="material-symbols-outlined me-1">{iconName}</span>
+      <UncontrolledTooltip target={elemId}>{toolChip}</UncontrolledTooltip>
+    </>
+  );
 };

+ 1 - 1
apps/app/src/components/Admin/Notification/UserTriggerNotification.jsx

@@ -112,7 +112,7 @@ class UserTriggerNotification extends React.Component {
               <td>
                 <div className="input-group notify-to-option" id="slack-input">
                   <div>
-                    <span className="input-group-text"><i className="fa fa-hashtag" /></span>
+                    <span className="input-group-text"><span className="material-symbols-outlined">tag</span></span>
                   </div>
                   <input
                     className="form-control"

+ 2 - 2
apps/app/src/components/Admin/Security/LocalSecuritySettingContents.jsx

@@ -181,7 +181,7 @@ class LocalSecuritySettingContents extends React.Component {
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.password_reset_please_enable_mailer')}</span>
                     <Link href="/admin/app#mail-settings">
-                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                      <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
                     </Link>
                   </div>
                 )}
@@ -210,7 +210,7 @@ class LocalSecuritySettingContents extends React.Component {
                   <div className="alert alert-warning p-1 my-1 small d-inline-block">
                     <span>{t('commons:alert.please_enable_mailer')}</span>
                     <Link href="/admin/app#mail-settings">
-                      <i className="fa fa-link"></i> {t('app_setting.mail_settings')}
+                      <span className="material-symbols-outlined">link</span> {t('app_setting.mail_settings')}
                     </Link>
                   </div>
                 )}

+ 4 - 4
apps/app/src/components/Admin/Security/SecurityManagementContents.jsx

@@ -30,19 +30,19 @@ const SecurityManagementContents = () => {
   const navTabMapping = useMemo(() => {
     return {
       passport_local: {
-        Icon: () => <i className="fa fa-users" />,
+        Icon: () => <span className="material-symbols-outlined">groups</span>,
         i18n: 'ID/Pass',
       },
       passport_ldap: {
-        Icon: () => <i className="fa fa-sitemap" />,
+        Icon: () => <span className="material-symbols-outlined">network_node</span>,
         i18n: 'LDAP',
       },
       passport_saml: {
-        Icon: () => <i className="fa fa-key" />,
+        Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'SAML',
       },
       passport_oidc: {
-        Icon: () => <i className="fa fa-key" />,
+        Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'OIDC',
       },
       passport_google: {

+ 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)}
                   >
-                    <i className={`fa fa-fw fa-arrow-right ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}></i>
+                    <span className={`material-symbols-outlined me-1 ${expantDeleteOptionsState ? 'fa-rotate-90' : ''}`}>navigate_next</span>
                     { t('security_settings.other_options') }
                   </button>
                   <Collapse isOpen={expantDeleteOptionsState}>

+ 4 - 4
apps/app/src/components/Admin/SlackIntegration/Bridge.jsx

@@ -72,15 +72,15 @@ const Bridge = (props) => {
   // all green
   else if (errorCount === 0) {
     description = t('admin:slack_integration.integration_sentence.integration_successful');
-    iconClass = 'fa fa-check text-success';
-    iconName = '';
+    iconClass = 'material-symbols-outlined text-success';
+    iconName = 'check';
     hrClass = 'border-success admin-border-success';
   }
   // some of them failed
   else {
     description = t('admin:slack_integration.integration_sentence.integration_some_ws_is_not_complete');
-    iconClass = 'fa fa-check text-warning';
-    iconName = '';
+    iconClass = 'material-symbols-outlined text-warning';
+    iconName = 'check';
     hrClass = 'border-warning admin-border-failed';
   }
 

+ 3 - 3
apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx

@@ -115,7 +115,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.REGISTER_SLACK_CONFIGURATION)}
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">3</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isEnterdSecretAndToken && <i className="ms-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="me-3">3</span>{t('admin:slack_integration.accordion.register_secret_and_token')}{isEnterdSecretAndToken && <span className="material-symbols-outlined ms-3 text-success">check</span>}</>}
       >
         <CustomBotWithoutProxySecretTokenSection
           onUpdatedSecretToken={props.onUpdatedSecretToken}
@@ -138,7 +138,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       <Accordion
         defaultIsActive={defaultOpenAccordionKeys.has(botInstallationStep.CONNECTION_TEST)}
         // eslint-disable-next-line max-len
-        title={<><span className="me-3">5</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <i className="ms-3 text-success fa fa-check"></i>}</>}
+        title={<><span className="me-3">5</span>{t('admin:slack_integration.accordion.test_connection')}{isLatestConnectionSuccess && <span className="material-symbols-outlined ms-3 text-success">check</span>}</>}
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <p className="text-center text-warning">
@@ -148,7 +148,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
           <form className="align-items-center" onSubmit={e => submitForm(e)}>
             <div className="input-group col-8">
               <div>
-                <span className="input-group-text" id="slack-channel-addon"><i className="fa fa-hashtag" /></span>
+                <span className="input-group-text" id="slack-channel-addon"><span className="material-symbols-outlined">tag</span></span>
               </div>
               <input
                 className="form-control"

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

@@ -246,7 +246,7 @@ const TestProcess = ({
         <form className="justify-content-center" onSubmit={e => submitForm(e)}>
           <div className="input-group col-8">
             <div>
-              <span className="input-group-text" id="slack-channel-addon"><i className="fa fa-hashtag" /></span>
+              <span className="input-group-text" id="slack-channel-addon"><span className="material-symbols-outlined">tag</span></span>
             </div>
             <input
               className="form-control"
@@ -391,7 +391,7 @@ const WithProxyAccordions = (props) => {
               <>
                 <span className="me-3">{key}</span>
                 {t(`admin:slack_integration.accordion.${value.title}`)}
-                {value.title === 'test_connection' && isLatestConnectionSuccess && <i className="ms-3 text-success fa fa-check"></i>}
+                {value.title === 'test_connection' && isLatestConnectionSuccess && <span className="material-symbols-outlined ms-3 text-success">check</span>}
               </>
             )}
             key={key}

+ 1 - 1
apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx

@@ -214,7 +214,7 @@ export const UserGroupTable: FC<Props> = ({
                           {onRemove != null
                           && (
                             <button className="dropdown-item" type="button" role="button" onClick={onClickRemove} data-user-group-id={group._id}>
-                              <i className="icon-fw fa fa-chain-broken"></i> {t('admin:user_group_management.remove_child_group')}
+                              <span className="material-symbols-outlined me-1">group_remove</span> {t('admin:user_group_management.remove_child_group')}
                             </button>
                           )}
                           <button className="dropdown-item" type="button" role="button" onClick={onClickDelete} data-user-group-id={group._id}>

+ 8 - 4
apps/app/src/components/Admin/Users/SortIcons.tsx

@@ -13,15 +13,19 @@ export const SortIcons = (props: SortIconsProps): JSX.Element => {
   return (
     <div className="d-flex flex-column text-center">
       <a
-        className={`fa ${isSelected && isAsc ? 'fa-chevron-up' : 'fa-angle-up'}`}
+        className={`${isSelected && isAsc ? 'text-primary' : 'text-muted'}`}
         aria-hidden="true"
         onClick={() => onClick('asc')}
-      />
+      >
+        <span className="material-symbols-outlined">expand_less</span>
+      </a>
       <a
-        className={`fa ${isSelected && !isAsc ? 'fa-chevron-down' : 'fa-angle-down'}`}
+        className={`${isSelected && !isAsc ? 'text-primary' : 'text-muted'}`}
         aria-hidden="true"
         onClick={() => onClick('desc')}
-      />
+      >
+        <span className="material-symbols-outlined">expand_more</span>
+      </a>
     </div>
   );
 };

+ 2 - 2
apps/app/src/components/Admin/Users/UserMenu.module.scss

@@ -1,5 +1,5 @@
 .grw-usermenu-notification-icon :global {
   position: absolute;
-  top: -4px;
-  left: 30px;
+  top: -6px;
+  left: 3px;
 }

+ 5 - 2
apps/app/src/components/Admin/Users/UserMenu.tsx

@@ -95,10 +95,13 @@ const UserMenu = (props: UserMenuProps) => {
   return (
     <UncontrolledDropdown id="userMenu" size="sm">
       <DropdownToggle caret color="secondary" outline>
-        {/* TODO:fontsize: 20px */}
         <span className="material-symbols-outlined fs-5">settings</span>
         {(user.status === USER_STATUS.INVITED && !isInvitationEmailSended)
-        && <i className={`fa fa-circle text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`} />}
+        && (
+          <span className={`material-symbols-outlined fill fs-6 text-danger grw-usermenu-notification-icon ${styles['grw-usermenu-notification-icon']}`}>
+            circle
+          </span>
+        )}
       </DropdownToggle>
       <DropdownMenu strategy="fixed">
         {renderEditMenu()}

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -35,7 +35,7 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickMoveToRoot}
             className="grw-page-control-dropdown-item"
           >
-            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
             {t('bookmark_folder.move_to_root')}
           </DropdownItem>
         )}

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -118,7 +118,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           onClick={onUnbookmarkHandler}
           className="grw-bookmark-folder-menu-item text-danger"
         >
-          <i className="fa fa-bookmark"></i>{' '}
+          <span className="material-symbols-outlined">bookmark</span>{' '}
           <span className="mx-2">
             {t('bookmark_folder.cancel_bookmark')}
           </span>

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -15,7 +15,7 @@ export const BookmarkMoveToRootBtn: React.FC<{
       className="grw-page-control-dropdown-item"
       data-testid="add-remove-bookmark-btn"
     >
-      <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+      <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
       {t('bookmark_folder.move_to_root')}
     </DropdownItem>
   );

+ 3 - 2
apps/app/src/components/Common/CustomCopyToClipBoard.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
@@ -25,7 +26,7 @@ const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
     <>
       <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
         <div className="btn input-group-text" id="tooltipTarget">
-          <i className="fa fa-clipboard mx-1" aria-hidden="true"></i>
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">content_paste</span>
         </div>
       </CopyToClipboard>
       <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>

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

@@ -170,7 +170,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
             className="grw-page-control-dropdown-item"
             data-testid="add-remove-bookmark-btn"
           >
-            <i className="fa fa-fw fa-bookmark-o grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
             { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
           </DropdownItem>
         ) }

+ 4 - 0
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.module.scss

@@ -2,3 +2,7 @@
   margin-right: 0.2em;
   margin-left: 0.2em;
 }
+
+.material-symbols-outlined {
+  font-size: inherit;
+}

+ 2 - 3
apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx

@@ -41,7 +41,7 @@ export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkPro
         <RootElm>
           <span className="path-segment">
             <Link href="/trash" prefetch={false}>
-              <span className="material-symbols-outlined">delete</span>
+              <span className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}>delete</span>
             </Link>
           </span>
           <span className={`separator ${styles.separator}`}><a href="/">/</a></span>
@@ -51,8 +51,7 @@ export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkPro
         <RootElm>
           <span className="path-segment">
             <Link href="/" prefetch={false}>
-              {/* TODO: Size adjust */}
-              <span className="material-symbols-outlined">home</span>
+              <span className={`material-symbols-outlined ${styles['material-symbols-outlined']}`}>home</span>
               <span className={`separator ${styles.separator}`}>/</span>
             </Link>
           </span>

+ 2 - 2
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -72,10 +72,10 @@ export const PagePathNav: FC<Props> = (props: Props) => {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
     formerLink = (
-      <>
+      <div className="fs-5">
         <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />
         <Separator />
-      </>
+      </div>
     );
     latterLink = (
       <>

+ 2 - 2
apps/app/src/components/Me/AssociateModal.tsx

@@ -66,7 +66,7 @@ const AssociateModal = (props: Props): JSX.Element => {
               className={activeTab === 1 ? 'active' : ''}
               onClick={() => setActiveTab(1)}
             >
-              <i className="fa fa-sitemap"></i> LDAP
+              <span className="material-symbols-outlined">network_node</span> LDAP
             </NavLink>
             <NavLink
               className={activeTab === 2 ? 'active' : ''}
@@ -113,7 +113,7 @@ const AssociateModal = (props: Props): JSX.Element => {
       </ModalBody>
       <ModalFooter className="border-top-0">
         <button type="button" className="btn btn-primary mt-3" onClick={clickAddLdapAccountHandler}>
-          <i className="fa fa-plus-circle" aria-hidden="true"></i>
+          <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
           {t('add')}
         </button>
       </ModalFooter>

+ 4 - 0
apps/app/src/components/Navbar/GrowiContextualSubNavigation.module.scss

@@ -9,5 +9,9 @@
   .grw-contextual-sub-navigation {
     position: fixed;
     right: 0;
+
+    // unset colors
+    background-color: unset;
+    backdrop-filter: unset;
   }
 }

+ 3 - 1
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,7 +1,9 @@
 import React, { type ReactNode, useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
+
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
@@ -74,7 +76,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
     try {
       await createAndTransit(
-        { path, wip: shouldCreateWipPage(path) },
+        { path, wip: shouldCreateWipPage(path), origin: Origin.View },
         { shouldCheckPageExists: true },
       );
     }

+ 3 - 1
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -20,7 +20,7 @@ import {
   useCurrentUser, useIsSlackConfigured, useAcceptedUploadFileType,
 } from '~/stores/context';
 import {
-  useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning,
+  useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning, useEditorSettings,
 } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useNextThemes } from '~/stores/use-next-themes';
@@ -79,6 +79,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
+  const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
     increment: incrementEditingCommentsNum,
@@ -336,6 +337,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 onChange={onChangeHandler}
                 onSave={postCommentHandler}
                 onUpload={uploadHandler}
+                editorSettings={editorSettings}
               />
               {/* <Editor
                 ref={editorRef}

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

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import DropdownToggle from 'reactstrap/esm/DropdownToggle';

+ 4 - 2
apps/app/src/components/PageControls/LikeButtons.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -8,6 +9,7 @@ import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
 import UserPictureList from '../Common/UserPictureList';
 
 import styles from './LikeButtons.module.scss';
+import popoverStyles from './user-list-popover.module.scss';
 
 type LikeButtonsProps = {
 
@@ -65,7 +67,7 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         {sumOfLikers}
       </button>
       <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className="user-list-popover">
+        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
           <div className="px-2 text-end user-list-content text-truncate text-muted">
             {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
           </div>

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

@@ -1,4 +1,5 @@
-import React, { FC, useState } from 'react';
+import type { FC } from 'react';
+import React, { useState } from 'react';
 
 import type { IUser } from '@growi/core';
 import { FootstampIcon } from '@growi/ui/dist/components';

+ 1 - 1
apps/app/src/components/PageControls/_button-styles.scss

@@ -2,7 +2,7 @@
 
 %btn-basis {
   --bs-btn-padding-x: 6px;
-  --bs-btn-padding-y: 8px;
+  --bs-btn-padding-y: 6px;
   --bs-btn-line-height: 1em;
   --bs-btn-border-width: 0;
   --bs-btn-box-shadow: none;

+ 3 - 10
apps/app/src/components/PageControls/user-list-popover.module.scss

@@ -1,12 +1,5 @@
-.user-list-popover :global {
-  --bs-popover-max-width: 200px;
-  --bs-popover-body-padding-x: .5rem;
-  --bs-popover-body-padding-y: .5rem;
+@use '@growi/ui/scss/molecules/user-list-popover';
 
-  .user-list-content {
-    direction: rtl;
-  }
-  .cls-1 {
-    isolation: isolate;
-  }
+.user-list-popover :global {
+  @extend %user-list-popover
 }

+ 59 - 90
apps/app/src/components/PageCreateModal.jsx → apps/app/src/components/PageCreateModal.tsx

@@ -2,36 +2,42 @@ import React, {
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 
+import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import {
   Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-import { toastError } from '~/client/util/toastr';
+import { useCreateTemplatePage } from '~/client/services/create-page';
+import { useCreatePageAndTransit } from '~/client/services/create-page/use-create-page-and-transit';
+import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode } from '~/stores/ui';
+
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 import styles from './PageCreateModal.module.scss';
 
 const {
-  isCreatablePage, generateEditorPath, isUsersHomepage,
+  isCreatablePage, isUsersHomepage,
 } = pagePathUtils;
 
-const PageCreateModal = () => {
+const PageCreateModal: React.FC = () => {
   const { t } = useTranslation();
-  const router = useRouter();
 
   const { data: currentUser } = useCurrentUser();
 
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
-  const { isOpened, path } = pageCreateModalData;
+
+  const path = pageCreateModalData?.path;
+  const isOpened = pageCreateModalData?.isOpened ?? false;
+
+  const { createAndTransit } = useCreatePageAndTransit();
+  const { createTemplate } = useCreateTemplatePage();
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const pathname = path || '';
@@ -39,26 +45,13 @@ const PageCreateModal = () => {
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
+  const todaysParentPath = [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/');
 
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  const [todayInput1, setTodayInput1] = useState(t('Memo'));
-  const [todayInput2, setTodayInput2] = useState('');
+  const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
   const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
 
-  // ensure pageNameInput is synced with selectedPagePath || currentPagePath
-  useEffect(() => {
-    if (isOpened) {
-      setPageNameInput(isCreatable ? pathUtils.addTrailingSlash(pathname) : '/');
-    }
-  }, [isOpened, pathname, isCreatable]);
-
-  useEffect(() => {
-    setTodayInput1(t('Memo'));
-  }, [t]);
-
   const checkIsUsersHomepageDebounce = useMemo(() => {
     const checkIsUsersHomepage = () => {
       setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
@@ -69,10 +62,11 @@ const PageCreateModal = () => {
 
   useEffect(() => {
     if (isOpened) {
-      checkIsUsersHomepageDebounce(pageNameInput);
+      checkIsUsersHomepageDebounce();
     }
   }, [isOpened, checkIsUsersHomepageDebounce, pageNameInput]);
 
+
   function transitBySubmitEvent(e, transitHandler) {
     // prevent page transition by submit
     e.preventDefault();
@@ -80,19 +74,11 @@ const PageCreateModal = () => {
   }
 
   /**
-   * change todayInput1
-   * @param {string} value
-   */
-  function onChangeTodayInput1Handler(value) {
-    setTodayInput1(value);
-  }
-
-  /**
-   * change todayInput2
+   * change todayInput
    * @param {string} value
    */
-  function onChangeTodayInput2Handler(value) {
-    setTodayInput2(value);
+  function onChangeTodayInputHandler(value) {
+    setTodayInput(value);
   }
 
   /**
@@ -103,53 +89,45 @@ const PageCreateModal = () => {
     setTemplate(value);
   }
 
-  /**
-   * join path, check if creatable, then redirect
-   * @param {string} paths
-   */
-  const redirectToEditor = useCallback(async(...paths) => {
-    try {
-      const editorPath = generateEditorPath(...paths);
-      await router.push(editorPath);
-      mutateEditorMode(EditorMode.Editor);
-
-      // close modal
-      closeCreateModal();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [closeCreateModal, mutateEditorMode, router]);
-
   /**
    * access today page
    */
-  function createTodayPage() {
-    let tmpTodayInput1 = todayInput1;
-    if (tmpTodayInput1 === '') {
-      tmpTodayInput1 = t('Memo');
-    }
-    redirectToEditor(userHomepagePath, tmpTodayInput1, now, todayInput2);
-  }
+  const createTodayPage = useCallback(async() => {
+    const joinedPath = [todaysParentPath, todayInput].join('/');
+    return createAndTransit(
+      { path: joinedPath, wip: true, origin: Origin.View },
+      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    );
+  }, [closeCreateModal, createAndTransit, todayInput, todaysParentPath]);
 
   /**
    * access input page
    */
-  function createInputPage() {
-    redirectToEditor(pageNameInput);
-  }
-
-  const ppacSubmitHandler = useCallback((input) => {
-    redirectToEditor(input);
-  }, [redirectToEditor]);
+  const createInputPage = useCallback(async() => {
+    return createAndTransit(
+      {
+        path: pageNameInput,
+        wip: true,
+        origin: Origin.View,
+      },
+      { shouldCheckPageExists: true, onTerminated: closeCreateModal },
+    );
+  }, [closeCreateModal, createAndTransit, pageNameInput]);
 
   /**
    * access template page
    */
-  function createTemplatePage(e) {
-    const pageName = (template === 'children') ? '_template' : '__template';
-    redirectToEditor(pathname, pageName);
-  }
+  const createTemplatePage = useCallback(async() => {
+
+    const label = (template === 'children') ? '_template' : '__template';
+
+    await createTemplate?.(label);
+    closeCreateModal();
+  }, [closeCreateModal, createTemplate, template]);
+
+  const createTodaysMemoWithToastr = useToastrOnError(createTodayPage);
+  const createInputPageWithToastr = useToastrOnError(createInputPage);
+  const createTemplateWithToastr = useToastrOnError(createTemplatePage);
 
   function renderCreateTodayForm() {
     if (!isOpened) {
@@ -158,31 +136,22 @@ const PageCreateModal = () => {
     return (
       <div className="row">
         <fieldset className="col-12 mb-4">
-          <h3 className="grw-modal-head pb-2">{t("Create today's")}</h3>
+          <h3 className="grw-modal-head pb-2">{t('create_page_dropdown.todays.desc', { ns: 'commons' })}</h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
 
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
-              <div className="d-flex align-items-center">
-                <span>{userHomepagePath}/</span>
-                <form onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
-                  <input
-                    type="text"
-                    className="page-today-input1 form-control text-center mx-2"
-                    value={todayInput1}
-                    onChange={e => onChangeTodayInput1Handler(e.target.value)}
-                  />
-                </form>
-                <span className="page-today-suffix">/{now}/</span>
+              <div className="d-flex align-items-center text-nowrap">
+                <span>{todaysParentPath}/</span>
               </div>
-              <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
+              <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={(e) => { transitBySubmitEvent(e, createTodaysMemoWithToastr) }}>
                 <input
                   type="text"
                   className="page-today-input2 form-control w-100"
                   id="page-today-input2"
                   placeholder={t('Input page name (optional)')}
-                  value={todayInput2}
-                  onChange={e => onChangeTodayInput2Handler(e.target.value)}
+                  value={todayInput}
+                  onChange={e => onChangeTodayInputHandler(e.target.value)}
                 />
               </form>
             </div>
@@ -192,7 +161,7 @@ const PageCreateModal = () => {
                 type="button"
                 data-testid="btn-create-memo"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
-                onClick={createTodayPage}
+                onClick={createTodaysMemoWithToastr}
               >
                 <span className="material-symbols-outlined">description</span>{t('Create')}
               </button>
@@ -221,13 +190,13 @@ const PageCreateModal = () => {
                   <PagePathAutoComplete
                     initializedPath={pageNameInputInitialValue}
                     addTrailingSlash
-                    onSubmit={ppacSubmitHandler}
+                    onSubmit={createInputPageWithToastr}
                     onInputChange={value => setPageNameInput(value)}
                     autoFocus
                   />
                 )
                 : (
-                  <form onSubmit={e => transitBySubmitEvent(e, createInputPage)}>
+                  <form onSubmit={(e) => { transitBySubmitEvent(e, createInputPageWithToastr) }}>
                     <input
                       type="text"
                       value={pageNameInput}
@@ -245,7 +214,7 @@ const PageCreateModal = () => {
                 type="button"
                 data-testid="btn-create-page-under-below"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
-                onClick={createInputPage}
+                onClick={createInputPageWithToastr}
                 disabled={isMatchedWithUserHomepagePath}
               >
                 <span className="material-symbols-outlined">description</span>{t('Create')}
@@ -300,7 +269,7 @@ const PageCreateModal = () => {
                 data-testid="grw-btn-edit-page"
                 type="button"
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
-                onClick={createTemplatePage}
+                onClick={createTemplateWithToastr}
                 disabled={template == null}
               >
                 <span className="material-symbols-outlined">description</span>{t('Edit')}

+ 5 - 0
apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.module.scss

@@ -0,0 +1,5 @@
+@use '@growi/ui/scss/molecules/user-list-popover';
+
+.user-list-popover :global {
+  @extend %user-list-popover;
+}

+ 57 - 0
apps/app/src/components/PageEditor/EditorNavbar/EditingUserList.tsx

@@ -0,0 +1,57 @@
+import { type FC, useState } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui/dist/components';
+import { Popover, PopoverBody } from 'reactstrap';
+
+import UserPictureList from '../../Common/UserPictureList';
+
+import styles from './EditingUserList.module.scss';
+
+const userListPopoverClass = styles['user-list-popover'] ?? '';
+
+type Props = {
+  userList: IUserHasId[]
+}
+
+export const EditingUserList: FC<Props> = ({ userList }) => {
+  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+  const togglePopover = () => setIsPopoverOpen(!isPopoverOpen);
+
+  const firstFourUsers = userList.slice(0, 4);
+  const remainingUsers = userList.slice(4);
+
+  if (userList.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <div className="d-flex flex-column justify-content-end">
+      <div className="d-flex justify-content-end">
+        {firstFourUsers.map(user => (
+          <div className="ms-1">
+            <UserPicture
+              user={user}
+              noLink
+              additionalClassName="border border-info"
+            />
+          </div>
+        ))}
+
+        {remainingUsers.length > 0 && (
+          <div className="ms-1">
+            <button type="button" id="btn-editing-user" className="btn border-0 bg-info-subtle rounded-pill p-0">
+              <span className="fw-bold text-info p-1">+{remainingUsers.length}</span>
+            </button>
+            <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-editing-user" toggle={togglePopover} trigger="legacy">
+              <PopoverBody className={userListPopoverClass}>
+                <UserPictureList users={remainingUsers} />
+              </PopoverBody>
+            </Popover>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+};

+ 3 - 0
apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.module.scss

@@ -0,0 +1,3 @@
+.editor-navbar :global {
+  min-height: 72px;
+}

+ 22 - 0
apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -0,0 +1,22 @@
+
+import { PageHeader } from '~/components/PageHeader';
+import { useEditingUsers } from '~/stores/use-editing-users';
+
+import { EditingUserList } from './EditingUserList';
+
+import styles from './EditorNavbar.module.scss';
+
+const moduleClass = styles['editor-navbar'] ?? '';
+
+export const EditorNavbar = (): JSX.Element => {
+  const { data: editingUsers } = useEditingUsers();
+
+  return (
+    <div className={`${moduleClass} d-flex justify-content-between px-4 py-1`}>
+      <PageHeader />
+      <EditingUserList
+        userList={editingUsers?.userList ?? []}
+      />
+    </div>
+  );
+};

+ 1 - 0
apps/app/src/components/PageEditor/EditorNavbar/index.ts

@@ -0,0 +1 @@
+export * from './EditorNavbar';

+ 1 - 1
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -91,7 +91,7 @@ const EditorNavbarBottom = (): JSX.Element => {
             >
               <div className="grw-slack-logo">
                 <SlackLogo />
-                <span className="grw-btn-slack-triangle fa fa-caret-up ms-2"></span>
+                <span className="grw-btn-slack-triangle material-symbols-outlined ms-2">arrow_drop_up</span>
               </div>
             </Button>
           ) : (

+ 1 - 1
apps/app/src/components/PageEditor/HandsontableModal.tsx

@@ -471,7 +471,7 @@ export const HandsontableModal = (): JSX.Element => {
             onClick={toggleDataImportArea}
           >
             <span className="me-3">{t('handsontable_modal.data_import')}</span>
-            <i className={isDataImportAreaExpanded ? 'fa fa-angle-up' : 'fa fa-angle-down'}></i>
+            <span className="material-symbols-outlined">{isDataImportAreaExpanded ? 'expand_less' : 'expand_more'}</span>
           </button>
           <div role="group" className="btn-group">
             <button type="button" className="btn btn-secondary" onClick={() => { alignButtonHandler('l') }}>

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

@@ -180,8 +180,8 @@ export const LinkEditModal = (): JSX.Element => {
         </div>
         <div className="d-flex align-items-center justify-content-center">
           <span className="lead mx-3">
-            <i className="d-none d-sm-block fa fa-caret-right"></i>
-            <i className="d-sm-none fa fa-caret-down"></i>
+            <span className="d-none d-sm-block material-symbols-outlined">arrow_right</span>
+            <span className="d-sm-none material-symbols-outlined">arrow_drop_down</span>
           </span>
         </div>
         <div className="card w-100 p-1 mb-0">

+ 19 - 15
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -2,8 +2,8 @@ import React, {
   memo, useCallback, useMemo, useState,
 } from 'react';
 
-import type {
-  EditorTheme, KeyMapMode,
+import {
+  type EditorTheme, type KeyMapMode, DEFAULT_KEYMAP, DEFAULT_THEME,
 } from '@growi/editor';
 import { useTranslation } from 'next-i18next';
 import Image from 'next/image';
@@ -14,11 +14,6 @@ import {
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 
-import {
-  DEFAULT_THEME, DEFAULT_KEYMAP,
-} from '../../interfaces/editor-settings';
-
-
 type RadioListItemProps = {
   onClick: () => void,
   icon?: React.ReactNode,
@@ -91,6 +86,7 @@ const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = {
 
 const ThemeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
 
+  const { t } = useTranslation();
   const { data: editorSettings, update } = useEditorSettings();
   const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME;
 
@@ -106,7 +102,7 @@ const ThemeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX
   ), [update, selectedTheme]);
 
   return (
-    <Selector header="Theme" onClickBefore={onClickBefore} items={listItems} />
+    <Selector header={t('page_edit.theme')} onClickBefore={onClickBefore} items={listItems} />
   );
 });
 ThemeSelector.displayName = 'ThemeSelector';
@@ -125,6 +121,7 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
 
 const KeymapSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
 
+  const { t } = useTranslation();
   const { data: editorSettings, update } = useEditorSettings();
   const selectedKeymapMode = editorSettings?.keymapMode ?? DEFAULT_KEYMAP;
 
@@ -144,7 +141,7 @@ const KeymapSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JS
 
 
   return (
-    <Selector header="Keymap" onClickBefore={onClickBefore} items={listItems} />
+    <Selector header={t('page_edit.keymap')} onClickBefore={onClickBefore} items={listItems} />
   );
 });
 KeymapSelector.displayName = 'KeymapSelector';
@@ -154,6 +151,7 @@ const TYPICAL_INDENT_SIZE = [2, 4];
 
 const IndentSizeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
 
+  const { t } = useTranslation();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
 
   const listItems = useMemo(() => (
@@ -167,7 +165,7 @@ const IndentSizeSelector = memo(({ onClickBefore }: {onClickBefore: () => void})
   ), [currentIndentSize, mutateCurrentIndentSize]);
 
   return (
-    <Selector header="Indent" onClickBefore={onClickBefore} items={listItems} />
+    <Selector header={t('page_edit.indent')} onClickBefore={onClickBefore} items={listItems} />
   );
 });
 IndentSizeSelector.displayName = 'IndentSizeSelector';
@@ -260,6 +258,8 @@ type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
 
 export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Element => {
 
+  const { t } = useTranslation();
+
   const [dropdownOpen, setDropdownOpen] = useState(false);
 
   const [status, setStatus] = useState<OptionStatus>(OptionsStatus.Home);
@@ -282,7 +282,7 @@ export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Eleme
         <span className="material-symbols-outlined py-0 fs-5"> settings </span>
         {
           collapsed ? <></>
-            : <label className="ms-1 me-1">Editor Config</label>
+            : <label className="ms-1 me-1">{t('page_edit.editor_config')}</label>
         }
       </DropdownToggle>
       <DropdownMenu container="body">
@@ -290,21 +290,25 @@ export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Eleme
           status === OptionsStatus.Home && (
             <div className="d-flex flex-column">
               <label className="text-muted ms-3">
-                Editor Config
+                {t('page_edit.editor_config')}
               </label>
               <hr className="my-1" />
-              <ChangeStateButton onClick={() => setStatus(OptionsStatus.Theme)} header="Theme" data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''} />
+              <ChangeStateButton
+                onClick={() => setStatus(OptionsStatus.Theme)}
+                header={t('page_edit.theme')}
+                data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''}
+              />
               <hr className="my-1" />
               <ChangeStateButton
                 onClick={() => setStatus(OptionsStatus.Keymap)}
-                header="Keymap"
+                header={t('page_edit.keymap')}
                 data={KEYMAP_LABEL_MAP[editorSettings.keymapMode ?? ''] ?? ''}
               />
               <hr className="my-1" />
               <ChangeStateButton
                 disabled={isIndentSizeForced}
                 onClick={() => setStatus(OptionsStatus.Indent)}
-                header="Indent"
+                header={t('page_edit.indent')}
                 data={currentIndentSize.toString() ?? ''}
               />
               <hr className="my-1" />

+ 15 - 15
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -6,7 +6,7 @@ import React, {
 import type EventEmitter from 'events';
 import nodePath from 'path';
 
-import type { IPageHasId } from '@growi/core';
+import { type IPageHasId, Origin } from '@growi/core';
 import { useGlobalSocket } from '@growi/core/dist/swr';
 import { pathUtils } from '@growi/core/dist/utils';
 import {
@@ -53,19 +53,19 @@ import {
   EditorMode,
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
+import { useEditingUsers } from '~/stores/use-editing-users';
 import { useNextThemes } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
-import { PageHeader } from '../PageHeader/PageHeader';
-
-// import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
-// import { ConflictDiffModal } from './ConflictDiffModal';
+import { EditorNavbar } from './EditorNavbar';
 import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 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');
 
@@ -117,6 +117,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { mutate: mutateRemoteRevisionLastUpdatedAt } = useRemoteRevisionLastUpdatedAt();
   const { mutate: mutateRemoteRevisionLastUpdateUser } = useRemoteRevisionLastUpdateUser();
   const { data: user } = useCurrentUser();
+  const { onEditorsUpdated } = useEditingUsers();
 
   const { data: socket } = useGlobalSocket();
 
@@ -157,8 +158,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     // set to ref
     initialValueRef.current = initialValue;
   }, [initialValue]);
-
-
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
@@ -205,9 +204,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
       const { page } = await updatePage({
         pageId,
-        revisionId: currentRevisionId,
         body: codeMirrorEditor?.getDoc() ?? '',
         grant: grantData?.grant,
+        origin: Origin.Editor,
         userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
           return { item: group.id, type: group.type };
         }),
@@ -433,9 +432,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   return (
     <div data-testid="page-editor" id="page-editor" className={`flex-expand-vert ${props.visibility ? '' : 'd-none'}`}>
-      <div className="px-4 py-2">
-        <PageHeader />
-      </div>
+
+      <EditorNavbar />
+
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
         <div className="page-editor-editor-container flex-expand-vert">
           <CodeMirrorEditorMain
@@ -445,12 +444,11 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             acceptedUploadFileType={acceptedUploadFileType}
             onScroll={scrollEditorHandlerThrottle}
             indentSize={currentIndentSize ?? defaultIndentSize}
-            userName={user?.name}
+            user={user ?? undefined}
             pageId={pageId ?? undefined}
             initialValue={initialValue}
-            onOpenEditor={markdown => setMarkdownToPreview(markdown)}
-            editorTheme={editorSettings?.theme}
-            editorKeymap={editorSettings?.keymapMode}
+            editorSettings={editorSettings}
+            onEditorsUpdated={onEditorsUpdated}
           />
         </div>
         <div
@@ -476,7 +474,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
         />
         */}
       </div>
+
       <EditorNavbarBottom />
+
     </div>
   );
 });

+ 6 - 4
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -21,10 +21,12 @@ export const PageHeader: FC = () => {
       <PagePathHeader
         currentPage={currentPage}
       />
-      <PageTitleHeader
-        className="mt-2"
-        currentPage={currentPage}
-      />
+      <div className="row mt-1">
+        <PageTitleHeader
+          className="col"
+          currentPage={currentPage}
+        />
+      </div>
     </div>
   );
 };

+ 1 - 1
apps/app/src/components/PageHeader/PagePathHeader.module.scss

@@ -13,7 +13,7 @@
     .btn {
       width: 24px;
       height: 24px;
-      transform: translateY(8px);
+      transform: translateY(12px);
     }
   }
 }

+ 1 - 0
apps/app/src/components/PageHeader/index.ts

@@ -0,0 +1 @@
+export * from './PageHeader';

+ 1 - 1
apps/app/src/components/PageManagement/ApiErrorMessage.jsx

@@ -39,7 +39,7 @@ const ApiErrorMessage = (props) => {
           <>
             <strong><span className="material-symbols-outlined me-1">lightbulb</span> { t('page_api_error.outdated') }</strong>
             <a className="btn-link" onClick={reload}>
-              <i className="fa fa-angle-double-right"></i> { t('Load latest') }
+              <span className="material-symbols-outlined">keyboard_double_arrow_right</span> { t('Load latest') }
             </a>
           </>
         );

+ 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)}>
-            <i className={`fa fa-fw fa-arrow-right ${expandOtherOptions ? 'fa-rotate-90' : ''}`}></i>
+            <span className={`material-symbols-outlined me-1 ${expandOtherOptions ? 'fa-rotate-90' : ''}`}>navigate_next</span>
             { t('modal_rename.label.Other options') }
           </button>
         </p>

+ 9 - 5
apps/app/src/components/PageSideContents/PageSideContents.tsx

@@ -1,4 +1,4 @@
-import React, { Suspense, useCallback } from 'react';
+import React, { Suspense, useCallback, useRef } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { getIdForRef, type IPageInfoForOperation } from '@growi/core';
@@ -82,6 +82,8 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
   const { page, isSharedUser } = props;
 
+  const tagsRef = useRef<HTMLDivElement>(null);
+
   const { data: pageInfo } = useSWRxPageInfo(page._id);
 
   const pagePath = page.path;
@@ -93,9 +95,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
     <>
       {/* Tags */}
       { page.revision != null && (
-        <Suspense fallback={<PageTagsSkeleton />}>
-          <Tags pageId={page._id} revisionId={page.revision._id} />
-        </Suspense>
+        <div ref={tagsRef}>
+          <Suspense fallback={<PageTagsSkeleton />}>
+            <Tags pageId={page._id} revisionId={page.revision._id} />
+          </Suspense>
+        </div>
       ) }
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
@@ -127,7 +131,7 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
       </div>
 
       <div className="d-none d-xl-block">
-        <TableOfContents />
+        <TableOfContents tagsElementHeight={tagsRef.current?.clientHeight} />
         {isUsersHomepagePath && <ContentLinkButtons author={page?.creator} />}
       </div>
     </>

+ 2 - 1
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
 import { useCreatePageAndTransit } from '~/client/services/create-page';
@@ -10,7 +11,7 @@ export const SidebarNotFound = (): JSX.Element => {
   const { createAndTransit } = useCreatePageAndTransit();
 
   const clickCreateButtonHandler = useCallback(async() => {
-    createAndTransit({ path: '/Sidebar', wip: false });
+    createAndTransit({ path: '/Sidebar', wip: false, origin: Origin.View });
   }, [createAndTransit]);
 
   return (

+ 2 - 17
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,10 +1,9 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState } from 'react';
 
-import { useTranslation } from 'react-i18next';
 import { Dropdown } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
-import { toastError } from '~/client/util/toastr';
+import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 
 import { CreateButton } from './CreateButton';
 import { DropendMenu } from './DropendMenu';
@@ -12,20 +11,6 @@ import { DropendToggle } from './DropendToggle';
 import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
 
-const useToastrOnError = <P, R>(method?: (param?: P) => Promise<R|undefined>): (param?: P) => Promise<R|undefined> => {
-  const { t } = useTranslation('commons');
-
-  return useCallback(async(param) => {
-    try {
-      return await method?.(param);
-    }
-    catch (err) {
-      toastError(t('toaster.create_failed', { target: 'a page' }));
-    }
-  }, [method, t]);
-};
-
-
 export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
 

+ 8 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -1,5 +1,7 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
+
 import { useCreatePageAndTransit } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/stores/page';
 
@@ -18,7 +20,12 @@ export const useCreateNewPage: UseCreateNewPage = () => {
     if (isLoadingPagePath) return;
 
     return createAndTransit(
-      { parentPath: currentPagePath, optionalParentPath: '/', wip: true },
+      {
+        parentPath: currentPagePath,
+        optionalParentPath: '/',
+        wip: true,
+        origin: Origin.View,
+      },
     );
   }, [createAndTransit, currentPagePath, isLoadingPagePath]);
 

+ 2 - 1
apps/app/src/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 
+import { Origin } from '@growi/core';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns';
 import { useTranslation } from 'react-i18next';
@@ -32,7 +33,7 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
     if (!isCreatable || todaysPath == null) return;
 
     return createAndTransit(
-      { path: todaysPath, wip: true },
+      { path: todaysPath, wip: true, origin: Origin.View },
       { shouldCheckPageExists: true },
     );
   }, [createAndTransit, isCreatable, todaysPath]);

+ 3 - 2
apps/app/src/components/SlackNotification.tsx

@@ -1,5 +1,6 @@
 /* eslint-disable react/prop-types */
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import { PopoverBody, PopoverHeader, UncontrolledPopover } from 'reactstrap';
@@ -56,7 +57,7 @@ export const SlackNotification: FC<SlackNotificationProps> = ({
           id={idForSlackPopover}
           type="text"
           value={slackChannels}
-          placeholder="Input channels"
+          placeholder={t('page_edit.input_channels', 'Input channels')}
           onChange={updateSlackChannelsHandler}
         />
         <UncontrolledPopover trigger="focus" placement="top" target={idForSlackPopover}>

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

@@ -29,7 +29,7 @@ const SystemVersion = (props: Props): JSX.Element => {
         </span>
         { showShortcutsButton && (
           <button type="button" className="btn btn-link ms-2 p-0" onClick={() => openShortcutsModal()}>
-            <i className="fa fa-keyboard-o"></i>&nbsp;<span className={`cmd-key ${os}`}></span>-/
+            <span className="material-symbols-outlined">keyboard</span>&nbsp;<span className={`cmd-key ${os}`}></span>-/
           </button>
         ) }
       </div>

+ 7 - 3
apps/app/src/components/TableOfContents.tsx

@@ -16,7 +16,11 @@ const { isUsersHomepage: _isUsersHomepage } = pagePathUtils;
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:TableOfContents');
 
-const TableOfContents = (): JSX.Element => {
+type Props = {
+  tagsElementHeight?: number
+}
+
+const TableOfContents = ({ tagsElementHeight }: Props): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
 
   const isUsersHomePage = currentPagePath != null && _isUsersHomepage(currentPagePath);
@@ -30,7 +34,7 @@ const TableOfContents = (): JSX.Element => {
 
     // rendererOptions for redo calcViewHeight()
     // see: https://github.com/weseek/growi/pull/6791
-    if (parentElem == null || containerElem == null || rendererOptions == null) {
+    if (parentElem == null || containerElem == null || rendererOptions == null || tagsElementHeight == null) {
       return 0;
     }
     const parentBottom = parentElem.getBoundingClientRect().bottom;
@@ -47,7 +51,7 @@ const TableOfContents = (): JSX.Element => {
     }
     // bottom - revisionToc top
     return bottom - (containerTop + containerPaddingTop);
-  }, [isUsersHomePage, rendererOptions]);
+  }, [isUsersHomePage, rendererOptions, tagsElementHeight]);
 
   return (
     <div id="revision-toc" className={`revision-toc ${styles['revision-toc']}`}>

+ 4 - 1
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,5 +1,7 @@
 import React, { useState, type FC, useCallback } from 'react';
 
+import { Origin } from '@growi/core';
+
 import { createPage } from '~/client/services/page-operation';
 import { useSWRxPageChildren, mutatePageTree } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
@@ -75,6 +77,7 @@ export const useNewPageInput = (): UseNewPageInput => {
         // keep grant info undefined to inherit from parent
         grant: undefined,
         grantUserGroupIds: undefined,
+        origin: Origin.View,
         wip: shouldCreateWipPage(newPagePath),
       });
 
@@ -83,7 +86,7 @@ export const useNewPageInput = (): UseNewPageInput => {
       if (!hasDescendants) {
         stateHandlers?.setIsOpen(true);
       }
-    }, [hasDescendants, mutateChildren, stateHandlers]);
+    }, [hasDescendants, stateHandlers]);
 
     const submittionFailedHandler = useCallback(() => {
       setProcessingSubmission(false);

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

@@ -53,7 +53,7 @@ const SimpleItemContent = ({ page }: { page: IPageForItem }) => {
     >
       {shouldShowAttentionIcon && (
         <>
-          <i id="path-recovery" className="fa fa-warning mr-2 text-warning"></i>
+          <span id="path-recovery" className="material-symbols-outlined mr-2 text-warning">warning</span>
           <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
             {t('tooltip.operation.attention.rename')}
           </UncontrolledTooltip>

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

@@ -27,7 +27,7 @@ export const UsersHomepageFooter = (props: UsersHomepageFooterProps): JSX.Elemen
     <div className={`container-lg user-page-footer py-5 ${styles['user-page-footer']}`}>
       <div className="grw-user-page-list-m d-edit-none">
         <h2 id="bookmarks-list" className="grw-user-page-header border-bottom pb-2 mb-3 d-flex">
-          <i style={{ fontSize: '1.3em' }} className="fa fa-fw fa-bookmark-o"></i>
+          <span style={{ fontSize: '1.3em' }} className="material-symbols-outlined">bookmark</span>
           {t('footer.bookmarks')}
           <span className="ms-auto ps-2 ">
             <button

+ 2 - 2
apps/app/src/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement.tsx

@@ -120,11 +120,11 @@ export const ExternalGroupManagement: FC = () => {
   const navTabMapping = useMemo(() => {
     return {
       ldap: {
-        Icon: () => <i className="fa fa-sitemap" />,
+        Icon: () => <span className="material-symbols-outlined">network_node</span>,
         i18n: 'LDAP',
       },
       keycloak: {
-        Icon: () => <i className="fa fa-key" />,
+        Icon: () => <span className="material-symbols-outlined">key</span>,
         i18n: 'Keycloak',
       },
     };

+ 5 - 2
apps/app/src/interfaces/apiv3/page.ts

@@ -1,5 +1,5 @@
 import type {
-  IPageHasId, IRevisionHasId, ITag,
+  IPageHasId, IRevisionHasId, ITag, Origin,
 } from '@growi/core';
 
 import type { IOptionsForCreate, IOptionsForUpdate } from '../page';
@@ -12,6 +12,8 @@ export type IApiv3PageCreateParams = IOptionsForCreate & {
   body?: string,
   pageTags?: string[],
 
+  origin?: Origin,
+
   isSlackEnabled?: boolean,
   slackChannels?: string,
 };
@@ -24,9 +26,10 @@ export type IApiv3PageCreateResponse = {
 
 export type IApiv3PageUpdateParams = IOptionsForUpdate & {
   pageId: string,
-  revisionId: string,
+  revisionId?: string,
   body: string,
 
+  origin?: Origin,
   isSlackEnabled?: boolean,
   slackChannels?: string,
 };

+ 0 - 11
apps/app/src/interfaces/editor-settings.ts

@@ -1,11 +0,0 @@
-import { type EditorTheme, type KeyMapMode } from '@growi/editor';
-
-export const DEFAULT_KEYMAP = 'default';
-export const DEFAULT_THEME = 'defaultlight';
-
-export interface IEditorSettings {
-  theme: undefined | EditorTheme,
-  keymapMode: undefined | KeyMapMode,
-  styleActiveLine: boolean,
-  autoFormatMarkdownTable: boolean,
-}

+ 3 - 1
apps/app/src/interfaces/page.ts

@@ -1,5 +1,5 @@
 import type {
-  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant,
+  GroupType, IGrantedGroup, IPageHasId, Nullable, PageGrant, Origin,
 } from '@growi/core';
 
 import type { IPageOperationProcessData } from './page-operation';
@@ -33,6 +33,7 @@ export type IDeleteManyPageApiv3Result = {
 };
 
 export type IOptionsForUpdate = {
+  origin?: Origin
   grant?: PageGrant,
   userRelatedGrantUserGroupIds?: IGrantedGroup[],
   // isSyncRevisionToHackmd?: boolean,
@@ -44,5 +45,6 @@ export type IOptionsForCreate = {
   grantUserGroupIds?: IGrantedGroup[],
   overwriteScopesOfDescendants?: boolean,
 
+  origin?: Origin
   wip?: boolean,
 };

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

@@ -1,4 +1,5 @@
-import React, { ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React from 'react';
 
 import type { IUser, IUserHasId } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';

+ 4 - 4
apps/app/src/server/models/editor-settings.ts

@@ -1,13 +1,13 @@
+import type { EditorSettings } from '@growi/editor';
+import type { Model, Document } from 'mongoose';
 import {
-  Schema, Model, Document,
+  Schema,
 } from 'mongoose';
 
-import { IEditorSettings } from '~/interfaces/editor-settings';
-
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-export interface EditorSettingsDocument extends IEditorSettings, Document {
+export interface EditorSettingsDocument extends EditorSettings, Document {
   userId: Schema.Types.ObjectId,
 }
 export type EditorSettingsModel = Model<EditorSettingsDocument>

+ 9 - 2
apps/app/src/server/models/obsolete-page.js

@@ -1,4 +1,4 @@
-import { PageGrant, GroupType } from '@growi/core';
+import { GroupType, Origin } from '@growi/core';
 import { templateChecker, pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import escapeStringRegexp from 'escape-string-regexp';
 
@@ -141,7 +141,14 @@ export const getPageSchema = (crowi) => {
     return relations.map((relation) => { return relation.relatedTag.name });
   };
 
-  pageSchema.methods.isUpdatable = function(previousRevision) {
+  pageSchema.methods.isUpdatable = async function(previousRevision, origin) {
+    const populatedPageDataWithRevisionOrigin = await this.populate('revision', 'origin');
+    const latestRevisionOrigin = populatedPageDataWithRevisionOrigin.revision.origin;
+    const ignoreLatestRevision = origin === Origin.Editor && (latestRevisionOrigin === Origin.Editor || latestRevisionOrigin === Origin.View);
+    if (ignoreLatestRevision) {
+      return true;
+    }
+
     const revision = this.latestRevision || this.revision;
     // comparing ObjectId with string
     // eslint-disable-next-line eqeqeq

+ 5 - 1
apps/app/src/server/models/revision.js

@@ -1,3 +1,5 @@
+import { allOrigin } from '@growi/core';
+
 import loggerFactory from '~/utils/logger';
 
 // disable no-return-await for model functions
@@ -29,6 +31,7 @@ module.exports = function(crowi) {
     format: { type: String, default: 'markdown' },
     author: { type: ObjectId, ref: 'User' },
     hasDiffToPrev: { type: Boolean },
+    origin: { type: String, enum: allOrigin },
   }, {
     timestamps: { createdAt: true, updatedAt: false },
   });
@@ -38,7 +41,7 @@ module.exports = function(crowi) {
     return this.updateMany({ pageId }, { $set: updateData });
   };
 
-  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, options) {
+  revisionSchema.statics.prepareRevision = function(pageData, body, previousBody, user, origin, options) {
     const Revision = this;
 
     if (!options) {
@@ -56,6 +59,7 @@ module.exports = function(crowi) {
     newRevision.body = body;
     newRevision.format = format;
     newRevision.author = user._id;
+    newRevision.origin = origin;
     if (pageData.revision != null) {
       newRevision.hasDiffToPrev = body !== previousBody;
     }

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

@@ -1,3 +1,4 @@
+import { allOrigin } from '@growi/core';
 import type {
   IPage, IUser, IUserHasId,
 } from '@growi/core';
@@ -117,6 +118,7 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
     body('wip').optional().isBoolean().withMessage('wip must be boolean'),
+    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
   ];
 
 
@@ -227,10 +229,10 @@ export const createPageHandlersFactory: CreatePageHandlersFactory = (crowi) => {
       let createdPage;
       try {
         const {
-          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip,
+          grant, grantUserGroupIds, overwriteScopesOfDescendants, wip, origin,
         } = req.body;
 
-        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip };
+        const options: IOptionsForCreate = { overwriteScopesOfDescendants, wip, origin };
         if (grant != null) {
           options.grant = grant;
           options.grantUserGroupIds = grantUserGroupIds;

+ 12 - 6
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -1,3 +1,4 @@
+import { allOrigin } from '@growi/core';
 import type {
   IPage, IRevisionHasId, IUserHasId,
 } from '@growi/core';
@@ -8,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 } from '~/interfaces/apiv3';
 import type { IOptionsForUpdate } from '~/interfaces/page';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type Crowi from '~/server/crowi';
@@ -63,7 +64,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
   const validator: ValidationChain[] = [
     body('pageId').exists().not().isEmpty({ ignore_whitespace: true })
       .withMessage("'pageId' must be specified"),
-    body('revisionId').exists().not().isEmpty({ ignore_whitespace: true })
+    body('revisionId').optional().exists().not()
+      .isEmpty({ ignore_whitespace: true })
       .withMessage("'revisionId' must be specified"),
     body('body').exists().isString()
       .withMessage("The empty value is not allowd for the 'body'"),
@@ -72,6 +74,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     body('overwriteScopesOfDescendants').optional().isBoolean().withMessage('overwriteScopesOfDescendants must be boolean'),
     body('isSlackEnabled').optional().isBoolean().withMessage('isSlackEnabled must be boolean'),
     body('slackChannels').optional().isString().withMessage('slackChannels must be string'),
+    body('origin').optional().isIn(allOrigin).withMessage('origin must be "view" or "editor"'),
   ];
 
 
@@ -101,7 +104,8 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     const { revisionId, isSlackEnabled, slackChannels } = req.body;
     if (isSlackEnabled) {
       try {
-        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', { previousRevision: revisionId });
+        const option = revisionId != null ? { previousRevision: revisionId } : undefined;
+        const results = await crowi.userNotificationService.fire(updatedPage, req.user, slackChannels, 'update', option);
         results.forEach((result) => {
           if (result.status === 'rejected') {
             logger.error('Create user notification failed', result.reason);
@@ -120,7 +124,9 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
     accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity,
     validator, apiV3FormValidator,
     async(req: UpdatePageRequest, res: ApiV3Response) => {
-      const { pageId, revisionId, body } = req.body;
+      const {
+        pageId, revisionId, body, origin,
+      } = req.body;
 
       // check page existence
       const isExist = await Page.count({ _id: pageId }) > 0;
@@ -130,7 +136,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
 
       // check revision
       const currentPage = await Page.findByIdAndViewer(pageId, req.user);
-      if (currentPage != null && !currentPage.isUpdatable(revisionId)) {
+      if (currentPage != null && !currentPage.isUpdatable(revisionId, origin)) {
         const latestRevision = await Revision.findById(currentPage.revision).populate('author');
         const returnLatestRevision = {
           revisionId: latestRevision?._id.toString(),
@@ -146,7 +152,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       let updatedPage;
       try {
         const { grant, userRelatedGrantUserGroupIds, overwriteScopesOfDescendants } = req.body;
-        const options: IOptionsForUpdate = { overwriteScopesOfDescendants };
+        const options: IOptionsForUpdate = { overwriteScopesOfDescendants, origin };
         if (grant != null) {
           options.grant = grant;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;

+ 5 - 5
apps/app/src/server/service/page/index.ts

@@ -3825,7 +3825,7 @@ class PageService implements IPageService {
 
     // Create revision
     const Revision = mongoose.model('Revision') as any; // TODO: Typescriptize model
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user);
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, options.origin);
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
@@ -3919,7 +3919,7 @@ class PageService implements IPageService {
     page.applyScope(user, grant, grantUserGroupIds);
 
     let savedPage = await page.save();
-    const newRevision = Revision.prepareRevision(savedPage, body, null, user, { format });
+    const newRevision = Revision.prepareRevision(savedPage, body, null, user, undefined, { format });
     savedPage = await pushRevision(savedPage, newRevision, user);
     await savedPage.populateDataToShowRevision();
 
@@ -4214,15 +4214,15 @@ class PageService implements IPageService {
     let savedPage = await newPageData.save();
 
     // Update body
-    const isBodyPresent = body != null && previousBody != null;
+    const isBodyPresent = body != null;
     const shouldUpdateBody = isBodyPresent;
     if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user);
+      const origin = options.origin;
+      const newRevision = await Revision.prepareRevision(newPageData, body, previousBody, user, origin);
       savedPage = await pushRevision(savedPage, newRevision, user);
       await savedPage.populateDataToShowRevision();
     }
 
-
     this.pageEvent.emit('update', savedPage, user);
 
     // Update ex children's parent

+ 4 - 4
apps/app/src/stores/editor.tsx

@@ -2,12 +2,12 @@ import { useCallback } from 'react';
 
 import { type Nullable } from '@growi/core';
 import { withUtils, type SWRResponseWithUtils } from '@growi/core/dist/swr';
+import type { EditorSettings } from '@growi/editor';
 import useSWR, { type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import type { IEditorSettings } from '~/interfaces/editor-settings';
 import type { SlackChannels } from '~/interfaces/user-trigger-notification';
 
 import {
@@ -29,13 +29,13 @@ export const useEditingMarkdown = (initialData?: string): SWRResponse<string, Er
 
 
 type EditorSettingsOperation = {
-  update: (updateData: Partial<IEditorSettings>) => Promise<void>,
+  update: (updateData: Partial<EditorSettings>) => Promise<void>,
 }
 
 // TODO: Enable localStorageMiddleware
 //   - Unabling localStorageMiddleware occurrs a flickering problem when loading theme.
 //   - see: https://github.com/weseek/growi/pull/6781#discussion_r1000285786
-export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperation, IEditorSettings, Error> => {
+export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperation, EditorSettings, Error> => {
   const { data: currentUser } = useCurrentUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
@@ -51,7 +51,7 @@ export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperatio
     },
   );
 
-  return withUtils<EditorSettingsOperation, IEditorSettings, Error>(swrResult, {
+  return withUtils<EditorSettingsOperation, EditorSettings, Error>(swrResult, {
     update: async(updateData) => {
       const { data, mutate } = swrResult;
 

+ 33 - 0
apps/app/src/stores/use-editing-users.ts

@@ -0,0 +1,33 @@
+import { useCallback } from 'react';
+
+import type { IUserHasId } from '@growi/core';
+import { useSWRStatic } from '@growi/core/dist/swr';
+import type { SWRResponse } from 'swr';
+
+type EditingUsersStatus = {
+  userList: IUserHasId[],
+}
+
+type EditingUsersStatusUtils = {
+  onEditorsUpdated(
+    userList: IUserHasId[],
+  ): void,
+}
+
+export const useEditingUsers = (status?: EditingUsersStatus): SWRResponse<EditingUsersStatus, Error> & EditingUsersStatusUtils => {
+  const initialData: EditingUsersStatus = {
+    userList: [],
+  };
+  const swrResponse = useSWRStatic<EditingUsersStatus, Error>('editingUsers', status, { fallbackData: initialData });
+
+  const { mutate } = swrResponse;
+
+  const onEditorsUpdated = useCallback((userList: IUserHasId[]): void => {
+    mutate({ userList });
+  }, [mutate]);
+
+  return {
+    ...swrResponse,
+    onEditorsUpdated,
+  };
+};

+ 2 - 0
bin/data-migrations/README.md

@@ -46,6 +46,8 @@ reference](https://docs.growi.org/ja/admin-guide/upgrading/60x.html#%E6%9C%AA%E5
 - `v60x` or `v60x/index`: Migration for all notations in v6.0.x series
 - `v61x/mdcont`: Migration for mdcont notation only([reference](https://docs.growi.org/ja/admin-guide/upgrading/61x.html#%E4%BB%95%E6%A7%98%E5%A4%89%E6%9B%B4-%E3%82%A2%E3%83%B3%E3%82%AB%E3%83%BC%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%AB%E8%87%AA%E5%8B%95%E4%BB%98%E4%B8%8E%E3%81%95%E3%82%8C%E3%82%8B-mdcont-%E3%83%95%E3%82%9A%E3%83%AC%E3%83%95%E3%82%A3%E3%82%AF%E3%82%B9%E3%81%AE%E5%BB%83%E6%AD%A2))
 - `v61x` or `v61x/index`: Migration for all notations in v6.1.x series
+- `v70x/bootstrap5`: Migration for Bootstrap4 to Bootstrap5 
+- `v70x` or `v70x/index`: Migration for all notations in v7.0.x series
 - `custom`: You can define your own processors and apply them to `revision` (see "Advanced" below for details)
 
 ### Optional

+ 682 - 0
bin/data-migrations/src/migrations/v70x/bootstrap5.js

@@ -0,0 +1,682 @@
+/**
+ * @typedef {import('../../types').MigrationModule} MigrationModule
+ */
+
+// Script for migrating from Bootstrap4 to Bootstrap5 syntax
+// Inspired by https://github.com/coliff/bootstrap-5-migrate-tool/blob/main/gulpfile.js
+
+module.exports = [
+  /**
+   * @type {MigrationModule}
+   */
+  (body) => {
+    let replacedBody = body;
+
+    replacedBody = replacedBody.replace(
+      // eslint-disable-next-line max-len
+      /\sdata-(animation|autohide|boundary|container|content|custom-class|delay|dismiss|display|html|interval|keyboard|method|offset|pause|placement|popper-config|reference|ride|selector|slide(-to)?|target|template|title|toggle|touch|trigger|wrap)=/g,
+      (match, p1) => {
+        if (p1 === 'toggle' && match.includes('data-bs-toggle="')) {
+          return match;
+        }
+        return ` data-bs-${p1}=`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(/\[data-toggle=/g, '[data-bs-toggle=');
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-danger\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-danger${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-dark\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-dark${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-info\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-info${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-light\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-light${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-pill\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-pill${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-primary\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-primary${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-secondary\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-secondary${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-success\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-success${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bbadge-warning\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-bg-warning${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bborder-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}border-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bborder-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}border-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bclose\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}btn-close${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control-input\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check-input${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control-label\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check-label${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control custom-checkbox\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control custom-radio\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-file-input\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-control${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-file-label\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-label${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-range\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-range${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-select-sm\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-select-sm${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-select-lg\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-select-lg${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-select\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-select${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bcustom-control custom-switch\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-check form-switch${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-sm-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-sm-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-md-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-md-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-lg-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-lg-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-xl-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-xl-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-sm-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-sm-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-md-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-md-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-lg-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-lg-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropdown-menu-xl-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropdown-menu-xl-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropleft\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropstart${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bdropright\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}dropend${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-sm-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-sm-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-md-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-md-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-lg-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-lg-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-xl-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-xl-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-sm-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-sm-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-md-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-md-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-lg-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-lg-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfloat-xl-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}float-xl-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-italic\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fst-italic${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-bold\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-bold${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-bolder\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-bolder${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-light\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-light${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-lighter\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-lighter${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bfont-weight-normal\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}fw-normal${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-control-file\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-control${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-control-range\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}form-range${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-group\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}mb-3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-inline\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}d-flex align-items-center${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bform-row\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}row${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bjumbotron-fluid\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-0 px-0${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bjumbotron\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}bg-light mb-4 rounded-2 py-5 px-3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmedia-body\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}flex-grow-1${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmedia\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}d-flex${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bml-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ms-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bml-n\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ms-n${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmr-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}me-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bmr-n\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}me-n${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bno-gutters\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}g-0${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bpl-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ps-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bpr-\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}pe-${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bpre-scrollable\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}overflow-y-scroll${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-item\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-16by9\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-16x9${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-1by1\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-1x1${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-21by9\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-21x9${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive-4by3\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio-4x3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bembed-responsive\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}ratio${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-lg\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-3${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\brounded-sm\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}rounded-1${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bsr-only-focusable\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}visually-hidden-focusable${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\bsr-only\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}visually-hidden${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-hide\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}d-none${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-sm-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-sm-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-md-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-md-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-lg-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-lg-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-xl-left\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-xl-start${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-sm-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-sm-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-md-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-md-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-lg-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-lg-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-xl-right\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}text-xl-end${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /(<[^>]*class\s*=\s*['"][^'"]*)\btext-monospace\b([^'"]*['"])/g,
+      (_, p1, p2) => {
+        return `${p1}font-monospace${p2}`;
+      },
+    );
+
+    replacedBody = replacedBody.replace(
+      /<select([^>]*)\bclass=['"]([^'"]*)form-control(-lg|-sm)?([^'"]*)['"]([^>]*)>/g, '<select$1class="$2form-select$3$4"$5>',
+    );
+
+    replacedBody = replacedBody.replace(/<select([^>]*)\bclass=['"]([^'"]*)form-control\b([^'"]*['"])/g, '<select$1class="$2form-select$3');
+
+    replacedBody = replacedBody.replace('<span aria-hidden="true">&times;</span>', '');
+
+    return replacedBody;
+  },
+];

+ 3 - 0
bin/data-migrations/src/migrations/v70x/index.js

@@ -0,0 +1,3 @@
+const bootstrap5 = require('./bootstrap5');
+
+module.exports = [...bootstrap5];

+ 7 - 4
packages/core/src/interfaces/growi-theme-metadata.ts

@@ -10,9 +10,12 @@ export type GrowiThemeMetadata = {
   name: string,
   manifestKey: string,
   schemeType: GrowiThemeSchemeType,
-  bg: string,
-  topbar: string,
-  sidebar: string,
-  accent: string,
+  lightBg: string,
+  darkBg: string,
+  lightSidebar: string,
+  darkSidebar: string,
+  lightIcon: string,
+  darkIcon: string,
+  createBtn: string,
   isPresetTheme?: boolean,
 };

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

@@ -1,12 +1,22 @@
 import type { HasObjectId } from './has-object-id';
 import type { IUser } from './user';
 
+export const Origin = {
+  View: 'view',
+  Editor: 'editor',
+} as const;
+
+export type Origin = typeof Origin[keyof typeof Origin];
+
+export const allOrigin = Object.values(Origin);
+
 export type IRevision = {
   body: string,
   author: IUser,
   hasDiffToPrev: boolean;
   createdAt: Date,
   updatedAt: Date,
+  origin?: Origin,
 }
 
 export type IRevisionHasId = IRevision & HasObjectId;

+ 13 - 49
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -1,21 +1,22 @@
 import {
-  forwardRef, useMemo, useRef, useEffect, useState,
+  forwardRef, useMemo, useRef, useEffect,
 } from 'react';
 
 import { indentUnit } from '@codemirror/language';
-import { Prec, Extension } from '@codemirror/state';
-import { EditorView } from '@codemirror/view';
+import {
+  EditorView,
+} from '@codemirror/view';
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
-import { GlobalCodeMirrorEditorKey } from '../../consts';
+import { EditorSettings, GlobalCodeMirrorEditorKey } from '../../consts';
 import {
-  useFileDropzone, FileDropzoneOverlay, getEditorTheme, type EditorTheme, getKeymap, type KeyMapMode,
+  useFileDropzone, FileDropzoneOverlay,
 } from '../../services';
 import {
   adjustPasteData, getStrFromBol,
 } from '../../services/paste-util/paste-markdown-util';
-import { useCodeMirrorEditorIsolated } from '../../stores';
+import { useDefaultExtensions, useCodeMirrorEditorIsolated, useEditorSettings } from '../../stores';
 
 import { Toolbar } from './Toolbar';
 
@@ -31,8 +32,7 @@ const CodeMirrorEditorContainer = forwardRef<HTMLDivElement>((props, ref) => {
 export type CodeMirrorEditorProps = {
   acceptedUploadFileType?: AcceptedUploadFileType,
   indentSize?: number,
-  editorTheme?: EditorTheme,
-  editorKeymap?: KeyMapMode,
+  editorSettings?: EditorSettings,
   onChange?: (value: string) => void,
   onSave?: () => void,
   onUpload?: (files: File[]) => void,
@@ -48,8 +48,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     editorKey,
     acceptedUploadFileType = AcceptedUploadFileType.NONE,
     indentSize,
-    editorTheme,
-    editorKeymap,
+    editorSettings,
     onChange,
     onSave,
     onUpload,
@@ -65,6 +64,9 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   }, [onChange]);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey, containerRef.current, cmProps);
 
+  useDefaultExtensions(codeMirrorEditor);
+  useEditorSettings(codeMirrorEditor, editorSettings, onSave);
+
   useEffect(() => {
     if (indentSize == null) {
       return;
@@ -76,6 +78,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [codeMirrorEditor, indentSize]);
 
+
   useEffect(() => {
     const handlePaste = (event: ClipboardEvent) => {
       event.preventDefault();
@@ -150,45 +153,6 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   }, [onScroll, codeMirrorEditor]);
 
 
-  const [themeExtension, setThemeExtension] = useState<Extension | undefined>(undefined);
-  useEffect(() => {
-    const settingTheme = async(name?: EditorTheme) => {
-      setThemeExtension(await getEditorTheme(name ?? 'defaultlight'));
-    };
-    settingTheme(editorTheme);
-  }, [codeMirrorEditor, editorTheme, setThemeExtension]);
-
-  useEffect(() => {
-    if (themeExtension == null) {
-      return;
-    }
-    // React CodeMirror has default theme which is default prec
-    // and extension have to be higher prec here than default theme.
-    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.high(themeExtension));
-    return cleanupFunction;
-  }, [codeMirrorEditor, themeExtension]);
-
-
-  const [keymapExtension, setKeymapExtension] = useState<Extension | undefined>(undefined);
-  useEffect(() => {
-    const settingKeyMap = async(name?: KeyMapMode) => {
-      setKeymapExtension(await getKeymap(name ?? 'default'));
-    };
-    settingKeyMap(editorKeymap);
-
-  }, [codeMirrorEditor, editorKeymap, setKeymapExtension]);
-
-  useEffect(() => {
-    if (keymapExtension == null) {
-      return;
-    }
-
-    // Prevent these Keybind from overwriting the originally defined keymap.
-    const cleanupFunction = codeMirrorEditor?.appendExtensions(Prec.low(keymapExtension));
-    return cleanupFunction;
-
-  }, [codeMirrorEditor, keymapExtension, onSave]);
-
   const {
     getRootProps,
     getInputProps,

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