Prechádzať zdrojové kódy

Merge branch 'dev/7.0.x' into imprv/136128-hide-title-of-untitled-page

kosei-n 2 rokov pred
rodič
commit
570ccb4f41
100 zmenil súbory, kde vykonal 838 pridanie a 1014 odobranie
  1. 4 3
      apps/app/package.json
  2. BIN
      apps/app/public/images/icons/sublime.png
  3. BIN
      apps/app/public/images/icons/vscode.png
  4. 1 1
      apps/app/public/static/locales/en_US/admin.json
  5. 16 3
      apps/app/public/static/locales/en_US/translation.json
  6. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  7. 16 3
      apps/app/public/static/locales/ja_JP/translation.json
  8. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  9. 16 3
      apps/app/public/static/locales/zh_CN/translation.json
  10. 2 0
      apps/app/src/client/services/create-page/index.ts
  11. 110 0
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  12. 38 0
      apps/app/src/client/services/create-page/use-create-template-page.ts
  13. 30 79
      apps/app/src/client/services/page-operation.ts
  14. 11 28
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  15. 14 29
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  16. 0 123
      apps/app/src/client/services/use-create-page-and-transit.tsx
  17. 0 55
      apps/app/src/client/services/use-on-template-button-clicked.ts
  18. 2 2
      apps/app/src/client/util/bookmark-utils.ts
  19. 9 10
      apps/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  20. 1 1
      apps/app/src/components/Admin/Common/AdminNavigation.tsx
  21. 4 5
      apps/app/src/components/Admin/Security/GitHubSecuritySettingContents.jsx
  22. 4 4
      apps/app/src/components/Admin/Security/GoogleSecuritySettingContents.jsx
  23. 6 6
      apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx
  24. 8 4
      apps/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  25. 1 1
      apps/app/src/components/Admin/Security/SecurityManagementContents.jsx
  26. 6 6
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  27. 9 3
      apps/app/src/components/Admin/SlackIntegration/Bridge.jsx
  28. 1 1
      apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  29. 1 1
      apps/app/src/components/Admin/SlackIntegration/SlackIntegration.jsx
  30. 1 1
      apps/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  31. 2 3
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  32. 5 6
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  33. 4 3
      apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  34. 0 169
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  35. 122 0
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx
  36. 6 5
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  37. 3 3
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  38. 1 1
      apps/app/src/components/Admin/UserManagement.module.scss
  39. 4 3
      apps/app/src/components/Admin/UserManagement.tsx
  40. 2 2
      apps/app/src/components/Admin/Users/ExternalAccountTable.tsx
  41. 1 1
      apps/app/src/components/Admin/Users/GrantAdminButton.tsx
  42. 1 1
      apps/app/src/components/Admin/Users/GrantReadOnlyButton.tsx
  43. 2 2
      apps/app/src/components/Admin/Users/RevokeAdminButton.tsx
  44. 2 2
      apps/app/src/components/Admin/Users/RevokeAdminMenuItem.tsx
  45. 1 1
      apps/app/src/components/Admin/Users/RevokeReadOnlyMenuItem.tsx
  46. 1 1
      apps/app/src/components/Admin/Users/SendInvitationEmailButton.jsx
  47. 1 1
      apps/app/src/components/Admin/Users/StatusActivateButton.jsx
  48. 2 2
      apps/app/src/components/Admin/Users/StatusSuspendMenuItem.tsx
  49. 3 2
      apps/app/src/components/Admin/Users/UserMenu.tsx
  50. 3 3
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  51. 0 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  52. 4 3
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  53. 25 18
      apps/app/src/components/Common/ClosableTextInput.tsx
  54. 2 1
      apps/app/src/components/Common/CountBadge.tsx
  55. 6 6
      apps/app/src/components/Common/Dropdown/PageItemControl.tsx
  56. 3 2
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  57. 5 0
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  58. 19 6
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  59. 0 6
      apps/app/src/components/ContentLinkButtons.module.scss
  60. 13 18
      apps/app/src/components/ContentLinkButtons.tsx
  61. 13 8
      apps/app/src/components/CreateTemplateModal.tsx
  62. 5 12
      apps/app/src/components/CustomNavigation/CustomNav.module.scss
  63. 3 3
      apps/app/src/components/CustomNavigation/CustomNav.tsx
  64. 3 5
      apps/app/src/components/DescendantsPageListModal.tsx
  65. 4 2
      apps/app/src/components/ExpandOrContractButton.tsx
  66. 0 28
      apps/app/src/components/Icons/AttachmentIcon.jsx
  67. 0 22
      apps/app/src/components/Icons/HistoryIcon.jsx
  68. 0 17
      apps/app/src/components/Icons/PageListIcon.jsx
  69. 0 22
      apps/app/src/components/Icons/PresentationIcon.jsx
  70. 0 35
      apps/app/src/components/Icons/ShareLinkIcon.jsx
  71. 0 19
      apps/app/src/components/Icons/TimeLineIcon.jsx
  72. 0 5
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  73. 14 8
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  74. 13 13
      apps/app/src/components/Me/BasicInfoSettings.tsx
  75. 1 1
      apps/app/src/components/Me/ExternalAccountLinkedMe.jsx
  76. 6 6
      apps/app/src/components/Me/PersonalSettings.jsx
  77. 19 19
      apps/app/src/components/Me/ProfileImageSettings.tsx
  78. 2 2
      apps/app/src/components/Me/UserSettings.tsx
  79. 20 18
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  80. 26 23
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  81. 2 4
      apps/app/src/components/NotFoundPage.tsx
  82. 3 3
      apps/app/src/components/Page/PageView.tsx
  83. 2 2
      apps/app/src/components/Page/RevisionLoader.tsx
  84. 3 2
      apps/app/src/components/Page/ShareLinkAlert.tsx
  85. 3 6
      apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  86. 1 1
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  87. 3 3
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  88. 1 1
      apps/app/src/components/PageAlert/OldRevisionAlert.tsx
  89. 2 0
      apps/app/src/components/PageAlert/PageAlerts.tsx
  90. 3 3
      apps/app/src/components/PageAlert/PageGrantAlert.tsx
  91. 1 1
      apps/app/src/components/PageAlert/PageStaleAlert.tsx
  92. 1 1
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  93. 53 0
      apps/app/src/components/PageAlert/WipPageAlert.tsx
  94. 2 2
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  95. 2 3
      apps/app/src/components/PageComment/Comment.tsx
  96. 48 47
      apps/app/src/components/PageComment/CommentEditor.tsx
  97. 2 2
      apps/app/src/components/PageComment/ReplyComments.tsx
  98. 4 0
      apps/app/src/components/PageContentFooter.tsx
  99. 9 8
      apps/app/src/components/PageControls/PageControls.tsx
  100. 8 8
      apps/app/src/components/PageDuplicateModal.tsx

+ 4 - 3
apps/app/package.json

@@ -69,8 +69,8 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/core": "link:../../packages/core",
+    "@growi/custom-icons": "link:../../packages/custom-icons",
     "@growi/pluginkit": "link:../../packages/pluginkit",
     "@growi/preset-templates": "link:../../packages/preset-templates",
     "@growi/preset-themes": "link:../../packages/preset-themes",
@@ -163,7 +163,7 @@
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
-    "react-bootstrap-typeahead": "^5.2.2",
+    "react-bootstrap-typeahead": "^6.3.2",
     "react-card-flip": "^1.0.10",
     "react-datepicker": "^4.7.0",
     "react-disable": "^0.1.1",
@@ -235,8 +235,8 @@
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "@types/throttle-debounce": "^5.0.1",
-    "@types/url-join": "^4.0.2",
     "@types/unzip-stream": "^0.3.4",
+    "@types/url-join": "^4.0.2",
     "@vitejs/plugin-react": "^4.2.1",
     "@vitest/coverage-v8": "^0.34.6",
     "autoprefixer": "^9.0.0",
@@ -270,6 +270,7 @@
     "react-copy-to-clipboard": "^5.0.1",
     "react-dropzone": "^11.2.4",
     "react-hotkeys": "^2.0.0",
+    "react-input-autosize": "^3.0.0",
     "rehype-rewrite": "^3.0.6",
     "replacestream": "^4.0.3",
     "sass": "^1.53.0",

BIN
apps/app/public/images/icons/sublime.png


BIN
apps/app/public/images/icons/vscode.png


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

@@ -746,7 +746,7 @@
       "description1":"Temporarily issue new users by email addresses.",
       "description2":"A temporary password will be generated for the first login.",
       "invite_thru_email": "Send invitation email",
-      "mail_setting_link":"<i class='icon-settings me-2'></i><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link":"<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",

+ 16 - 3
apps/app/public/static/locales/en_US/translation.json

@@ -391,11 +391,11 @@
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Same page already exists": "Same page already exists",
-      "Only duplicate user related resources": "Only duplicate user related resources"
+      "Only duplicate user related pages": "Only duplicate pages you can access"
     },
     "help": {
       "recursive": "Duplicate children of under this path recursively",
-      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
+      "only_inherit_user_related_groups": "If the page privilege is set to \"Only inside the group\", groups you do not belong to will lose access to the duplicated page"
     }
   },
   "duplicated_pages": "{{fromPath}} has been duplicated",
@@ -538,6 +538,7 @@
     "search_again" : "Search again",
     "number_of_list_to_display" : "Display",
     "page_number_unit" : "pages",
+    "hit_number_unit" : "hit",
     "sort_axis": {
       "relationScore": "Sort by relevance",
       "createdAt": "Creation date",
@@ -640,7 +641,7 @@
     "Username or E-mail has invalid characters": "Username or E-mail has invalid characters.",
     "Password minimum character should be more than 6 characters": "Password minimum character should be more than 6 characters.",
     "user_not_found": "User not found.",
-    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException occured</strong></p><p class='mb-0'> Your {{ failedProviderForDuplicatedUsernameException }} authentication was succeeded, but a new user could not be created. See the issue <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
@@ -825,5 +826,17 @@
   },
   "page_select_modal": {
     "select_page_location": "Select page location"
+  },
+  "wip_page": {
+    "save_as_wip": "Save as WIP (Currently drafting)",
+    "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",
+    "publish_page": "Publish page",
+    "success_publish_page": "Page has been published",
+    "fail_publish_page": "Failed to publish the Page"
+  },
+  "sidebar_header": {
+    "show_wip_page": "Show WIP"
   }
 }

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

@@ -756,7 +756,7 @@
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
       "description2": "初回のログイン時に使用する仮パスワードが生成されます。",
       "invite_thru_email": "招待メールを送信する",
-      "mail_setting_link": "<i class='icon-settings me-2'></i><a href='/admin/app'>メールの設定</a>",
+      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>メールの設定</a>",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",

+ 16 - 3
apps/app/public/static/locales/ja_JP/translation.json

@@ -424,11 +424,11 @@
       "Recursively": "再帰的に複製",
       "Duplicate without exist path": "存在するパス以外を複製する",
       "Same page already exists": "同じページがすでに存在します",
-      "Only duplicate user related resources": "ユーザに関連のあるリソースのみを複製する"
+      "Only duplicate user related pages": "自分が閲覧可能なページのみを複製する"
     },
     "help": {
       "recursive": "配下のページも複製します",
-      "only_user_related_resources": "ユーザが閲覧可能なページのみを複製します。また、閲覧権限が「特定グループのみ」で設定されている場合、複製後のページにはユーザが所属するグループのみを閲覧可能なグループとして設定します。"
+      "only_inherit_user_related_groups": "閲覧権限が「特定グループのみ」で設定されている場合、複製されたページを閲覧可能なグループ一覧から、自分が所属していないものは取り除かれます"
     }
   },
   "duplicated_pages": "{{fromPath}} を複製しました",
@@ -571,6 +571,7 @@
     "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
     "page_number_unit" : "件",
+    "hit_number_unit" : "件",
     "sort_axis": {
       "relationScore": "関連度順",
       "createdAt": "作成日時",
@@ -673,7 +674,7 @@
     "Username or E-mail has invalid characters": "ユーザー名または、メールアドレスに無効な文字があります",
     "Password minimum character should be more than 6 characters": "パスワードの最小文字数は6文字以上です",
     "user_not_found": "ユーザーが見つかりません",
-    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>エラー: DuplicatedUsernameException</strong></p><p class='mb-0'> {{ failedProviderForDuplicatedUsernameException }} 認証は成功しましたが、新しいユーザーを作成できませんでした。詳しくは<a href='https://github.com/weseek/growi/issues/193'>こちら: #193</a>.</p>"
   },
   "grid_edit":{
     "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
@@ -858,5 +859,17 @@
   },
   "page_select_modal": {
     "select_page_location": "ページの場所を選択"
+  },
+  "wip_page": {
+    "save_as_wip": "WIP (執筆中) として保存",
+    "success_save_as_wip": "WIP ページとして保存しました",
+    "fail_save_as_wip": "WIP ページとして保存できませんでした",
+    "alert": "このページは作業途中です",
+    "publish_page": "WIP を解除",
+    "success_publish_page": "WIP を解除しました",
+    "fail_publish_page": "WIP を解除できませんでした"
+  },
+  "sidebar_header": {
+    "show_wip_page": "WIP を表示"
   }
 }

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

@@ -754,7 +754,7 @@
       "emails": "电子邮件",
       "description1": "通过电子邮件地址临时发布新用户。",
       "description2": "将为首次登录生成一个临时密码。",
-      "mail_setting_link": "<i class='icon-settings me-2'></i><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "需要有效的电子邮件地址",
       "invite_thru_email": "发送邀请电子邮件",
       "temporary_password": "创建的用户具有临时密码",

+ 16 - 3
apps/app/public/static/locales/zh_CN/translation.json

@@ -381,11 +381,11 @@
       "Recursively": "Recursively",
       "Duplicate without exist path": "Duplicate without exist path",
       "Same page already exists": "Same page already exists",
-      "Only duplicate user related resources": "Only duplicate user related resources"
+      "Only duplicate user related pages": "Only duplicate pages you can access"
     },
     "help": {
       "recursive": "Duplicate children of under this path recursively",
-      "only_user_related_resources": "This will only duplicate pages that the user has permission to view. If the page permission is set to \"Only specific groups\", only user related groups will be set to the page duplicate."
+      "only_inherit_user_related_groups": "If the page privilege is set to \"Only inside the group\", groups you do not belong to will lose access to the duplicated page"
     }
   },
   "duplicated_pages": "{{fromPath}} 已重复",
@@ -541,6 +541,7 @@
     "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
     "page_number_unit" : "例",
+    "hit_number_unit" : "例",
     "sort_axis": {
       "relationScore": "按相关性排序",
       "createdAt": "按创建日期排序",
@@ -643,7 +644,7 @@
     "Username or E-mail has invalid characters": "用户名或电子邮件有无效的字符",
     "Password minimum character should be more than 6 characters": "密码最小字符应超过6个字符",
     "user_not_found": "未找到用户",
-    "provider_duplicated_username_exception": "<p><strong><i class='icon-fw icon-ban'></i>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>发生了重复用户名异常</strong></p><p class='mb-0'> 你的 {{ failedProviderForDuplicatedUsernameException }} 认证成功了,但不能创建新的用户。参见问题<a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
 	},
   "grid_edit":{
     "create_bootstrap_4_grid":"创建Bootstrap 4网格",
@@ -828,5 +829,17 @@
   },
   "page_select_modal": {
     "select_page_location": "选择页面位置"
+  },
+  "wip_page": {
+    "save_as_wip": "保存为 WIP(书面)",
+    "success_save_as_wip": "成功保存为 WIP 页面",
+    "fail_save_as_wip": "保存为 WIP 页失败",
+    "alert": "本页面正在制作中",
+    "publish_page": "发布 WIP",
+    "success_publish_page": "WIP 已停用",
+    "fail_publish_page": "无法停用 WIP"
+  },
+  "sidebar_header": {
+    "show_wip_page": "显示 WIP"
   }
 }

+ 2 - 0
apps/app/src/client/services/create-page/index.ts

@@ -0,0 +1,2 @@
+export * from './use-create-page-and-transit';
+export * from './use-create-template-page';

+ 110 - 0
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -0,0 +1,110 @@
+import { useCallback, useState } from 'react';
+
+import { useRouter } from 'next/router';
+
+import { createPage, exist } from '~/client/services/page-operation';
+import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
+import { useCurrentPagePath } from '~/stores/page';
+import { EditorMode, useEditorMode } from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
+
+/**
+ * Invoked when creation and transition has finished
+ */
+type OnCreated = () => void;
+/**
+ * Invoked when either creation or transition has aborted
+ */
+type OnAborted = () => void;
+/**
+ * Always invoked after processing is terminated
+ */
+type OnTerminated = () => void;
+
+type CreatePageAndTransitOpts = {
+  shouldCheckPageExists?: boolean,
+  onCreationStart?: OnCreated,
+  onCreated?: OnCreated,
+  onAborted?: OnAborted,
+  onTerminated?: OnTerminated,
+}
+
+type CreatePageAndTransit = (
+  params: IApiv3PageCreateParams,
+  opts?: CreatePageAndTransitOpts,
+) => Promise<void>;
+
+type UseCreatePageAndTransit = () => {
+  isCreating: boolean,
+  createAndTransit: CreatePageAndTransit,
+};
+
+export const useCreatePageAndTransit: UseCreatePageAndTransit = () => {
+
+  const router = useRouter();
+
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { mutate: mutateEditorMode } = useEditorMode();
+
+  const [isCreating, setCreating] = useState(false);
+
+  const createAndTransit: CreatePageAndTransit = useCallback(async(params, opts = {}) => {
+    const {
+      shouldCheckPageExists,
+      onCreationStart, onCreated, onAborted, onTerminated,
+    } = opts;
+
+    // check the page existence
+    if (shouldCheckPageExists && params.path != null) {
+      const pagePath = params.path;
+
+      try {
+        const { isExist } = await exist(pagePath);
+
+        if (isExist) {
+          // routing
+          if (pagePath !== currentPagePath) {
+            await router.push(`${pagePath}#edit`);
+          }
+          mutateEditorMode(EditorMode.Editor);
+          onAborted?.();
+          return;
+        }
+      }
+      catch (err) {
+        throw err;
+      }
+      finally {
+        onTerminated?.();
+      }
+    }
+
+    // create and transit
+    try {
+      setCreating(true);
+      onCreationStart?.();
+
+      const response = await createPage(params);
+
+      await router.push(`/${response.page._id}#edit`);
+      mutateEditorMode(EditorMode.Editor);
+
+      onCreated?.();
+    }
+    catch (err) {
+      throw err;
+    }
+    finally {
+      onTerminated?.();
+      setCreating(false);
+    }
+
+  }, [currentPagePath, mutateEditorMode, router]);
+
+  return {
+    isCreating,
+    createAndTransit,
+  };
+};

+ 38 - 0
apps/app/src/client/services/create-page/use-create-template-page.ts

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+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 = () => {
+  isCreatable: boolean,
+  isCreating: boolean,
+  createTemplate?: (label: LabelType) => Promise<void>,
+}
+
+export const useCreateTemplatePage: UseCreateTemplatePage = () => {
+
+  const { data: currentPagePath, isLoading: isLoadingPagePath } = useCurrentPagePath();
+
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+  const isCreatable = currentPagePath != null && isCreatablePage(normalizePath(`${currentPagePath}/_template`));
+
+  const createTemplate = useCallback(async(label: LabelType) => {
+    if (isLoadingPagePath || !isCreatable) return;
+
+    return createAndTransit(
+      { path: normalizePath(`${currentPagePath}/${label}`), wip: false },
+      { shouldCheckPageExists: true },
+    );
+  }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);
+
+  return {
+    isCreatable,
+    isCreating,
+    createTemplate: isCreatable ? createTemplate : undefined,
+  };
+};

+ 30 - 79
apps/app/src/client/services/page-operation.ts

@@ -1,20 +1,24 @@
 import { useCallback } from 'react';
 
-import { SubscriptionStatusType, type Nullable } from '@growi/core';
+import type { IPageHasId } from '@growi/core';
+import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
-import { OptionsToSave } from '~/interfaces/page-operation';
-import { useEditingMarkdown, useIsEnabledUnsavedWarning, usePageTagsForEditors } from '~/stores/editor';
+import type {
+  IApiv3PageCreateParams, IApiv3PageCreateResponse, IApiv3PageUpdateParams, IApiv3PageUpdateResponse,
+} from '~/interfaces/apiv3';
+import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
 import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
-import { apiGet, apiPost } from '../util/apiv1-client';
-import { apiv3Post, apiv3Put } from '../util/apiv3-client';
+import { apiPost } from '../util/apiv1-client';
+import { apiv3Get, apiv3Post, apiv3Put } from '../util/apiv3-client';
 import { toastError } from '../util/toastr';
 
 const logger = loggerFactory('growi:services:page-operation');
 
+
 export const toggleSubscribe = async(pageId: string, currentStatus: SubscriptionStatusType | undefined): Promise<void> => {
   try {
     const newStatus = currentStatus === SubscriptionStatusType.SUBSCRIBE
@@ -87,71 +91,14 @@ export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 
-// TODO: define return type
-export const createPage = async(pagePath: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    path: pagePath,
-    body: markdown,
-  });
-
-  const res = await apiv3Post('/pages/', params);
-  const { page, tags, revision } = res.data;
-
-  return { page, tags, revision };
-};
-
-// TODO: define return type
-const updatePage = async(pageId: string, revisionId: string, markdown: string, tmpParams: OptionsToSave) => {
-  // clone
-  const params = Object.assign(tmpParams, {
-    page_id: pageId,
-    revision_id: revisionId,
-    body: markdown,
-  });
-
-  const res: any = await apiPost('/pages.update', params);
-  if (!res.ok) {
-    throw new Error(res.error);
-  }
-  return res;
+export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+  const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
+  return res.data;
 };
 
-type PageInfo= {
-  path: string,
-  pageId: Nullable<string>,
-  revisionId: Nullable<string>,
-}
-
-type SaveOrUpdateFunction = (markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => any;
-
-// TODO: define return type
-export const useSaveOrUpdate = (): SaveOrUpdateFunction => {
-  /* eslint-disable react-hooks/rules-of-hooks */
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-  /* eslint-enable react-hooks/rules-of-hooks */
-
-  return useCallback(async(markdown: string, pageInfo: PageInfo, optionsToSave?: OptionsToSave) => {
-    const { path, pageId, revisionId } = pageInfo;
-
-    const options: OptionsToSave = Object.assign({}, optionsToSave);
-
-    let res;
-    if (pageId == null || revisionId == null) {
-      res = await createPage(path, markdown, options);
-    }
-    else {
-      if (revisionId == null) {
-        const msg = '\'revisionId\' is required to update page';
-        throw new Error(msg);
-      }
-      res = await updatePage(pageId, revisionId, markdown, options);
-    }
-
-    mutateIsEnabledUnsavedWarning(false);
-
-    return res;
-  }, [mutateIsEnabledUnsavedWarning]);
+export const updatePage = async(params: IApiv3PageUpdateParams): Promise<IApiv3PageUpdateResponse> => {
+  const res = await apiv3Put<IApiv3PageUpdateResponse>('/page', params);
+  return res.data;
 };
 
 export type UpdateStateAfterSaveOption = {
@@ -178,7 +125,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
     await mutateCurrentPageId(pageId);
     const updatedPage = await mutateCurrentPage();
 
-    if (updatedPage == null) { return }
+    if (updatedPage == null || updatedPage.revision == null) { return }
 
     // supress to mutate only when updated from built-in editor
     // and see: https://github.com/weseek/growi/pull/7118
@@ -205,17 +152,21 @@ export const unlink = async(path: string): Promise<void> => {
 };
 
 
-interface PageExistRequest {
-  pagePaths: string;
-}
-
 interface PageExistResponse {
-  pages: Record<string, boolean>;
-  ok: boolean
+  isExist: boolean,
 }
 
-export const exist = async(pagePaths: string): Promise<PageExistResponse> => {
-  const request: PageExistRequest = { pagePaths };
-  const res = await apiGet<PageExistResponse>('/pages.exist', request);
-  return res;
+export const exist = async(path: string): Promise<PageExistResponse> => {
+  const res = await apiv3Get<PageExistResponse>('/page/exist', { path });
+  return res.data;
+};
+
+export const publish = async(pageId: string): Promise<IPageHasId> => {
+  const res = await apiv3Put(`/page/${pageId}/publish`);
+  return res.data;
+};
+
+export const unpublish = async(pageId: string): Promise<IPageHasId> => {
+  const res = await apiv3Put(`/page/${pageId}/unpublish`);
+  return res.data;
 };

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

@@ -1,17 +1,17 @@
 import { useCallback, useEffect } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
 import type { DrawioEditByViewerProps } from '@growi/remark-drawio';
 
-import { useSaveOrUpdate } from '~/client/services/page-operation';
 import { replaceDrawioInMarkdown } from '~/components/Page/markdown-drawio-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useDrawioModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+import { updatePage } from '../page-operation';
+
 
 const logger = loggerFactory('growi:cli:side-effects:useDrawioModalLauncherForView');
 
@@ -33,38 +33,21 @@ export const useDrawioModalLauncherForView = (opts?: {
 
   const { open: openDrawioModal } = useDrawioModal();
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || shareLinkId != null) {
+    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceDrawioInMarkdown(drawioMxFile, currentMarkdown, bol, eol);
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
       opts?.onSaveSuccess?.();
     }
@@ -72,7 +55,7 @@ export const useDrawioModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
   // set handler to open DrawioModal

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

@@ -1,16 +1,16 @@
 import { useCallback, useEffect } from 'react';
 
-import EventEmitter from 'events';
+import type EventEmitter from 'events';
 
-import MarkdownTable from '~/client/models/MarkdownTable';
-import { useSaveOrUpdate } from '~/client/services/page-operation';
+import type MarkdownTable from '~/client/models/MarkdownTable';
 import { getMarkdownTableFromLine, replaceMarkdownTableInMarkdown } from '~/components/Page/markdown-table-util-for-view';
-import type { OptionsToSave } from '~/interfaces/page-operation';
 import { useShareLinkId } from '~/stores/context';
 import { useHandsontableModal } from '~/stores/modal';
-import { useSWRxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import { useSWRxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
+import { updatePage } from '../page-operation';
+
 
 const logger = loggerFactory('growi:cli:side-effects:useHandsontableModalLauncherForView');
 
@@ -32,38 +32,21 @@ export const useHandsontableModalLauncherForView = (opts?: {
 
   const { open: openHandsontableModal } = useHandsontableModal();
 
-  const saveOrUpdate = useSaveOrUpdate();
-
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || shareLinkId != null) {
+    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
     const currentMarkdown = currentPage.revision.body;
     const newMarkdown = replaceMarkdownTableInMarkdown(table, currentMarkdown, bol, eol);
 
-    const grantUserGroupIds = currentPage.grantedGroups.map((g) => {
-      return {
-        type: g.type,
-        item: g.item._id,
-      };
-    });
-
-    const optionsToSave: OptionsToSave = {
-      isSlackEnabled: false,
-      slackChannels: '',
-      grant: currentPage.grant,
-      // grantUserGroupIds,
-      // pageTags: tagsInfo.tags,
-    };
-
     try {
       const currentRevisionId = currentPage.revision._id;
-      await saveOrUpdate(
-        newMarkdown,
-        { pageId: currentPage._id, path: currentPage.path, revisionId: currentRevisionId },
-        optionsToSave,
-      );
+      await updatePage({
+        pageId: currentPage._id,
+        revisionId: currentRevisionId,
+        body: newMarkdown,
+      });
 
       opts?.onSaveSuccess?.();
     }
@@ -71,7 +54,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
       logger.error('failed to save', error);
       opts?.onSaveError?.(error);
     }
-  }, [currentPage, opts, saveOrUpdate, shareLinkId]);
+  }, [currentPage, opts, shareLinkId]);
 
 
   // set handler to open HandsonTableModal
@@ -81,6 +64,8 @@ export const useHandsontableModalLauncherForView = (opts?: {
     }
 
     const handler = (bol: number, eol: number) => {
+      if (currentPage.revision == null) return;
+
       const markdown = currentPage.revision.body;
       const currentMarkdownTable = getMarkdownTableFromLine(markdown, bol, eol);
       openHandsontableModal(currentMarkdownTable, false, table => saveByHandsontableModal(table, bol, eol));

+ 0 - 123
apps/app/src/client/services/use-create-page-and-transit.tsx

@@ -1,123 +0,0 @@
-import { useCallback } from 'react';
-
-import { useRouter } from 'next/router';
-
-import { createPage } from '~/client/services/page-operation';
-import { useIsNotFound, useSWRxCurrentPage } from '~/stores/page';
-import { EditorMode, useEditorMode } from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
-
-/**
- * Invoked when creation and transition has finished
- */
-type OnCreated = () => void;
-/**
- * Invoked when either creation or transition has aborted
- */
-type OnAborted = () => void;
-/**
- * Invoked when an error is occured
- */
-type OnError = (err) => void;
-/**
- * Always invoked after processing is terminated
- */
-type OnTerminated = () => void;
-
-type CreatePageAndTransitOpts = {
-  onCreationStart?: OnCreated,
-  onCreated?: OnCreated,
-  onAborted?: OnAborted,
-  onError?: OnError,
-  onTerminated?: OnTerminated,
-}
-
-type CreatePageAndTransit = (
-  pagePath: string | undefined,
-  // grant?: number,
-  // grantUserGroupId?: string,
-  opts?: CreatePageAndTransitOpts,
-) => Promise<void>;
-
-export const useCreatePageAndTransit = (): CreatePageAndTransit => {
-
-  const router = useRouter();
-
-  const { data: isNotFound } = useIsNotFound();
-  const { data: currentPage, isLoading } = useSWRxCurrentPage();
-  const { mutate: mutateEditorMode } = useEditorMode();
-
-  // const {
-  //   path: currentPagePath,
-  //   grant: currentPageGrant,
-  //   grantedGroups: currentPageGrantedGroups,
-  // } = currentPage ?? {};
-
-  return useCallback(async(pagePath, opts = {}) => {
-    if (isLoading) {
-      return;
-    }
-
-    const {
-      onCreationStart, onCreated, onAborted, onError, onTerminated,
-    } = opts;
-
-    if (isNotFound == null || !isNotFound || pagePath == null) {
-      mutateEditorMode(EditorMode.Editor);
-
-      onAborted?.();
-      onTerminated?.();
-      return;
-    }
-
-    try {
-      onCreationStart?.();
-
-      /**
-       * !! NOTICE !! - Verification of page createable or not is checked on the server side.
-       * since the new page path is not generated on the client side.
-       * need shouldGeneratePath flag.
-       */
-      // const shouldCreateUnderRoot = currentPagePath == null || currentPageGrant == null;
-      // const parentPath = shouldCreateUnderRoot
-      //   ? '/'
-      //   : currentPagePath;
-
-      // const params = {
-      //   isSlackEnabled: false,
-      //   slackChannels: '',
-      //   grant: shouldCreateUnderRoot ? 1 : currentPageGrant,
-      //   grantUserGroupIds: shouldCreateUnderRoot ? undefined : currentPageGrantedGroups,
-      //   shouldGeneratePath: true,
-      // };
-
-      // !! NOTICE !! - if shouldGeneratePath is flagged, send the parent page path
-      // const response = await createPage(parentPath, '', params);
-
-      const params = {
-        isSlackEnabled: false,
-        slackChannels: '',
-        grant: 4,
-        // grant,
-        // grantUserGroupId,
-      };
-
-      const response = await createPage(pagePath, '', params);
-
-      await router.push(`${response.page.id}#edit`);
-      mutateEditorMode(EditorMode.Editor);
-
-      onCreated?.();
-    }
-    catch (err) {
-      logger.warn(err);
-      onError?.(err);
-    }
-    finally {
-      onTerminated?.();
-    }
-
-  }, [isLoading, isNotFound, mutateEditorMode, router]);
-};

+ 0 - 55
apps/app/src/client/services/use-on-template-button-clicked.ts

@@ -1,55 +0,0 @@
-import { useCallback, useState } from 'react';
-
-import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
-import { useRouter } from 'next/router';
-
-import { createPage, exist } from '~/client/services/page-operation';
-import type { LabelType } from '~/interfaces/template';
-
-export const useOnTemplateButtonClicked = (
-    currentPagePath?: string,
-    isLoading?: boolean,
-): {
-  onClickHandler: (label: LabelType) => Promise<void>,
-  isPageCreating: boolean
-} => {
-  const router = useRouter();
-  const [isPageCreating, setIsPageCreating] = useState(false);
-
-  const onClickHandler = useCallback(async(label: LabelType) => {
-    if (isLoading) return;
-
-    try {
-      setIsPageCreating(true);
-
-      const targetPath = currentPagePath == null || currentPagePath === '/'
-        ? `/${label}`
-        : `${currentPagePath}/${label}`;
-
-      const path = isCreatablePage(targetPath) ? targetPath : `/${label}`;
-
-      const res = await exist(JSON.stringify([path]));
-      if (!res.pages[path]) {
-        const params = {
-          isSlackEnabled: false,
-          slackChannels: '',
-          grant: 4,
-        // grant: currentPage?.grant || 1,
-        // grantUserGroupId: currentPage?.grantedGroup?._id,
-        };
-
-        await createPage(path, '', params);
-      }
-
-      router.push(`${path}#edit`);
-    }
-    catch (err) {
-      throw err;
-    }
-    finally {
-      setIsPageCreating(false);
-    }
-  }, [currentPagePath, isLoading, router]);
-
-  return { onClickHandler, isPageCreating };
-};

+ 2 - 2
apps/app/src/client/util/bookmark-utils.ts

@@ -1,6 +1,6 @@
 import type { IRevision, Ref } from '@growi/core';
 
-import { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
@@ -31,7 +31,7 @@ export const deleteBookmarkFolder = async(bookmarkFolderId: string): Promise<voi
 };
 
 // Rename page from bookmark item control
-export const renamePage = async(pageId: string, revisionId: Ref<IRevision>, newPagePath: string): Promise<void> => {
+export const renamePage = async(pageId: string, revisionId: Ref<IRevision> | undefined, newPagePath: string): Promise<void> => {
   await apiv3Put('/pages/rename', { pageId, revisionId, newPagePath });
 };
 

+ 9 - 10
apps/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx

@@ -1,11 +1,13 @@
+import type { ForwardRefRenderFunction } from 'react';
 import React, {
-  Fragment, useState, useCallback, useRef, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
+  Fragment, useState, useCallback, forwardRef, useRef, useImperativeHandle,
 } from 'react';
 
+import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 import { useTranslation } from 'react-i18next';
 
-import { IClearable } from '~/client/interfaces/clearable';
+import type { IClearable } from '~/client/interfaces/clearable';
 import { useSWRxUsernames } from '~/stores/user';
 
 
@@ -30,7 +32,7 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
   const { onChange } = props;
   const { t } = useTranslation();
 
-  const typeaheadRef = useRef<IClearable>(null);
+  const typeaheadRef = useRef<TypeaheadRef>(null);
 
   /*
    * State
@@ -41,11 +43,11 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
    * Fetch
    */
   const requestOptions = { isIncludeActiveUser: true, isIncludeInactiveUser: true, isIncludeActivitySnapshotUser: true };
-  const { data: usernameData, error } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
+  const { data: usernameData, error, isLoading: _isLoading } = useSWRxUsernames(searchKeyword, 0, 5, requestOptions);
   const activeUsernames = usernameData?.activeUser?.usernames != null ? usernameData.activeUser.usernames : [];
   const inactiveUsernames = usernameData?.inactiveUser?.usernames != null ? usernameData.inactiveUser.usernames : [];
   const activitySnapshotUsernames = usernameData?.activitySnapshotUser?.usernames != null ? usernameData.activitySnapshotUser.usernames : [];
-  const isLoading = usernameData === undefined && error == null;
+  const isLoading = _isLoading === true && error == null;
 
   const allUser: UserDataType[] = [];
   const pushToAllUser = (usernames: string[], category: CategoryType) => {
@@ -59,10 +61,8 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
    * Functions
    */
   const changeHandler = useCallback((userData: UserDataType[]) => {
-    if (onChange != null) {
-      const usernames = userData.map(user => user.username);
-      onChange(usernames);
-    }
+    const usernames = userData.map(user => user.username);
+    onChange(usernames);
   }, [onChange]);
 
   const searchHandler = useCallback((text: string) => {
@@ -120,7 +120,6 @@ const SearchUsernameTypeaheadSubstance: ForwardRefRenderFunction<IClearable, Pro
         delay={400}
         minLength={0}
         placeholder={t('admin:audit_log_management.username')}
-        caseSensitive={false}
         isLoading={isLoading}
         options={allUser}
         onSearch={searchHandler}

+ 1 - 1
apps/app/src/components/Admin/Common/AdminNavigation.tsx

@@ -46,7 +46,7 @@ const MenuLink = ({
 }: MenuLinkProps) => {
 
   const pageTransitionClassName = isListGroupItems
-    ? 'list-group-item list-group-item-action border-0 round-corner'
+    ? 'list-group-item list-group-item-action rounded border-0'
     : 'dropdown-item px-3 py-2';
 
   const href = isRoot ? '/admin' : urljoin('/admin', menu);

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

@@ -87,10 +87,9 @@ class GitHubSecurityManagementContents extends React.Component {
             <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
             {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
-                <i
-                  className="icon-exclamation"
-                  // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
+                <span className="material-symbols-outlined">error</span>
+                <span // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}
@@ -172,7 +171,7 @@ class GitHubSecurityManagementContents extends React.Component {
 
         <div style={{ minHeight: '300px' }}>
           <h4>
-            <i className="icon-question" aria-hidden="true"></i>
+            <span className="material-symbols-outlined" aria-hidden="true">help</span>
             <a href="#collapseHelpForGitHubOauth" data-bs-toggle="collapse"> {t('security_settings.OAuth.how_to.github')}</a>
           </h4>
           <ol id="collapseHelpForGitHubOauth" className="collapse">

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

@@ -85,10 +85,10 @@ class GoogleSecurityManagementContents extends React.Component {
             <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
             {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
-                <i
-                  className="icon-exclamation"
-                  // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
+                <span className="material-symbols-outlined">error</span>
+                <span
+                // eslint-disable-next-line max-len
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}

+ 6 - 6
apps/app/src/components/Admin/Security/OidcSecuritySettingContents.jsx

@@ -79,10 +79,10 @@ class OidcSecurityManagementContents extends React.Component {
             <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
             {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
-                <i
-                  className="icon-exclamation"
+                <span className="material-symbols-outlined">error</span>
+                <span
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}
@@ -375,10 +375,10 @@ class OidcSecurityManagementContents extends React.Component {
                 <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'OAuth' })}</p>
                 {(siteUrl == null || siteUrl === '') && (
                   <div className="alert alert-danger">
-                    <i
-                      className="icon-exclamation"
+                    <span className="material-symbols-outlined">error</span>
+                    <span
                       // eslint-disable-next-line max-len
-                      dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
+                      dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
                     />
                   </div>
                 )}

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

@@ -96,10 +96,10 @@ class SamlSecurityManagementContents extends React.Component {
             <p className="form-text text-muted small">{t('security_settings.desc_of_callback_URL', { AuthName: 'SAML Identity' })}</p>
             {(siteUrl == null || siteUrl === '') && (
               <div className="alert alert-danger">
-                <i
-                  className="icon-exclamation"
+                <span className="material-symbols-outlined">error</span>
+                <span
                   // eslint-disable-next-line max-len
-                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<i class="icon-login"></i></a>`, ns: 'commons' }) }}
+                  dangerouslySetInnerHTML={{ __html: t('alert.siteUrl_is_not_set', { link: `<a href="/admin/app">${t('headers.app_settings', { ns: 'commons' })}<span class="material-symbols-outlined">login</span></a>`, ns: 'commons' }) }}
                 />
               </div>
             )}
@@ -484,7 +484,11 @@ pWVdnzS1VCO8fKsJ7YYIr+JmHvseph3kFUOI5RqkCcMZlKUv83aUThsTHw==
                               aria-expanded="true"
                               aria-controls="ablchelp"
                             >
-                              <i className={`icon-fw ${this.state.isHelpOpened ? 'icon-arrow-down' : 'icon-arrow-right'} small`}></i> Show more...
+                              <span
+                                className="material-symbols-outlined me-1"
+                                small
+                              >{this.state.isHelpOpened ? 'expand_more' : 'chevron_right'}
+                              </span> Show more...
                             </button>
                           </h2>
                           <Collapse isOpen={this.state.isHelpOpened}>

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

@@ -81,7 +81,7 @@ const SecurityManagementContents = () => {
             href="/admin/markdown/#preventXSS"
             style={{ fontSize: 'large' }}
           >
-            <i className="fa-fw icon-login"></i> {t('security_settings.xss_prevent_setting_link')}
+            <span className="material-symbols-outlined me-1">login</span> {t('security_settings.xss_prevent_setting_link')}
           </Link>
         </div>
       </div>

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

@@ -303,7 +303,7 @@ class SecuritySetting extends React.Component {
                     <div className="pb-4">
                       <p className="card custom-card">
                         <span className="text-warning">
-                          <i className="icon-info"></i>
+                          <span className="material-symbols-outlined">info</span>
                           {/* eslint-disable-next-line react/no-danger */}
                           <span dangerouslySetInnerHTML={{ __html: t('security_settings.page_delete_rights_caution') }} />
                         </span>
@@ -368,11 +368,11 @@ class SecuritySetting extends React.Component {
             <tbody>
               <tr>
                 <th scope="row">{ t('public') }</th>
-                <td><i className="icon-fw icon-check text-success"></i>{ t('security_settings.always_displayed') }</td>
+                <td><span className="material-symbols-outlined text-success me-1">check_circle</span>{ t('security_settings.always_displayed') }</td>
               </tr>
               <tr>
                 <th scope="row">{ t('anyone_with_the_link') }</th>
-                <td><i className="icon-fw icon-ban text-danger"></i>{ t('security_settings.always_hidden') }</td>
+                <td><span className="material-symbols-outlined text-danger me-1">cancel</span>{ t('security_settings.always_hidden') }</td>
               </tr>
               <tr>
                 <th scope="row">{ t('only_me') }</th>
@@ -444,8 +444,8 @@ class SecuritySetting extends React.Component {
             </div>
             {adminGeneralSecurityContainer.isWikiModeForced && (
               <p className="alert alert-warning mt-2 col-6">
-                <i className="icon-exclamation icon-fw">
-                </i><b>FIXED</b><br />
+                <span className="material-symbols-outlined me-1">error</span>
+                <b>FIXED</b><br />
                 <b
                   dangerouslySetInnerHTML={{
                     __html: t('security_settings.Fixed by env var',
@@ -526,7 +526,7 @@ class SecuritySetting extends React.Component {
             <p className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('security_settings.max_age_desc') }} />
             <p className="card custom-card">
               <span className="text-warning">
-                <i className="icon-info"></i> {t('security_settings.max_age_caution')}
+                <span className="material-symbols-outlined">info</span> {t('security_settings.max_age_caution')}
               </span>
             </p>
           </div>

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

@@ -15,14 +15,14 @@ const ProxyCircle = () => (
 
 const BridgeCore = (props) => {
   const {
-    description, iconClass, hrClass, withProxy,
+    description, iconClass, iconName, hrClass, withProxy,
   } = props;
 
   return (
     <>
       <div id="grw-bridge-container" className={`grw-bridge-container ${withProxy ? 'with-proxy' : ''}`}>
         <p className={`${withProxy ? 'mt-0' : 'mt-2'}`}>
-          <i className={iconClass} />
+          <span className={iconClass}>{iconName}</span>
           <small
             className="ms-2 d-none d-lg-inline"
             // eslint-disable-next-line react/no-danger
@@ -47,6 +47,7 @@ const BridgeCore = (props) => {
 BridgeCore.propTypes = {
   description: PropTypes.string.isRequired,
   iconClass: PropTypes.string.isRequired,
+  iconName: PropTypes.string.isRequired,
   hrClass: PropTypes.string.isRequired,
   withProxy: PropTypes.bool,
 };
@@ -58,24 +59,28 @@ const Bridge = (props) => {
 
   let description;
   let iconClass;
+  let iconName;
   let hrClass;
 
   // empty or all failed
   if (totalCount === 0 || errorCount === totalCount) {
     description = t('admin:slack_integration.integration_sentence.integration_is_not_complete');
-    iconClass = 'icon-info text-danger';
+    iconClass = 'material-symbols-outlined text-danger';
+    iconName = 'info';
     hrClass = 'border-danger admin-border-failed';
   }
   // all green
   else if (errorCount === 0) {
     description = t('admin:slack_integration.integration_sentence.integration_successful');
     iconClass = 'fa fa-check text-success';
+    iconName = '';
     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 = '';
     hrClass = 'border-warning admin-border-failed';
   }
 
@@ -83,6 +88,7 @@ const Bridge = (props) => {
     <BridgeCore
       description={description}
       iconClass={iconClass}
+      iconName={iconName}
       hrClass={hrClass}
       withProxy={withProxy}
     />

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

@@ -142,7 +142,7 @@ const CustomBotWithoutProxySettingsAccordion = (props) => {
       >
         <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
         <p className="text-center text-warning">
-          <i className="icon-info">{t('admin:slack_integration.accordion.test_connection_only_public_channel')}</i>
+          <span className="material-symbols-outlined">info</span>{t('admin:slack_integration.accordion.test_connection_only_public_channel')}
         </p>
         <div className="d-flex justify-content-center">
           <form className="align-items-center" onSubmit={e => submitForm(e)}>

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

@@ -211,7 +211,7 @@ const SlackIntegration = () => {
         <h2 className="admin-setting-header mb-4">
           {t('admin:slack_integration.selecting_bot_types.slack_bot')}
           <a className="ms-2 btn-link small" href={t('admin:slack_integration.docs_url.slack_integration')} target="_blank" rel="noopener noreferrer">
-            <i className="icon icon-question ms-1" aria-hidden="true"></i>
+            <span className="material-symbols-outlined ms-1" aria-hidden="true">help</span>
           </a>
         </h2>
 

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

@@ -240,7 +240,7 @@ const TestProcess = ({
     <>
       <p className="text-center m-4">{t('admin:slack_integration.accordion.test_connection_by_pressing_button')}</p>
       <p className="text-center text-warning">
-        <i className="icon-info">{t('admin:slack_integration.accordion.test_connection_only_public_channel')}</i>
+        <span className="material-symbols-outlined me-1">info</span>{t('admin:slack_integration.accordion.test_connection_only_public_channel')}
       </p>
       <div className="d-flex justify-content-center">
         <form className="justify-content-center" onSubmit={e => submitForm(e)}>

+ 2 - 3
apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useState, useEffect, useCallback,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
 
 import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';

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

@@ -1,13 +1,12 @@
-import React, {
-  FC, useState, useEffect,
-} from 'react';
+import type { FC } from 'react';
+import React, { useState, useEffect } from 'react';
 
 import type { IUserGroupHasId, IUserGroupRelation, IUserHasId } from '@growi/core';
 import dateFnsFormat from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
-import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 
 
 type Props = {
@@ -206,11 +205,11 @@ export const UserGroupTable: FC<Props> = ({
                           className="btn btn-outline-secondary btn-sm dropdown-toggle"
                           data-bs-toggle="dropdown"
                         >
-                          <i className="icon-settings"></i>
+                          <span className="material-symbols-outlined fs-5">settings</span>
                         </button>
                         <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${group._id}`}>
                           <button className="dropdown-item" type="button" role="button" onClick={onClickEdit} data-user-group-id={group._id}>
-                            <i className="icon-fw icon-note"></i> {t('Edit')}
+                            <span className="material-symbols-outlined me-1">edit_square</span> {t('Edit')}
                           </button>
                           {onRemove != null
                           && (

+ 4 - 3
apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -1,4 +1,5 @@
-import React, { FC, useState } from 'react';
+import type { FC } from 'react';
+import React, { useState } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -27,7 +28,7 @@ export const UpdateParentConfirmModal: FC = () => {
   return (
     <Modal className="modal-md" isOpen={isOpened} toggle={closeModal}>
       <ModalHeader tag="h4" toggle={closeModal} className="bg-warning text-light">
-        <i className="icon icon-warning"></i> {t('admin:user_group_management.update_parent_confirm_modal.header')}
+        <span className="material-symbols-outlined">warning</span> {t('admin:user_group_management.update_parent_confirm_modal.header')}
       </ModalHeader>
       {
         targetGroup != null && updateData != null ? (
@@ -39,7 +40,7 @@ export const UpdateParentConfirmModal: FC = () => {
                 {t('admin:user_group_management.update_parent_confirm_modal.caution_change_parent', { groupName: targetGroup.name })}
               </div>
               <div className="text-danger mb-3">
-                <i className="icon-exclamation"></i>
+                <span className="material-symbols-outlined">error</span>
                 {t('admin:user_group_management.update_parent_confirm_modal.danger_message')}
               </div>
 

+ 0 - 169
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -1,169 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui/dist/components';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { AsyncTypeahead } from 'react-bootstrap-typeahead';
-import { debounce } from 'throttle-debounce';
-
-import { toastSuccess, toastError } from '~/client/util/toastr';
-import Xss from '~/services/xss';
-
-class UserGroupUserFormByInput extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      keyword: '',
-      inputUser: '',
-      applicableUsers: [],
-      isLoading: false,
-      searchError: null,
-    };
-
-    this.xss = new Xss();
-
-    this.addUserBySubmit = this.addUserBySubmit.bind(this);
-    this.validateForm = this.validateForm.bind(this);
-    this.handleChange = this.handleChange.bind(this);
-    this.handleSearch = this.handleSearch.bind(this);
-    this.onKeyDown = this.onKeyDown.bind(this);
-    this.renderMenuItemChildren = this.renderMenuItemChildren.bind(this);
-
-    this.searhApplicableUsersDebounce = debounce(1000, this.searhApplicableUsers);
-  }
-
-  async addUserBySubmit() {
-    const { userGroup, onClickAddUserBtn } = this.props;
-
-    if (this.state.inputUser.length === 0) { return }
-    const userName = this.state.inputUser[0].username;
-
-    try {
-      await onClickAddUserBtn(userName);
-      toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
-      this.setState({ inputUser: '' });
-    }
-    catch (err) {
-      toastError(new Error(`Unable to add "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`));
-    }
-
-
-  }
-
-  validateForm() {
-    return this.state.inputUser !== '';
-  }
-
-  async searhApplicableUsers() {
-    const { onSearchApplicableUsers } = this.props;
-
-    try {
-      const users = await onSearchApplicableUsers(this.state.keyword);
-      this.setState({ applicableUsers: users, isLoading: false });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  /**
-   * Reflect when forecast is clicked
-   * @param {object} inputUser
-   */
-  handleChange(inputUser) {
-    this.setState({ inputUser });
-  }
-
-  handleSearch(keyword) {
-    if (keyword === '') {
-      return;
-    }
-
-    this.setState({ keyword, isLoading: true });
-    this.searhApplicableUsersDebounce();
-  }
-
-  onKeyDown(event) {
-    // 13 is Enter key
-    if (event.keyCode === 13) {
-      this.addUserBySubmit();
-    }
-  }
-
-  renderMenuItemChildren(option) {
-    const { isAlsoNameSearched, isAlsoMailSearched } = this.props;
-    const user = option;
-    return (
-      <>
-        <UserPicture user={user} size="sm" noLink noTooltip />
-        <strong className="ms-2">{user.username}</strong>
-        {isAlsoNameSearched && <span className="ms-2">{user.name}</span>}
-        {isAlsoMailSearched && <span className="ms-2">{user.email}</span>}
-      </>
-    );
-  }
-
-  getEmptyLabel() {
-    return (this.state.searchError !== null) && 'Error on searching.';
-  }
-
-  render() {
-    const { t } = this.props;
-
-    const inputProps = { autoComplete: 'off' };
-
-    return (
-      <div className="row">
-        <div className="col-8 pe-0">
-          <AsyncTypeahead
-            {...this.props}
-            id="name-typeahead-asynctypeahead"
-            ref={(c) => { this.typeahead = c }}
-            inputProps={inputProps}
-            isLoading={this.state.isLoading}
-            labelKey={user => `${user.username} ${user.name} ${user.email}`}
-            minLength={0}
-            options={this.state.applicableUsers} // Search result
-            searchText={(this.state.isLoading ? 'Searching...' : this.getEmptyLabel())}
-            renderMenuItemChildren={this.renderMenuItemChildren}
-            align="left"
-            onChange={this.handleChange}
-            onSearch={this.handleSearch}
-            onKeyDown={this.onKeyDown}
-            caseSensitive={false}
-            clearButton
-          />
-        </div>
-        <div className="col-2 ps-0">
-          <button
-            type="button"
-            className="btn btn-success"
-            disabled={!this.validateForm()}
-            onClick={this.addUserBySubmit}
-          >
-            {t('add')}
-          </button>
-        </div>
-      </div>
-    );
-  }
-
-}
-
-UserGroupUserFormByInput.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  isAlsoMailSearched: PropTypes.bool.isRequired,
-  isAlsoNameSearched: PropTypes.bool.isRequired,
-  onClickAddUserBtn: PropTypes.func,
-  onSearchApplicableUsers: PropTypes.func,
-  userGroup: PropTypes.object,
-};
-
-const UserGroupUserFormByInputWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserFormByInput t={t} {...props} />;
-};
-
-export default UserGroupUserFormByInputWrapperFC;

+ 122 - 0
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx

@@ -0,0 +1,122 @@
+import type { FC, KeyboardEvent } from 'react';
+import React, { useState, useRef } from 'react';
+
+import type { IUserGroupHasId, IUserHasId } from '@growi/core';
+import { UserPicture } from '@growi/ui/dist/components';
+import { useTranslation } from 'next-i18next';
+import { AsyncTypeahead } from 'react-bootstrap-typeahead';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import type { SearchType } from '~/interfaces/user-group';
+import Xss from '~/services/xss';
+
+type Props = {
+  userGroup: IUserGroupHasId,
+  onClickAddUserBtn: (username: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>,
+  isAlsoNameSearched: boolean,
+  isAlsoMailSearched: boolean,
+  searchType: SearchType,
+}
+
+export const UserGroupUserFormByInput: FC<Props> = (props) => {
+  const {
+    userGroup, onClickAddUserBtn, onSearchApplicableUsers, isAlsoNameSearched, isAlsoMailSearched, searchType,
+  } = props;
+
+  const { t } = useTranslation();
+  const typeaheadRef = useRef(null);
+  const [inputUser, setInputUser] = useState<IUserHasId[]>([]);
+  const [applicableUsers, setApplicableUsers] = useState<IUserHasId[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isSearchError, setIsSearchError] = useState(false);
+
+  const xss = new Xss();
+
+  const addUserBySubmit = async() => {
+    if (inputUser.length === 0) { return }
+    const userName = inputUser[0].username;
+
+    try {
+      await onClickAddUserBtn(userName);
+      toastSuccess(`Added "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`);
+      setInputUser([]);
+    }
+    catch (err) {
+      toastError(new Error(`Unable to add "${xss.process(userName)}" to "${xss.process(userGroup.name)}"`));
+    }
+  };
+
+  const searchApplicableUsers = async(keyword: string) => {
+    try {
+      const users = await onSearchApplicableUsers(keyword);
+      setApplicableUsers(users);
+      setIsLoading(false);
+    }
+    catch (err) {
+      setIsSearchError(true);
+      toastError(err);
+    }
+  };
+
+  const handleChange = (inputUser: IUserHasId[]) => {
+    setInputUser(inputUser);
+  };
+
+  const handleSearch = async(keyword: string) => {
+    setIsLoading(true);
+    await searchApplicableUsers(keyword);
+  };
+
+  const onKeyDown = (event: KeyboardEvent) => {
+    if (event.key === 'Enter') {
+      addUserBySubmit();
+    }
+  };
+
+  const renderMenuItemChildren = (option: IUserHasId) => {
+    const user = option;
+
+    return (
+      <>
+        <UserPicture user={user} size="sm" noLink noTooltip />
+        <strong className="ms-2">{user.username}</strong>
+        {isAlsoNameSearched && <span className="ms-2">{user.name}</span>}
+        {isAlsoMailSearched && <span className="ms-2">{user.email}</span>}
+      </>
+    );
+  };
+
+  return (
+    <div className="row">
+      <div className="col-8 pe-0">
+        <AsyncTypeahead
+          key={`${searchType}-${isAlsoNameSearched}-${isAlsoMailSearched}`} // The searched keywords are not re-searched, so re-rendered by key.
+          id="name-typeahead-asynctypeahead"
+          inputProps={{ autoComplete: 'off' }}
+          isLoading={isLoading}
+          labelKey={(user: IUserHasId) => `${user.username} ${user.name} ${user.email}`}
+          options={applicableUsers} // Search result
+          onSearch={handleSearch}
+          onChange={handleChange}
+          onKeyDown={onKeyDown}
+          minLength={1}
+          searchText={isLoading ? 'Searching...' : (isSearchError && 'Error on searching.')}
+          renderMenuItemChildren={renderMenuItemChildren}
+          align="left"
+          clearButton
+        />
+      </div>
+      <div className="col-2 ps-0">
+        <button
+          type="button"
+          className="btn btn-success"
+          disabled={inputUser.length === 0}
+          onClick={addUserBySubmit}
+        >
+          {t('add')}
+        </button>
+      </div>
+    </div>
+  );
+};

+ 6 - 5
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -1,16 +1,17 @@
 import React from 'react';
 
-import type { IUserGroupHasId } from '@growi/core';
+import type { IUserGroupHasId, IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import {
   Modal, ModalHeader, ModalBody,
 } from 'reactstrap';
 
-import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import type { SearchType } from '~/interfaces/user-group';
+import { SearchTypes } from '~/interfaces/user-group';
 
 import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
 import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
-import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+import { UserGroupUserFormByInput } from './UserGroupUserFormByInput';
 
 type Props = {
   isOpen: boolean,
@@ -19,7 +20,7 @@ type Props = {
   isAlsoMailSearched: boolean,
   isAlsoNameSearched: boolean,
   onClickAddUserBtn: (username: string) => Promise<void>,
-  onSearchApplicableUsers: (searchWord: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<IUserHasId[]>,
   onSwitchSearchType: (searchType: SearchType) => void
   onClose: () => void,
   onToggleIsAlsoMailSearched: () => void,
@@ -54,9 +55,9 @@ const UserGroupUserModal = (props: Props): JSX.Element => {
             userGroup={userGroup}
             onClickAddUserBtn={onClickAddUserBtn}
             onSearchApplicableUsers={onSearchApplicableUsers}
-            onClose={onClose}
             isAlsoNameSearched={isAlsoNameSearched}
             isAlsoMailSearched={isAlsoMailSearched}
+            searchType={searchType}
           />
         </div>
         <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>

+ 3 - 3
apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -52,9 +52,9 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
                       type="button"
                       id={`admin-group-menu-button-${relatedUser._id}`}
                       className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                      data-toggle="dropdown"
+                      data-bs-toggle="dropdown"
                     >
-                      <i className="icon-settings"></i>
+                      <span className="material-symbols-outlined fs-5">settings</span>
                     </button>
                     <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
                       <button
@@ -62,7 +62,7 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
                         type="button"
                         onClick={() => props.onClickRemoveUserBtn(relatedUser.username)}
                       >
-                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                        <span className="material-symbols-outlined me-1">person_remove</span>{t('admin:user_group_management.remove_from_group')}
                       </button>
                     </div>
                   </div>

+ 1 - 1
apps/app/src/components/Admin/UserManagement.module.scss

@@ -12,7 +12,7 @@
   }
   .search-clear {
     position: absolute;
-    top: 12px;
+    top: 15px;
     right: 1px;
     z-index: 3;
     width: 24px;

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

@@ -149,15 +149,16 @@ const UserManagement = (props: UserManagementProps) => {
               {
                 adminUsersContainer.state.searchText.length > 0
                   ? (
-                    <i
-                      className="icon-close search-clear"
+                    <span
+                      className="material-symbols-outlined me-1 search-clear"
                       onClick={async() => {
                         await adminUsersContainer.clearSearchText();
                         if (inputRef.current != null) {
                           inputRef.current.value = '';
                         }
                       }}
-                    />
+                    >cancel
+                    </span>
                   )
                   : ''
               }

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

@@ -63,7 +63,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
                   data-html="true"
                   title={t('user_management.password_setting_help')}
                 >
-                  <small><i className="icon-question" aria-hidden="true"></i></small>
+                  <small><span className="material-symbols-outlined" aria-hidden="true">help</span></small>
                 </span>
               </div>
             </th>
@@ -92,7 +92,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
                 <td>
                   <div className="btn-group admin-user-menu">
                     <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
-                      <i className="icon-settings"></i> <span className="caret"></span>
+                      <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
                     </button>
                     <ul className="dropdown-menu" role="menu">
                       <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>

+ 1 - 1
apps/app/src/components/Admin/Users/GrantAdminButton.tsx

@@ -30,7 +30,7 @@ const GrantAdminButton = (props: GrantAdminButtonProps): JSX.Element => {
 
   return (
     <button className="dropdown-item" type="button" onClick={() => onClickGrantAdminBtnHandler()}>
-      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.grant_admin_access')}
+      <span className="material-symbols-outlined me-1">person_add</span>{t('user_management.user_table.grant_admin_access')}
     </button>
   );
 

+ 1 - 1
apps/app/src/components/Admin/Users/GrantReadOnlyButton.tsx

@@ -26,7 +26,7 @@ const GrantReadOnlyButton: React.FC<{
 
   return (
     <button className="dropdown-item" type="button" onClick={onClickGrantReadOnlyBtnHandler}>
-      <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.grant_read_only_access')}
+      <span className="material-symbols-outlined me-1">person_add</span>{t('user_management.user_table.grant_read_only_access')}
     </button>
   );
 };

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

@@ -33,7 +33,7 @@ const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
   const renderRevokeAdminBtn = () => {
     return (
       <button className="dropdown-item" type="button" onClick={() => onClickRevokeAdminBtnHandler()}>
-        <i className="icon-fw icon-user-unfollow"></i>{t('user_management.user_table.revoke_admin_access')}
+        <span className="material-symbols-outlined me-1">person_remove</span>{t('user_management.user_table.revoke_admin_access')}
       </button>
     );
   };
@@ -41,7 +41,7 @@ const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
   const renderRevokeAdminAlert = () => {
     return (
       <div className="px-4">
-        <i className="icon-fw icon-user-unfollow mb-2"></i>{t('user_management.user_table.revoke_admin_access')}
+        <span className="material-symbols-outlined me-1 mb-2">person_remove</span>{t('user_management.user_table.revoke_admin_access')}
         <p className="alert alert-danger">{t('user_management.user_table.cannot_revoke')}</p>
       </div>
     );

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

@@ -15,7 +15,7 @@ const RevokeAdminAlert = React.memo((): JSX.Element => {
 
   return (
     <div className="px-4">
-      <i className="icon-fw icon-user-unfollow mb-2"></i>{t('admin:user_management.user_table.revoke_admin_access')}
+      <span className="material-symbols-outlined me-1 mb-2">person_remove</span>{t('admin:user_management.user_table.revoke_admin_access')}
       <p className="alert alert-danger">{t('admin:user_management.user_table.cannot_revoke')}</p>
     </div>
   );
@@ -49,7 +49,7 @@ const RevokeAdminMenuItem = (props: Props): JSX.Element => {
   return user.username !== currentUser?.username
     ? (
       <button className="dropdown-item" type="button" onClick={clickRevokeAdminBtnHandler}>
-        <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.revoke_admin_access')}
+        <span className="material-symbols-outlined me-1">person_remove</span> {t('user_management.user_table.revoke_admin_access')}
       </button>
     )
     : <RevokeAdminAlert />;

+ 1 - 1
apps/app/src/components/Admin/Users/RevokeReadOnlyMenuItem.tsx

@@ -26,7 +26,7 @@ const RevokeReadOnlyMenuItem: React.FC<{
 
   return (
     <button className="dropdown-item" type="button" onClick={clickRevokeReadOnlyBtnHandler}>
-      <i className="icon-fw icon-user-unfollow"></i> {t('user_management.user_table.revoke_read_only_access')}
+      <span className="material-symbols-outlined me-1">person_remove</span> {t('user_management.user_table.revoke_read_only_access')}
     </button>
   );
 };

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

@@ -38,7 +38,7 @@ const SendInvitationEmailButton = (props) => {
 
   return (
     <button className={`dropdown-item ${textColor}`} type="button" onClick={() => { onClickSendInvitationEmailButton() }}>
-      <i className="icon-fw icon-envelope"></i>
+      <span className="material-symbols-outlined me-1">mail</span>
       {isInvitationEmailSended && (<>{t('admin:user_management.user_table.resend_invitation_email')}</>)}
       {!isInvitationEmailSended && (<>{t('admin:user_management.user_table.send_invitation_email')}</>)}
     </button>

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

@@ -33,7 +33,7 @@ class StatusActivateButton extends React.Component {
 
     return (
       <button className="dropdown-item" type="button" onClick={() => { this.onClickAcceptBtn() }}>
-        <i className="icon-fw icon-user-following"></i> {t('user_management.user_table.accept')}
+        <span className="material-symbols-outlined me-1">person_add</span>{t('user_management.user_table.accept')}
       </button>
     );
   }

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

@@ -14,7 +14,7 @@ const SuspendAlert = React.memo((): JSX.Element => {
 
   return (
     <div className="px-4">
-      <i className="icon-fw icon-ban mb-2"></i>{t('admin:user_management.user_table.deactivate_account')}
+      <span className="material-symbols-outlined me-1 mb-2">cancel</span>{t('admin:user_management.user_table.deactivate_account')}
       <p className="alert alert-danger">{t('admin:user_management.user_table.your_own')}</p>
     </div>
   );
@@ -47,7 +47,7 @@ const StatusSuspendMenuItem = (props: Props): JSX.Element => {
   return user.username !== currentUser?.username
     ? (
       <button className="dropdown-item" type="button" onClick={clickDeactiveBtnHandler}>
-        <i className="icon-fw icon-ban"></i> {t('user_management.user_table.deactivate_account')}
+        <span className="material-symbols-outlined me-1">cancel</span> {t('user_management.user_table.deactivate_account')}
       </button>
     )
     : <SuspendAlert />;

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

@@ -48,7 +48,7 @@ const UserMenu = (props: UserMenuProps) => {
         <li className="dropdown-header">{t('user_management.user_table.edit_menu')}</li>
         <li>
           <button className="dropdown-item" type="button" onClick={onClickPasswordResetHandler}>
-            <i className="icon-fw icon-key"></i>{ t('user_management.reset_password') }
+            <span className="material-symbols-outlined me-1">key</span>{ t('user_management.reset_password') }
           </button>
         </li>
       </>
@@ -95,7 +95,8 @@ const UserMenu = (props: UserMenuProps) => {
   return (
     <UncontrolledDropdown id="userMenu" size="sm">
       <DropdownToggle caret color="secondary" outline>
-        <i className="icon-settings" />
+        {/* 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']}`} />}
       </DropdownToggle>

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

@@ -23,7 +23,7 @@ export const BookmarkFolderItemControl: React.FC<{
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
       { children ?? (
         <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
-          <i className="icon-options"></i>
+          <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
       ) }
       <DropdownMenu
@@ -43,7 +43,7 @@ export const BookmarkFolderItemControl: React.FC<{
           onClick={onClickRename}
           className="grw-page-control-dropdown-item"
         >
-          <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
+          <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
           {t('Rename')}
         </DropdownItem>
 
@@ -53,7 +53,7 @@ export const BookmarkFolderItemControl: React.FC<{
           className="pt-2 grw-page-control-dropdown-item text-danger"
           onClick={onClickDelete}
         >
-          <span className="material-symbols-outlined grw-page-control-dropdown-icon">delete</span>
+          <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
           {t('Delete')}
         </DropdownItem>
       </DropdownMenu>

+ 0 - 4
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -24,10 +24,6 @@ $grw-bookmark-item-padding-left: 35px;
 
 .grw-foldertree :global {
 
-  .btn-page-item-control .icon-plus::before {
-    font-size: 18px;
-  }
-
   .list-group-item {
     .grw-visible-on-hover {
       display: none;

+ 4 - 3
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -14,7 +14,8 @@ import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { BookmarkFolderItems, DragItemDataType, DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
+import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { usePutBackPageModal } from '~/stores/modal';
 import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
 
@@ -180,7 +181,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
               : undefined}
           >
             <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
-              <i className="icon-options fa fa-rotate-90 p-1"></i>
+              <span className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
           </PageItemControl>
         </div>
@@ -191,7 +192,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          {formerPagePath !== null ? `${formerPagePath}/` : '/'}
+          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
       </li>
     </DragAndDropWrapper>

+ 25 - 18
apps/app/src/components/Common/ClosableTextInput.tsx

@@ -4,6 +4,7 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import AutosizeInput from 'react-input-autosize';
 
 import type { AlertInfo } from '~/client/util/input-validator';
 import { AlertType, inputValidator } from '~/client/util/input-validator';
@@ -12,10 +13,12 @@ type ClosableTextInputProps = {
   value?: string
   placeholder?: string
   validationTarget?: string,
+  useAutosizeInput?: boolean
+  inputClassName?: string,
   onPressEnter?(inputText: string | null): void
   onPressEscape?: () => void
   onClickOutside?(): void
-  handleInputChange?: (string) => void
+  onChange?(inputText: string): void
 }
 
 const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextInputProps) => {
@@ -43,7 +46,7 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     setInputText(inputText);
     setIsAbleToShowAlert(true);
 
-    props.handleInputChange?.(inputText);
+    props.onChange?.(inputText);
   };
 
   const onFocusHandler = async(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -115,25 +118,29 @@ const ClosableTextInput: FC<ClosableTextInputProps> = memo((props: ClosableTextI
     );
   };
 
+  const inputProps = {
+    'data-testid': 'closable-text-input',
+    value: inputText || '',
+    ref: inputRef,
+    type: 'text',
+    placeholder: props.placeholder,
+    name: 'input',
+    onFocus: onFocusHandler,
+    onChange: onChangeHandler,
+    onKeyDown: onKeyDownHandler,
+    onCompositionStart: () => setComposing(true),
+    onCompositionEnd: () => setComposing(false),
+    onBlur: onBlurHandler,
+  };
+
+  const inputClassName = `form-control ${props.inputClassName ?? ''}`;
 
   return (
     <div>
-      <input
-        value={inputText || ''}
-        ref={inputRef}
-        type="text"
-        className="form-control"
-        placeholder={props.placeholder}
-        name="input"
-        data-testid="closable-text-input"
-        onFocus={onFocusHandler}
-        onChange={onChangeHandler}
-        onKeyDown={onKeyDownHandler}
-        onCompositionStart={() => setComposing(true)}
-        onCompositionEnd={() => setComposing(false)}
-        onBlur={onBlurHandler}
-        autoFocus={false}
-      />
+      { props.useAutosizeInput
+        ? <AutosizeInput inputClassName={inputClassName} {...inputProps} />
+        : <input className={inputClassName} {...inputProps} />
+      }
       {isAbleToShowAlert && <AlertInfo />}
     </div>
   );

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

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 type CountProps = {
   count?: number,

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

@@ -11,7 +11,7 @@ import {
 } from 'reactstrap';
 
 import { NotAvailableForGuest } from '~/components/NotAvailableForGuest';
-import { IPageOperationProcessData } from '~/interfaces/page-operation';
+import type { IPageOperationProcessData } from '~/interfaces/page-operation';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import { shouldRecoverPagePaths } from '~/utils/page-operation';
@@ -182,7 +182,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
             data-testid="open-page-move-rename-modal-btn"
             className="grw-page-control-dropdown-item"
           >
-            <i className="icon-fw icon-action-redo grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
             {t(isInstantRename ? 'Rename' : 'Move/Rename')}
           </DropdownItem>
         ) }
@@ -194,7 +194,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
             data-testid="open-page-duplicate-modal-btn"
             className="grw-page-control-dropdown-item"
           >
-            <i className="icon-fw icon-docs grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">file_copy</span>
             {t('Duplicate')}
           </DropdownItem>
         ) }
@@ -205,7 +205,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
           >
-            <i className="icon-fw icon-action-undo grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">undo</span>
             {t('modal_putback.label.Put Back Page')}
           </DropdownItem>
         ) }
@@ -223,7 +223,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
             onClick={pathRecoveryItemClickedHandler}
             className="grw-page-control-dropdown-item"
           >
-            <i className="icon-fw icon-wrench grw-page-control-dropdown-icon"></i>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">build</span>
             {t('PathRecovery')}
           </DropdownItem>
         ) }
@@ -239,7 +239,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
               onClick={deleteItemClickedHandler}
               data-testid="open-page-delete-modal-btn"
             >
-              <i className="icon-fw icon-trash grw-page-control-dropdown-icon"></i>
+              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
               {t('Delete')}
             </DropdownItem>
           </>

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

@@ -3,7 +3,7 @@ import React, { memo, useCallback } from 'react';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
-import LinkedPagePath from '../../../models/linked-page-path';
+import type LinkedPagePath from '../../../models/linked-page-path';
 
 import styles from './PagePathHierarchicalLink.module.scss';
 
@@ -51,7 +51,8 @@ export const PagePathHierarchicalLink = memo((props: PagePathHierarchicalLinkPro
         <RootElm>
           <span className="path-segment">
             <Link href="/" prefetch={false}>
-              <i className="icon-home"></i>
+              {/* TODO: Size adjust */}
+              <span className="material-symbols-outlined">home</span>
               <span className={`separator ${styles.separator}`}>/</span>
             </Link>
           </span>

+ 5 - 0
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -6,6 +6,11 @@
   margin-right: 0.2em;
 }
 
+.grw-mx-02em {
+  margin-right: 0.2em;
+  margin-left: 0.2em;
+}
+
 .grw-page-path-nav-sticky :global {
   min-height: 75px;
 

+ 19 - 6
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -19,6 +20,7 @@ const { isTrashPage } = pagePathUtils;
 type Props = {
   pagePath: string,
   pageId?: string | null,
+  isWipPage?: boolean,
   isSingleLineMode?: boolean,
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
@@ -27,13 +29,16 @@ type Props = {
 
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
 
-const Separator = (): JSX.Element => {
+const RootSlash = (): JSX.Element => {
   return <span className={styles['grw-mr-02em']}>/</span>;
 };
+const Separator = (): JSX.Element => {
+  return <span className={styles['grw-mx-02em']}>/</span>;
+};
 
 export const PagePathNav: FC<Props> = (props: Props) => {
   const {
-    pageId, pagePath, isSingleLineMode, isCollapseParents,
+    pageId, pagePath, isWipPage, isSingleLineMode, isCollapseParents,
     formerLinkClassName, latterLinkClassName,
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
@@ -66,10 +71,15 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   else {
     const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
     const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-    formerLink = <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />;
-    latterLink = (
+    formerLink = (
       <>
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} isInTrash={isInTrash} />
         <Separator />
+      </>
+    );
+    latterLink = (
+      <>
+        <RootSlash />
         <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.former} isInTrash={isInTrash} />
       </>
     );
@@ -85,7 +95,10 @@ export const PagePathNav: FC<Props> = (props: Props) => {
           {latterLink}
         </h1>
         { pageId != null && !isNotFound && (
-          <div className="mx-2">
+          <div className="d-flex align-items-center ms-2">
+            { isWipPage && (
+              <span className="badge rounded-pill text-bg-secondary ms-1 me-1">WIP</span>
+            )}
             <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
               <i className="ti ti-clipboard"></i>
             </CopyDropdown>

+ 0 - 6
apps/app/src/components/ContentLinkButtons.module.scss

@@ -1,6 +0,0 @@
-.grw-icon-container-recently-created :global {
-  svg {
-    width: 14px;
-    height: 14px;
-  }
-}

+ 13 - 18
apps/app/src/components/ContentLinkButtons.tsx

@@ -1,22 +1,19 @@
 import React from 'react';
 
-import type { IUserHasId } from '@growi/core';
+import { USER_STATUS, type IUserHasId } from '@growi/core';
+import { useTranslation } from 'next-i18next';
 import { Link as ScrollLink } from 'react-scroll';
 
-import { RecentlyCreatedIcon } from '~/components/Icons/RecentlyCreatedIcon';
-
-import styles from './ContentLinkButtons.module.scss';
-
 const BookMarkLinkButton = React.memo(() => {
-
+  const { t } = useTranslation();
   return (
     <ScrollLink to="bookmarks-list" offset={-120}>
       <button
         type="button"
-        className="btn btn-outline-secondary btn-sm px-2"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
-        <span className="material-symbols-outlined">bookmark</span>
-        <span>Bookmarks</span>
+        <span className="material-symbols-outlined p-0">bookmark</span>
+        <span>{t('footer.bookmarks')}</span>
       </button>
     </ScrollLink>
   );
@@ -25,15 +22,15 @@ const BookMarkLinkButton = React.memo(() => {
 BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 
 const RecentlyCreatedLinkButton = React.memo(() => {
-
+  const { t } = useTranslation();
   return (
     <ScrollLink to="recently-created-list" offset={-120}>
       <button
         type="button"
-        className="btn btn-outline-secondary btn-sm px-3"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
-        <i className={`${styles['grw-icon-container-recently-created']} grw-icon-container-recently-created me-2`}><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
+        <span className="growi-custom-icons mx-1">recently_created</span>
+        <span>{t('footer.recently_created')}</span>
       </button>
     </ScrollLink>
   );
@@ -43,22 +40,20 @@ RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
 
 export type ContentLinkButtonsProps = {
-  author?: IUserHasId,
+  author: IUserHasId | null,
 }
 
 export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
-
   const { author } = props;
 
-  if (author == null || author.status === 4) {
+  if (author == null || author.status === USER_STATUS.DELETED) {
     return <></>;
   }
 
   return (
-    <div className="mt-3 d-flex justify-content-between">
+    <div className="d-grid gap-2">
       <BookMarkLinkButton />
       <RecentlyCreatedLinkButton />
     </div>
   );
-
 };

+ 13 - 8
apps/app/src/components/CreateTemplateModal.tsx

@@ -4,9 +4,9 @@ import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
-import { useOnTemplateButtonClicked } from '~/client/services/use-on-template-button-clicked';
+import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
-import { TargetType, LabelType } from '~/interfaces/template';
+import type { TargetType, LabelType } from '~/interfaces/template';
 
 
 type TemplateCardProps = {
@@ -53,18 +53,19 @@ type CreateTemplateModalProps = {
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
   path, isOpen, onClose,
 }) => {
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
 
-  const { onClickHandler: onClickTemplateButton, isPageCreating } = useOnTemplateButtonClicked(path);
+  const { createTemplate, isCreating, isCreatable } = useCreateTemplatePage();
 
   const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
     try {
-      await onClickTemplateButton(label);
+      await createTemplate?.(label);
+      onClose();
     }
     catch (err) {
-      toastError(err);
+      toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [onClickTemplateButton]);
+  }, [createTemplate, path, t]);
 
   const parentPath = pathUtils.addTrailingSlash(path);
 
@@ -73,12 +74,16 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
       <TemplateCard
         target={target}
         label={label}
-        isPageCreating={isPageCreating}
+        isPageCreating={isCreating}
         onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
     </div>
   );
 
+  if (!isCreatable) {
+    return <></>;
+  }
+
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">

+ 5 - 12
apps/app/src/components/CustomNavigation/CustomNav.module.scss

@@ -1,15 +1,3 @@
-.grw-custom-nav-tab,
-.grw-custom-nav-dropdown {
-  :global {
-    svg {
-      width: 17px;
-      height: 17px;
-      margin-right: 5px;
-      vertical-align: text-bottom;
-    }
-  }
-}
-
 .grw-custom-nav-tab :global {
   .nav-title {
     flex-wrap: nowrap;
@@ -24,4 +12,9 @@
     border-bottom: 3px solid;
     transition: 0.3s ease-in-out;
   }
+
+  .material-symbols-outlined {
+    margin-right: 6px;
+    font-size: 18px;
+  }
 }

+ 3 - 3
apps/app/src/components/CustomNavigation/CustomNav.tsx

@@ -2,12 +2,12 @@ import React, {
   useEffect, useState, useRef, useMemo, useCallback,
 } from 'react';
 
-import { Breakpoint } from '@growi/ui/dist/interfaces';
+import type { Breakpoint } from '@growi/ui/dist/interfaces';
 import {
   Nav, NavItem, NavLink,
 } from 'reactstrap';
 
-import { ICustomNavTabMappings } from '~/interfaces/ui';
+import type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 import styles from './CustomNav.module.scss';
 
@@ -49,7 +49,7 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   }, [onNavSelected]);
 
   return (
-    <div className="grw-custom-nav-dropdown btn-group">
+    <div className="btn-group">
       <button
         className="btn btn-outline-primary btn-lg dropdown-toggle text-end"
         type="button"

+ 3 - 5
apps/app/src/components/DescendantsPageListModal.tsx

@@ -13,10 +13,8 @@ import { useDescendantsPageListModal } from '~/stores/modal';
 
 import { CustomNavTab } from './CustomNavigation/CustomNav';
 import CustomTabContent from './CustomNavigation/CustomTabContent';
-import { DescendantsPageListProps } from './DescendantsPageList';
+import type { DescendantsPageListProps } from './DescendantsPageList';
 import ExpandOrContractButton from './ExpandOrContractButton';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
 
 import styles from './DescendantsPageListModal.module.scss';
 
@@ -46,7 +44,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: PageListIcon,
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
         Content: () => {
           if (status == null || status.path == null || !status.isOpened) {
             return <></>;
@@ -57,7 +55,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
-        Icon: TimeLineIcon,
+        Icon: () => <span className="material-symbols-outlined">timeline</span>,
         Content: () => {
           if (status == null || !status.isOpened) {
             return <></>;

+ 4 - 2
apps/app/src/components/ExpandOrContractButton.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 type Props = {
   isWindowExpanded: boolean,
@@ -24,9 +25,10 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
   return (
     <button
       type="button"
-      className={`btn ${isWindowExpanded ? 'icon-size-actual' : 'icon-size-fullscreen'}`}
+      className="btn material-symbols-outlined"
       onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
     >
+      {isWindowExpanded ? 'close_fullscreen' : 'open_in_full'}
     </button>
   );
 };

+ 0 - 28
apps/app/src/components/Icons/AttachmentIcon.jsx

@@ -1,28 +0,0 @@
-import React from 'react';
-
-const Attachment = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 14 14"
-    width="14px"
-    height="14px"
-  >
-    <rect width="14" height="14" fillOpacity="0" />
-    <g className="cls-1">
-      <path
-        d="M2.9,13a2,2,0,0,1-1.44-.63,2.28,2.28,0,0,1,0-3.23l7-7.38a2.48,2.48,0,0,1,1.22-.7,2.61,
-        2.61,0,0,1,1.41.09A3.46,3.46,0,0,1,12.37,2a3.94,3.94,0,0,1,.36.45A2.61,2.61,0,0,1,13,3a3.41,3.41,
-        0,0,1,.16.57,3.06,3.06,0,0,1-.82,2.75L7.07,11.86a.35.35,0,0,1-.26.13.4.4,0,0,1-.28-.1.47.47,0,0,
-        1-.12-.27.39.39,0,0,1,.11-.29l5.26-5.59a2.28,2.28,0,0,0,.65-1.62,2.07,2.07,0,0,0-.62-1.58A2.62,2.62,
-        0,0,0,11,1.93a2,2,0,0,0-1-.13,1.63,1.63,0,0,0-1,.5L2,9.67a1.52,1.52,0,0,0,0,2.16,1.28,1.28,0,0,0,
-        .44.3,1,1,0,0,0,.51.08,1.43,1.43,0,0,0,1-.49L9.49,5.84l.12-.13.11-.15a1.24,1.24,0,0,0,.1-.2,1.94,
-        1.94,0,0,0,0-.2.6.6,0,0,0,0-.22.66.66,0,0,0-.14-.2.57.57,0,0,0-.45-.22,1,1,0,0,0-.52.3L4.56,
-        9.25a.42.42,0,0,1-.17.1.34.34,0,0,1-.2,0A.4.4,0,0,1,4,9.26.34.34,0,0,1,3.89,9,.41.41,0,0,1,4,8.72L8.16,
-        4.28a1.7,1.7,0,0,1,1-.53,1.32,1.32,0,0,1,1.06.43,1.23,1.23,0,0,1,.4,1.05,1.8,1.8,0,0,1-.58,1.14L4.52,
-        12.26A2.3,2.3,0,0,1,3,13H2.9Z"
-      />
-    </g>
-  </svg>
-);
-
-export default Attachment;

+ 0 - 22
apps/app/src/components/Icons/HistoryIcon.jsx

@@ -1,22 +0,0 @@
-import React from 'react';
-
-const RecentChanges = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 14 14"
-    width="14px"
-    height="14px"
-  >
-    <rect width="14" height="14" fillOpacity="0" />
-    <path
-      d="M7.94.94A6.13,6.13,0,0,0,1.89,7v.1L.67,5.89a.38.38,0,0,0-.55,0,.39.39,0,0,0,0,.56L2.36,8.69,4.6,6.45a.4.4,0,0,0,0-.56.39.39,0,0,0-.56,
-      0L2.68,7.25V7A5.33,5.33,0,0,1,7.94,1.73,5.33,5.33,0,0,1,13.21,7a5.34,5.34,0,0,1-5.27,5.27H7.86A5,5,0,0,1,4,10.38a.4.4,0,0,0-.55-.07.4.4,0,
-      0,0-.07.56,5.83,5.83,0,0,0,4.52,2.19H8A6.13,6.13,0,0,0,14,7,6.13,6.13,0,0,0,7.94.94Z"
-    />
-    <path
-      d="M7.94,2.83a.4.4,0,0,0-.39.4V7.37L10,8.92a.37.37,0,0,0,.21.06.4.4,0,0,0,.21-.73L8.34,6.93V3.23A.4.4,0,0,0,7.94,2.83Z"
-    />
-  </svg>
-);
-
-export default RecentChanges;

+ 0 - 17
apps/app/src/components/Icons/PageListIcon.jsx

@@ -1,17 +0,0 @@
-import React from 'react';
-
-const PageList = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 14 14"
-
-  >
-    <rect width="14" height="14" fillOpacity="0" />
-    <path d="M12.63,2.72H1.37a.54.54,0,0,1,0-1.08H12.63a.54.54,0,0,1,0,1.08Z" />
-    <path d="M11.82,5.94H1.37a.55.55,0,0,1,0-1.09H11.82a.55.55,0,1,1,0,1.09Z" />
-    <path d="M9.41,9.15h-8a.54.54,0,0,1,0-1.08h8a.54.54,0,0,1,0,1.08Z" />
-    <path d="M10.84,12.36H1.37a.54.54,0,1,1,0-1.08h9.47a.54.54,0,1,1,0,1.08Z" />
-  </svg>
-);
-
-export default PageList;

+ 0 - 22
apps/app/src/components/Icons/PresentationIcon.jsx

@@ -1,22 +0,0 @@
-import React from 'react';
-
-const PresentationIcon = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    width="14"
-    height="14"
-    viewBox="0 0 12.25 14"
-  >
-    <path
-      d="M44.261,0H32.909a.448.448,0,0,0-.449.448V7.635a.449.449,0,0,0,.9,0V.9H43.812V7.635a.449.449,0,0,0,.9,0V.448A.448.448,0,0,0,44.261,0Z"
-      transform="translate(-32.46)"
-    />
-    <path
-      d="M90.959,287.182H82.315a.448.448,0,1,0,0,.9h3.873v1.115l-3.207,3.381a.449.449,0,0,0,.652.616l2.555-2.694v2.013a.449.449,0,0,0,.9,0V
-        290.5l2.555,2.694a.449.449,0,0,0,.652-.616l-3.208-3.382v-1.114h3.873a.448.448,0,1,0,0-.9Z"
-      transform="translate(-80.512 -279.329)"
-    />
-  </svg>
-);
-
-export default PresentationIcon;

+ 0 - 35
apps/app/src/components/Icons/ShareLinkIcon.jsx

@@ -1,35 +0,0 @@
-import React from 'react';
-
-const ShareLink = () => (
-  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 20 20">
-    <g transform="translate(-142 -502)">
-      <rect width="20" height="20" transform="translate(142 502)" fill="none" />
-      <g transform="translate(16 286.938)">
-        <path
-          d="M-1.813-3.563a2.711,2.711,0,0,0-1.274.308,2.8,2.8,0,0,0-.976.835L-11.48-6.2a2.676,2.676,
-          0,0,0,.105-.738,2.555,2.555,0,0,0-.044-.466,3.34,3.34,0,0,0-.114-.448l7.453-3.621a2.71,2.71,
-          0,0,0,.984.853,2.764,2.764,0,0,0,1.283.308,2.708,2.708,0,0,0,1.986-.826A2.708,2.708,0,0,
-          0,1-13.125a2.751,2.751,0,0,0-.378-1.406A2.793,2.793,0,0,0-.406-15.56a2.751,2.751,0,0,
-          0-1.406-.378,2.751,2.751,0,0,0-1.406.378,2.793,2.793,0,0,0-1.028,1.028,2.751,2.751,0,0,0-.378,
-          1.406v.105a.64.64,0,0,0,.009.105.641.641,0,0,1,.009.105A.641.641,0,0,0-4.6-12.7a.694.694,0,0,0,
-          .026.105.332.332,0,0,1,.018.105l-7.559,3.674a2.735,2.735,0,0,0-.923-.686,2.727,2.727,0,0,
-          0-1.151-.246,2.708,2.708,0,0,0-1.986.826A2.708,2.708,0,0,0-17-6.937a2.708,2.708,0,0,0,
-          .826,1.986,2.708,2.708,0,0,0,1.986.826A2.666,2.666,0,0,0-11.99-5.2l7.453,3.8a1.388,1.388,0,0,
-          0-.053.211q-.018.105-.026.22t-.009.22A2.751,2.751,0,0,0-4.247.656,2.792,2.792,0,0,0-3.219,
-          1.685a2.751,2.751,0,0,0,1.406.378A2.708,2.708,0,0,0,.174,1.236,2.708,2.708,0,0,0,1-.75,2.708,
-          2.708,0,0,0,.174-2.736,2.708,2.708,0,0,0-1.813-3.563Zm-1.2-10.758a1.627,1.627,0,0,1,1.2-.492,
-          1.627,1.627,0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2,1.627,1.627,0,0,1-.492,1.2,1.627,1.627,
-          0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,1.627,0,0,1-.492-1.2A1.627,1.627,0,0,
-          1-3.008-14.32Zm-9.984,8.578a1.627,1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492,1.627,
-          1.627,0,0,1-.492-1.2,1.627,1.627,0,0,1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,
-          0,0,1,1.2.492,1.627,1.627,0,0,1,.492,1.2A1.627,1.627,0,0,1-12.992-5.742ZM-.617.445a1.627,
-          1.627,0,0,1-1.2.492,1.627,1.627,0,0,1-1.2-.492A1.627,1.627,0,0,1-3.5-.75a1.627,1.627,0,0,
-          1,.492-1.2,1.627,1.627,0,0,1,1.2-.492,1.627,1.627,0,0,1,1.2.492A1.627,1.627,0,0,1-.125-.75,1.627,1.627,0,0,1-.617.445Z"
-          transform="translate(144 232)"
-        />
-      </g>
-    </g>
-  </svg>
-);
-
-export default ShareLink;

+ 0 - 19
apps/app/src/components/Icons/TimeLineIcon.jsx

@@ -1,19 +0,0 @@
-import React from 'react';
-
-const TimeLine = () => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 14 14"
-
-  >
-    <rect width="14" height="14" fillOpacity="0" />
-    <path
-      d="M13.6,4.6a1.2,1.2,0,0,1-1.2,1.2,1,1,0,0,1-.3,0L10,7.89a1.1,1.1,0,0,1,0,.31,1.2,1.2,0,1,1-2.4,0,1.1,1.1,0,0,1,
-      0-.31L6.11,6.36a1.3,1.3,0,0,1-.62,0L2.75,9.1a1,1,0,0,1,0,.3A1.2,1.2,0,1,1,1.6,8.2a1,1,0,0,1,.3,0L4.64,
-      5.51a1.1,1.1,0,0,1,0-.31A1.2,1.2,0,0,1,7,5.2a1.1,1.1,0,0,1,0,.31L8.49,7a1.3,1.3,0,0,1,.62,0L11.25,4.9a1,
-      1,0,0,1-.05-.3,1.2,1.2,0,1,1,2.4,0Z"
-    />
-  </svg>
-);
-
-export default TimeLine;

+ 0 - 5
apps/app/src/components/ItemsTree/ItemsTree.module.scss

@@ -17,11 +17,6 @@ $grw-pagetree-item-container-height: 40px;
   }
 
   :global {
-    .btn-page-item-control {
-      .icon-plus::before {
-        font-size: 18px;
-      }
-    }
 
     .list-group-item {
       .grw-visible-on-hover {

+ 14 - 8
apps/app/src/components/ItemsTree/ItemsTree.tsx

@@ -11,12 +11,13 @@ import { useRouter } from 'next/router';
 import { debounce } from 'throttle-debounce';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
-import { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import { SocketEventName, UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
-import {
-  IPageForPageDuplicateModal, usePageDuplicateModal, usePageDeleteModal,
-} from '~/stores/modal';
+import type { IPageForItem } from '~/interfaces/page';
+import type { AncestorsChildrenResult, RootPageResult, TargetAndAncestors } from '~/interfaces/page-listing-results';
+import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
+import type { IPageForPageDuplicateModal } from '~/stores/modal';
+import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
 import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
 import {
   useSWRxPageAncestorsChildren, useSWRxRootPage, mutatePageTree, mutatePageList,
@@ -31,6 +32,7 @@ import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
 
 import styles from './ItemsTree.module.scss';
 
+
 const logger = loggerFactory('growi:cli:ItemsTree');
 
 /*
@@ -89,10 +91,12 @@ const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondSta
 type ItemsTreeProps = {
   isEnableActions: boolean
   isReadOnlyUser: boolean
+  isWipPageShown?: boolean
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
   CustomTreeItem: React.FunctionComponent<TreeItemProps>
+  onClickTreeItem?: (page: IPageForItem) => void;
 }
 
 /*
@@ -100,7 +104,7 @@ type ItemsTreeProps = {
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
 
   const { t } = useTranslation();
@@ -271,17 +275,19 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
   if (initialItemNode != null) {
     return (
-      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-3`} ref={rootElemRef}>
+      <ul className={`grw-pagetree ${styles['grw-pagetree']} list-group py-4`} ref={rootElemRef}>
         <CustomTreeItem
           key={initialItemNode.page.path}
           targetPathOrId={targetPathOrId}
           itemNode={initialItemNode}
           isOpen
           isEnableActions={isEnableActions}
+          isWipPageShown={isWipPageShown}
           isReadOnlyUser={isReadOnlyUser}
           onRenamed={onRenamed}
           onClickDuplicateMenuItem={onClickDuplicateMenuItem}
           onClickDeleteMenuItem={onClickDeleteMenuItem}
+          onClick={onClickTreeItem}
         />
       </ul>
     );

+ 13 - 13
apps/app/src/components/Me/BasicInfoSettings.tsx

@@ -49,7 +49,7 @@ export const BasicInfoSettings = (): JSX.Element => {
   return (
     <>
 
-      <div className="row">
+      <div className="row mt-3 mt-md-4">
         <label htmlFor="userForm[name]" className="text-start text-md-end col-md-3 col-form-label">{t('Name')}</label>
         <div className="col-md-6">
           <input
@@ -62,7 +62,7 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
       </div>
 
-      <div className="row">
+      <div className="row mt-3">
         <label htmlFor="userForm[email]" className="text-start text-md-end col-md-3 col-form-label">{t('Email')}</label>
         <div className="col-md-6">
           <input
@@ -83,10 +83,10 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
       </div>
 
-      <div className="row">
+      <div className="row mt-3">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Disclose E-mail')}</label>
-        <div className="col-md-6">
-          <div className="form-check form-check-inline">
+        <div className="col-md-6 my-auto">
+          <div className="form-check form-check-inline me-4">
             <input
               type="radio"
               id="radioEmailShow"
@@ -95,7 +95,7 @@ export const BasicInfoSettings = (): JSX.Element => {
               checked={personalSettingsInfo?.isEmailPublished === true}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: true })}
             />
-            <label className="form-label form-check-label" htmlFor="radioEmailShow">{t('Show')}</label>
+            <label className="form-label form-check-label mb-0" htmlFor="radioEmailShow">{t('Show')}</label>
           </div>
           <div className="form-check form-check-inline">
             <input
@@ -106,21 +106,21 @@ export const BasicInfoSettings = (): JSX.Element => {
               checked={personalSettingsInfo?.isEmailPublished === false}
               onChange={() => changePersonalSettingsHandler({ isEmailPublished: false })}
             />
-            <label className="form-label form-check-label" htmlFor="radioEmailHide">{t('Hide')}</label>
+            <label className="form-label form-check-label mb-0" htmlFor="radioEmailHide">{t('Hide')}</label>
           </div>
         </div>
       </div>
 
-      <div className="row">
+      <div className="row mt-3">
         <label className="text-start text-md-end col-md-3 col-form-label">{t('Language')}</label>
-        <div className="col-md-6">
+        <div className="col-md-6 my-auto">
           {
             i18nConfig.locales.map((locale) => {
               if (i18n == null) { return }
               const fixedT = i18n.getFixedT(locale);
 
               return (
-                <div key={locale} className="form-check form-check-inline">
+                <div key={locale} className="form-check form-check-inline me-4">
                   <input
                     type="radio"
                     id={`radioLang${locale}`}
@@ -129,14 +129,14 @@ export const BasicInfoSettings = (): JSX.Element => {
                     checked={personalSettingsInfo?.lang === locale}
                     onChange={() => changePersonalSettingsHandler({ lang: locale })}
                   />
-                  <label className="form-label form-check-label" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
+                  <label className="form-label form-check-label mb-0" htmlFor={`radioLang${locale}`}>{fixedT('meta.display_name') as string}</label>
                 </div>
               );
             })
           }
         </div>
       </div>
-      <div className="row">
+      <div className="row mt-3">
         <label htmlFor="userForm[slackMemberId]" className="text-start text-md-end col-md-3 col-form-label">{t('Slack Member ID')}</label>
         <div className="col-md-6">
           <input
@@ -150,7 +150,7 @@ export const BasicInfoSettings = (): JSX.Element => {
         </div>
       </div>
 
-      <div className="row my-3">
+      <div className="row mt-4">
         <div className="offset-4 col-5">
           <button
             data-testid="grw-besic-info-settings-update-button"

+ 1 - 1
apps/app/src/components/Me/ExternalAccountLinkedMe.jsx

@@ -64,7 +64,7 @@ class ExternalAccountLinkedMe extends React.Component {
             className="btn btn-outline-secondary btn-sm pull-right"
             onClick={this.openAssociateModal}
           >
-            <i className="icon-plus" aria-hidden="true" />
+            <span className="material-symbols-outlined" aria-hidden="true">add_circle</span>
             Add
           </button>
           { t('admin:user_management.external_accounts') }

+ 6 - 6
apps/app/src/components/Me/PersonalSettings.jsx

@@ -20,22 +20,22 @@ const PersonalSettings = () => {
   const navTabMapping = useMemo(() => {
     return {
       user_infomation: {
-        Icon: () => <i className="icon-fw icon-user"></i>,
+        Icon: () => <span className="material-symbols-outlined">person</span>,
         Content: UserSettings,
         i18n: t('User Information'),
       },
       external_accounts: {
-        Icon: () => <i className="icon-fw icon-share-alt"></i>,
+        Icon: () => <span className="material-symbols-outlined">ungroup</span>,
         Content: ExternalAccountLinkedMe,
         i18n: t('admin:user_management.external_accounts'),
       },
       password_settings: {
-        Icon: () => <i className="icon-fw icon-lock"></i>,
+        Icon: () => <span className="material-symbols-outlined">password</span>,
         Content: PasswordSettings,
         i18n: t('Password Settings'),
       },
       api_settings: {
-        Icon: () => <i className="icon-fw icon-paper-plane"></i>,
+        Icon: () => <span className="material-symbols-outlined">api</span>,
         Content: ApiSettings,
         i18n: t('API Settings'),
       },
@@ -45,12 +45,12 @@ const PersonalSettings = () => {
       //   i18n: t('editor_settings.editor_settings'),
       // },
       in_app_notification_settings: {
-        Icon: () => <i className="icon-fw icon-bell"></i>,
+        Icon: () => <span className="material-symbols-outlined">notifications</span>,
         Content: InAppNotificationSettings,
         i18n: t('in_app_notification_settings.in_app_notification_settings'),
       },
       other_settings: {
-        Icon: () => <i className="icon-fw icon-settings"></i>,
+        Icon: () => <span className="material-symbols-outlined">settings</span>,
         Content: OtherSettings,
         i18n: t('Other Settings'),
       },

+ 19 - 19
apps/app/src/components/Me/ProfileImageSettings.tsx

@@ -91,9 +91,9 @@ const ProfileImageSettings = (): JSX.Element => {
 
   return (
     <>
-      <div className="row">
-        <div className="col-md-6 col-12 mb-3 mb-md-0">
-          <h4>
+      <div className="row justify-content-around mt-5 mt-md-4">
+        <div className="col-md-3">
+          <h5>
             <div className="form-check radio-primary">
               <input
                 type="radio"
@@ -105,18 +105,18 @@ const ProfileImageSettings = (): JSX.Element => {
                 onChange={() => setGravatarEnabled(true)}
               />
               <label className="form-label form-check-label" htmlFor="radioGravatar">
-                <img src={GRAVATAR_DEFAULT} data-vrt-blackout-profile /> Gravatar
+                <img src={GRAVATAR_DEFAULT} className="me-1" data-vrt-blackout-profile /> Gravatar
               </label>
-              <a href="https://gravatar.com/">
-                <small><i className="icon-arrow-right-circle" aria-hidden="true"></i></small>
+              <a href="https://gravatar.com/" target="_blank" rel="noopener noreferrer">
+                <small><span className="material-symbols-outlined ms-2 text-secondary" aria-hidden="true">info</span></small>
               </a>
             </div>
-          </h4>
-          <img src={generateGravatarSrc(currentUser.email)} width="64" data-vrt-blackout-profile />
+          </h5>
+          <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" data-vrt-blackout-profile />
         </div>
 
-        <div className="col-md-6 col-12">
-          <h4>
+        <div className="col-md-7 mt-5">
+          <h5>
             <div className="form-check radio-primary">
               <input
                 type="radio"
@@ -131,21 +131,21 @@ const ProfileImageSettings = (): JSX.Element => {
                 { t('Upload Image') }
               </label>
             </div>
-          </h4>
-          <div className="row mb-3">
-            <label className="col-sm-4 col-12 col-form-label text-start">
+          </h5>
+          <div className="row mt-3">
+            <label className="col-md-6 col-lg-4 col-form-label text-start">
               { t('Current Image') }
             </label>
-            <div className="col-sm-8 col-12">
-              <p><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
+            <div className="col-md-6 col-lg-8">
+              <p className="mb-0"><img src={uploadedPictureSrc ?? DEFAULT_IMAGE} className="picture picture-lg rounded-circle" id="settingUserPicture" /></p>
               {uploadedPictureSrc && <button type="button" className="btn btn-danger" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
             </div>
           </div>
-          <div className="row">
-            <label className="col-sm-4 col-12 col-form-label text-start">
+          <div className="row align-items-center mt-3 mt-md-5">
+            <label className="col-md-6 col-lg-4 col-form-label text-start mt-3 mt-md-0">
               {t('Upload new image')}
             </label>
-            <div className="col-sm-8 col-12">
+            <div className="col-md-6 col-lg-8">
               <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
             </div>
           </div>
@@ -161,7 +161,7 @@ const ProfileImageSettings = (): JSX.Element => {
         showCropOption
       />
 
-      <div className="row my-3">
+      <div className="row mt-4">
         <div className="offset-4 col-5">
           <button type="button" className="btn btn-primary" onClick={submit}>
             {t('Update')}

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

@@ -11,11 +11,11 @@ const UserSettings = React.memo((): JSX.Element => {
   return (
     <div data-testid="grw-user-settings">
       <div className="mb-5">
-        <h2 className="border-bottom my-4">{t('Basic Info')}</h2>
+        <h2 className="border-bottom fs-4 mt-4 pb-1">{t('Basic Info')}</h2>
         <BasicInfoSettings />
       </div>
       <div className="mb-5">
-        <h2 className="border-bottom my-4">{t('Set Profile Image')}</h2>
+        <h2 className="border-bottom fs-4 mt-3 mt-md-5 pb-1">{t('Set Profile Image')}</h2>
         <ProfileImageSettings />
       </div>
     </div>

+ 20 - 18
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -8,6 +8,7 @@ import type {
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
+import Link from 'next/link';
 import { useRouter } from 'next/router';
 import { DropdownItem } from 'reactstrap';
 
@@ -32,10 +33,6 @@ import {
 } from '~/stores/ui';
 
 import { CreateTemplateModal } from '../CreateTemplateModal';
-import AttachmentIcon from '../Icons/AttachmentIcon';
-import HistoryIcon from '../Icons/HistoryIcon';
-import PresentationIcon from '../Icons/PresentationIcon';
-import ShareLinkIcon from '../Icons/ShareLinkIcon';
 import { NotAvailable } from '../NotAvailable';
 import { Skeleton } from '../Skeleton';
 
@@ -80,9 +77,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         data-testid="open-presentation-modal-btn"
         className="grw-page-control-dropdown-item"
       >
-        <i className="icon-fw grw-page-control-dropdown-icon">
-          <PresentationIcon />
-        </i>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">jamboard_kiosk</span>
         {t('Presentation Mode')}
       </DropdownItem>
 
@@ -91,7 +86,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
         className="grw-page-control-dropdown-item"
       >
-        <i className="icon-fw icon-cloud-download grw-page-control-dropdown-icon"></i>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
         {t('export_bulk.export_page_markdown')}
       </DropdownItem>
 
@@ -107,9 +102,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         data-testid="open-page-accessories-modal-btn-with-history-tab"
         className="grw-page-control-dropdown-item"
       >
-        <span className="grw-page-control-dropdown-icon">
-          <HistoryIcon />
-        </span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">history</span>
         {t('History')}
       </DropdownItem>
 
@@ -118,9 +111,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
         className="grw-page-control-dropdown-item"
       >
-        <span className="grw-page-control-dropdown-icon">
-          <AttachmentIcon />
-        </span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">attachment</span>
         {t('attachment_data')}
       </DropdownItem>
 
@@ -131,9 +122,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
             data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
             className="grw-page-control-dropdown-item"
           >
-            <span className="grw-page-control-dropdown-icon">
-              <ShareLinkIcon />
-            </span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">share</span>
             {t('share_links.share_link_management')}
           </DropdownItem>
         </NotAvailable>
@@ -163,7 +152,7 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         className="grw-page-control-dropdown-item"
         data-testid="open-page-template-modal-btn"
       >
-        <i className="icon-fw icon-magic-wand grw-page-control-dropdown-icon"></i>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">contract_edit</span>
         {t('template.option_label.create/edit')}
       </DropdownItem>
     </>
@@ -179,6 +168,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { currentPage } = props;
 
+  const { t } = useTranslation();
+
   const router = useRouter();
 
   const { data: shareLinkId } = useShareLinkId();
@@ -329,6 +320,17 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             // grantUserGroupId={grantUserGroupId}
           />
         )}
+
+        { isGuestUser && (
+          <>
+            <Link href="/login#register" className="btn" prefetch={false}>
+              <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+            </Link>
+            <Link href="/login#login" className="btn btn-primary" prefetch={false}>
+              <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
+            </Link>
+          </>
+        ) }
       </div>
 
       {path != null && currentUser != null && !isReadOnlyUser && (

+ 26 - 23
apps/app/src/components/Navbar/PageEditorModeManager.tsx

@@ -1,12 +1,14 @@
-import React, { type ReactNode, useCallback, useState } from 'react';
+import React, { type ReactNode, useCallback } from 'react';
 
-import type { IGrantedGroup } 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';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
-import { useCreatePageAndTransit } from '../../client/services/use-create-page-and-transit';
+import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
+
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -47,9 +49,6 @@ type Props = {
   editorMode: EditorMode | undefined,
   isBtnDisabled: boolean,
   path?: string,
-  grant?: number,
-  // grantUserGroupId?: string
-  grantUserGroupIds?: IGrantedGroup[]
 }
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
@@ -57,30 +56,34 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
     editorMode = EditorMode.View,
     isBtnDisabled,
     path,
-    // grant,
-    // grantUserGroupId,
   } = props;
 
-  const { t } = useTranslation('common');
-  const [isCreating, setIsCreating] = useState(false);
+  const { t } = useTranslation('commons');
 
+  const { data: isNotFound } = useIsNotFound();
   const { mutate: mutateEditorMode } = useEditorMode();
   const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
-  const _isBtnDisabled = isCreating || isBtnDisabled;
+  const { isCreating, createAndTransit } = useCreatePageAndTransit();
+
+  const editButtonClickedHandler = useCallback(async() => {
+    if (isNotFound == null || isNotFound === false) {
+      mutateEditorMode(EditorMode.Editor);
+      return;
+    }
+
+    try {
+      await createAndTransit(
+        { path, wip: shouldCreateWipPage(path) },
+        { shouldCheckPageExists: true },
+      );
+    }
+    catch (err) {
+      toastError(t('toaster.create_failed', { target: path }));
+    }
+  }, [createAndTransit, isNotFound, mutateEditorMode, path, t]);
 
-  const createPageAndTransit = useCreatePageAndTransit();
-
-  const editButtonClickedHandler = useCallback(() => {
-    createPageAndTransit(
-      path,
-      {
-        onCreationStart: () => { setIsCreating(true) },
-        onError: () => { toastError(t('toaster.create_failed', { target: path })) },
-        onTerminated: () => { setIsCreating(false) },
-      },
-    );
-  }, [createPageAndTransit, path, t]);
+  const _isBtnDisabled = isCreating || isBtnDisabled;
 
   return (
     <>

+ 2 - 4
apps/app/src/components/NotFoundPage.tsx

@@ -4,8 +4,6 @@ import { useTranslation } from 'next-i18next';
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
 import { DescendantsPageList } from './DescendantsPageList';
-import PageListIcon from './Icons/PageListIcon';
-import TimeLineIcon from './Icons/TimeLineIcon';
 import { PageTimeline } from './PageTimeline';
 
 type NotFoundPageProps = {
@@ -20,12 +18,12 @@ const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: PageListIcon,
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
         Content: () => <DescendantsPageList path={path} />,
         i18n: t('page_list'),
       },
       timeLine: {
-        Icon: TimeLineIcon,
+        Icon: () => <span className="material-symbols-outlined">timeline</span>,
         Content: PageTimeline,
         i18n: t('Timeline View'),
       },

+ 3 - 3
apps/app/src/components/Page/PageView.tsx

@@ -68,7 +68,7 @@ export const PageView = (props: Props): JSX.Element => {
   const { data: viewOptions } = useViewOptions();
 
   const page = pageBySWR ?? initialPage;
-  const isNotFound = isNotFoundMeta || page?.revision == null;
+  const isNotFound = isNotFoundMeta || page == null;
   const isUsersHomepagePath = isUsersHomepage(pagePath);
 
   const shouldExpandContent = useShouldExpandContent(page);
@@ -103,7 +103,7 @@ export const PageView = (props: Props): JSX.Element => {
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
   const headerContents = (
-    <PagePathNavSticky pageId={page?._id} pagePath={pagePath} />
+    <PagePathNavSticky pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />
   );
 
   const sideContents = !isNotFound && !isNotCreatable
@@ -124,7 +124,7 @@ export const PageView = (props: Props): JSX.Element => {
     : null;
 
   const Contents = () => {
-    if (isNotFound) {
+    if (isNotFound || page?.revision == null) {
       return <NotFoundPage path={pagePath} />;
     }
 

+ 2 - 2
apps/app/src/components/Page/RevisionLoader.tsx

@@ -49,11 +49,11 @@ export const RevisionLoader = (props: RevisionLoaderProps): JSX.Element => {
     if (error != null) {
       const isForbidden = error != null && error[0].code === 'forbidden-page';
       if (isForbidden) {
-        setMarkdown(`<i class="icon-exclamation p-1"></i>${t('not_allowed_to_see_this_page')}`);
+        setMarkdown(`<span className="material-symbols-outlined p-1">cancel</span>${t('not_allowed_to_see_this_page')}`);
       }
       else {
         const errorMessages = error.map((error) => {
-          return `<i class="icon-exclamation p-1"></i><span class="text-muted"><em>${error.message}</em></span>`;
+          return `<span className="material-symbols-outlined p-1">cancel</span><span class="text-muted"><em>${error.message}</em></span>`;
         });
         setMarkdown(errorMessages.join('\n'));
       }

+ 3 - 2
apps/app/src/components/Page/ShareLinkAlert.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
@@ -40,7 +41,7 @@ const ShareLinkAlert: FC<Props> = (props: Props) => {
 
   return (
     <p className={`alert alert-${alertColor} px-4 d-edit-none`}>
-      <i className="icon-fw icon-link"></i>
+      <span className="material-symbols-outlined me-1">link</span>
       {(expiredAt === null ? <span>{t('page_page.notice.no_deadline')}</span>
       // eslint-disable-next-line react/no-danger
         : <span dangerouslySetInnerHTML={{ __html: t('page_page.notice.expiration', { expiredAt }) }} />

+ 3 - 6
apps/app/src/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -14,9 +14,6 @@ import { usePageAccessoriesModal, PageAccessoriesModalContents } from '~/stores/
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import ExpandOrContractButton from '../ExpandOrContractButton';
-import AttachmentIcon from '../Icons/AttachmentIcon';
-import HistoryIcon from '../Icons/HistoryIcon';
-import ShareLinkIcon from '../Icons/ShareLinkIcon';
 
 import { useAutoOpenModalByQueryParam } from './hooks';
 
@@ -46,7 +43,7 @@ export const PageAccessoriesModal = (): JSX.Element => {
   const navTabMapping = useMemo(() => {
     return {
       [PageAccessoriesModalContents.PageHistory]: {
-        Icon: HistoryIcon,
+        Icon: () => <span className="material-symbols-outlined">history</span>,
         Content: () => {
           return <PageHistory onClose={close} />;
         },
@@ -54,14 +51,14 @@ export const PageAccessoriesModal = (): JSX.Element => {
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
       },
       [PageAccessoriesModalContents.Attachment]: {
-        Icon: AttachmentIcon,
+        Icon: () => <span className="material-symbols-outlined">attachment</span>,
         Content: () => {
           return <PageAttachment />;
         },
         i18n: t('attachment_data'),
       },
       [PageAccessoriesModalContents.ShareLink]: {
-        Icon: ShareLinkIcon,
+        Icon: () => <span className="material-symbols-outlined">share</span>,
         Content: () => {
           return <ShareLink />;
         },

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

@@ -33,7 +33,7 @@ const PageAttachment = (): JSX.Element => {
   const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModal();
   const { data: currentPage } = useSWRxCurrentPage();
-  const markdown = currentPage?.revision.body;
+  const markdown = currentPage?.revision?.body;
 
   // Custom hooks
   const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {

+ 3 - 3
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -8,8 +8,8 @@ import {
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { IPageGrantData } from '~/interfaces/page';
-import { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
+import type { IPageGrantData } from '~/interfaces/page';
+import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
 import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
 
@@ -297,7 +297,7 @@ export const FixPageGrantAlert = (): JSX.Element => {
     <>
       <div className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row">
         <div className="flex-grow-1 d-flex align-items-center">
-          <i className="icon-fw icon-exclamation ms-1" aria-hidden="true" />
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">error</span>
           {t('fix_page_grant.alert.description')}
         </div>
         <div className="d-flex align-items-end align-items-lg-center">

+ 1 - 1
apps/app/src/components/PageAlert/OldRevisionAlert.tsx

@@ -20,7 +20,7 @@ export const OldRevisionAlert = (): JSX.Element => {
     <div className="alert alert-warning">
       <strong>{t('Warning')}: </strong> {t('page_page.notice.version')}
       <Link href={returnPathForURL(page.path, page._id)}>
-        <i className="icon-fw icon-arrow-right-circle"></i>{t('Show latest')}
+        <span className="material-symbols-outlined me-1">arrow_circle_right</span>{t('Show latest')}
       </Link>
     </div>
   );

+ 2 - 0
apps/app/src/components/PageAlert/PageAlerts.tsx

@@ -8,6 +8,7 @@ import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
 import { PageRedirectedAlert } from './PageRedirectedAlert';
 import { PageStaleAlert } from './PageStaleAlert';
+import { WipPageAlert } from './WipPageAlert';
 
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 // dynamic import because TrashPageAlert uses localStorageMiddleware
@@ -22,6 +23,7 @@ export const PageAlerts = (): JSX.Element => {
       <div className="col-sm-12">
         {/* alerts */}
         { !isNotFound && <FixPageGrantAlert /> }
+        <WipPageAlert />
         <PageGrantAlert />
         <TrashPageAlert />
         <PageStaleAlert />

+ 3 - 3
apps/app/src/components/PageAlert/PageGrantAlert.tsx

@@ -23,21 +23,21 @@ export const PageGrantAlert = (): JSX.Element => {
       if (pageData.grant === 2) {
         return (
           <>
-            <i className="icon-fw icon-link"></i><strong>{t('Anyone with the link')}</strong>
+            <span className="material-symbols-outlined me-1">link</span><strong>{t('Anyone with the link')}</strong>
           </>
         );
       }
       if (pageData.grant === 4) {
         return (
           <>
-            <i className="icon-fw icon-lock"></i><strong>{t('Only me')}</strong>
+            <span className="material-symbols-outlined me-1">lock</span><strong>{t('Only me')}</strong>
           </>
         );
       }
       if (pageData.grant === 5) {
         return (
           <>
-            <i className="icon-fw icon-organization"></i>
+            <span className="material-symbols-outlined me-1">account_tree</span>
             <strong>{
               populatedGrantedGroups().map(g => g.item.name).join(', ')
             }

+ 1 - 1
apps/app/src/components/PageAlert/PageStaleAlert.tsx

@@ -37,7 +37,7 @@ export const PageStaleAlert = ():JSX.Element => {
 
   return (
     <div className={`alert ${alertClass}`}>
-      <i className="icon-fw icon-hourglass"></i>
+      <span className="material-symbols-outlined me-1">hourglass</span>
       <strong>{ t('page_page.notice.stale', { count: contentAge }) }</strong>
     </div>
   );

+ 1 - 1
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -90,7 +90,7 @@ export const TrashPageAlert = (): JSX.Element => {
           onClick={openPutbackPageModalHandler}
           data-testid="put-back-button"
         >
-          <i className="icon-action-undo" aria-hidden="true"></i> {t('Put Back')}
+          <span className="material-symbols-outlined" aria-hidden="true">undo</span> {t('Put Back')}
         </button>
         <button
           type="button"

+ 53 - 0
apps/app/src/components/PageAlert/WipPageAlert.tsx

@@ -0,0 +1,53 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { publish } from '../../client/services/page-operation';
+
+
+export const WipPageAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+  const { data: currentPage } = useSWRxCurrentPage();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+
+  const clickPagePublishButton = useCallback(async() => {
+    const pageId = currentPage?._id;
+
+    if (pageId == null) {
+      return;
+    }
+
+    try {
+      await publish(pageId);
+      await mutateCurrentPage();
+      await mutatePageTree();
+      toastSuccess(t('wip_page.success_publish_page'));
+    }
+    catch {
+      toastError(t('wip_page.fail_publish_page'));
+    }
+  }, [currentPage?._id, mutateCurrentPage, t]);
+
+
+  if (!currentPage?.wip) {
+    return <></>;
+  }
+
+  return (
+    <p className="d-flex align-items-center alert alert-secondary py-3 px-4">
+      <span className="material-symbols-outlined me-1 fs-5">info</span>
+      <span>{t('wip_page.alert')}</span>
+      <button
+        type="button"
+        className="btn btn-outline-secondary ms-auto"
+        onClick={clickPagePublishButton}
+      >
+        {t('wip_page.publish_page') }
+      </button>
+    </p>
+  );
+};

+ 2 - 2
apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -20,7 +20,7 @@ import styles from './DeleteAttachmentModal.module.scss';
 const logger = loggerFactory('growi:attachmentDelete');
 
 const iconByFormat = (format: string): string => {
-  return format.match(/image\/.+/i) ? 'icon-picture' : 'icon-doc';
+  return format.match(/image\/.+/i) ? 'image' : 'description';
 };
 
 export const DeleteAttachmentModal: React.FC = () => {
@@ -73,7 +73,7 @@ export const DeleteAttachmentModal: React.FC = () => {
     return (
       <div className="attachment-delete-image">
         <p>
-          <i className={iconByFormat(attachment.fileFormat)}></i> {attachment.originalName}
+          <span className="material-symbols-outlined">{iconByFormat(attachment.fileFormat)}</span> {attachment.originalName}
         </p>
         <p>
           uploaded by <UserPicture user={attachment.creator} size="sm"></UserPicture> <Username user={attachment.creator as IUser}></Username>

+ 2 - 3
apps/app/src/components/PageComment/Comment.tsx

@@ -12,9 +12,8 @@ import urljoin from 'url-join';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
 
-import { ICommentHasId } from '../../interfaces/comment';
+import type { ICommentHasId } from '../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
-import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import { Username } from '../User/Username';
 
@@ -177,7 +176,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
                   className="page-comment-revision"
                   prefetch={false}
                 >
-                  <HistoryIcon />
+                  <span className="material-symbols-outlined">history</span>
                 </Link>
                 <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
                   {t('page_comment.display_the_page_when_posting_this_comment')}

+ 48 - 47
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -12,17 +12,19 @@ import {
   Button, TabContent, TabPane,
 } from 'reactstrap';
 
-import { apiPostForm } from '~/client/util/apiv1-client';
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import type { IEditorMethods } from '~/interfaces/editor-methods';
 import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
 import {
-  useCurrentUser, useIsSlackConfigured,
-  useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useCurrentUser, useIsSlackConfigured, useAcceptedUploadFileType,
 } from '~/stores/context';
-import { useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning } from '~/stores/editor';
+import {
+  useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning,
+} from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
 import { useNextThemes } from '~/stores/use-next-themes';
+import loggerFactory from '~/utils/logger';
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
@@ -30,19 +32,23 @@ import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 
 import { CommentPreview } from './CommentPreview';
 
+import '@growi/editor/dist/style.css';
 import styles from './CommentEditor.module.scss';
 
 
+const logger = loggerFactory('growi:components:CommentEditor');
+
+
 const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 
 
 const navTabMapping = {
   comment_editor: {
-    Icon: () => <i className="icon-settings" />,
+    Icon: () => <span className="material-symbols-outlined">edit_square</span>,
     i18n: 'Write',
   },
   comment_preview: {
-    Icon: () => <i className="icon-settings" />,
+    Icon: () => <span className="material-symbols-outlined">play_arrow</span>,
     i18n: 'Preview',
   },
 };
@@ -70,10 +76,9 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
+  const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
   const { data: isSlackConfigured } = useIsSlackConfigured();
-  const { data: isUploadAllFileAllowed } = useIsUploadAllFileAllowed();
-  const { data: isUploadEnabled } = useIsUploadEnabled();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
     increment: incrementEditingCommentsNum,
@@ -201,48 +206,43 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     updateComment, comment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment,
   ]);
 
-  const ctrlEnterHandler = useCallback((event) => {
-    if (event != null) {
-      event.preventDefault();
-    }
+  // the upload event handler
+  const uploadHandler = useCallback((files: File[]) => {
+    files.forEach(async(file) => {
+      try {
+        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
 
-    postCommentHandler();
-  }, [postCommentHandler]);
+        if (!resLimit.isUploadable) {
+          throw new Error(resLimit.errorMessage);
+        }
 
-  const apiErrorHandler = useCallback((error: Error) => {
-    toastError(error.message);
-  }, []);
+        const formData = new FormData();
+        formData.append('file', file);
+        if (pageId != null) {
+          formData.append('page_id', pageId);
+        }
 
-  const uploadHandler = useCallback(async(file) => {
-    if (editorRef.current == null) { return }
+        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
 
-    const pagePath = currentPagePath;
-    const endpoint = '/attachments.add';
-    const formData = new FormData();
-    formData.append('file', file);
-    formData.append('path', pagePath ?? '');
-    formData.append('page_id', pageId ?? '');
+        const attachment = resAdd.attachment;
+        const fileName = attachment.originalName;
 
-    try {
-      // TODO: typescriptize res
-      const res = await apiPostForm(endpoint, formData) as any;
-      const attachment = res.attachment;
-      const fileName = attachment.originalName;
-      let insertText = `[${fileName}](${attachment.filePathProxied})`;
-      // when image
-      if (attachment.fileFormat.startsWith('image/')) {
-        // modify to "![fileName](url)" syntax
-        insertText = `!${insertText}`;
+        let insertText = `[${fileName}](${attachment.filePathProxied})\n`;
+        // when image
+        if (attachment.fileFormat.startsWith('image/')) {
+          // modify to "![fileName](url)" syntax
+          insertText = `!${insertText}`;
+        }
+
+        codeMirrorEditor?.insertText(insertText);
       }
-      editorRef.current.insertText(insertText);
-    }
-    catch (err) {
-      apiErrorHandler(err);
-    }
-    finally {
-      editorRef.current.terminateUploadingState();
-    }
-  }, [apiErrorHandler, currentPagePath, pageId]);
+      catch (e) {
+        logger.error('failed to upload', e);
+        toastError(e);
+      }
+    });
+
+  }, [codeMirrorEditor, pageId]);
 
   const getCommentHtml = useCallback(() => {
     if (currentPagePath == null) {
@@ -263,7 +263,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
             >
-              <i className="icon-bubble"></i> Add Comment
+              <span className="material-symbols-outlined">comment</span> Add Comment
             </button>
           </NotAvailableForReadOnlyUser>
         </NotAvailableForGuest>
@@ -325,8 +325,6 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       </Button>
     );
 
-    const isUploadable = isUploadEnabled || isUploadAllFileAllowed;
-
     return (
       <>
         <div className="comment-write">
@@ -334,7 +332,10 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
               <CodeMirrorEditorComment
+                acceptedUploadFileType={acceptedUploadFileType}
                 onChange={onChangeHandler}
+                onSave={postCommentHandler}
+                onUpload={uploadHandler}
               />
               {/* <Editor
                 ref={editorRef}

+ 2 - 2
apps/app/src/components/PageComment/ReplyComments.tsx

@@ -68,8 +68,8 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
   }
 
   const areThereHiddenReplies = (replyList.length > 2);
-  const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
-  const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+  const toggleButtonIconName = isOlderRepliesShown ? 'expand_less' : 'more_vert';
+  const toggleButtonIcon = <span className="material-icons-outlined me-1">{toggleButtonIconName}</span>;
   const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
   const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
   const hiddenReplies = replyList.slice(0, replyList.length - 2);

+ 4 - 0
apps/app/src/components/PageContentFooter.tsx

@@ -21,6 +21,10 @@ export const PageContentFooter = (props: PageContentFooterProps): JSX.Element =>
     creator, lastUpdateUser, createdAt, updatedAt,
   } = page;
 
+  if (page.isEmpty) {
+    return <></>;
+  }
+
   return (
     <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
       <div className="container-lg grw-container-convertible">

+ 9 - 8
apps/app/src/components/PageControls/PageControls.tsx

@@ -20,8 +20,9 @@ import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
+import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
 import {
-  AdditionalMenuItemsRendererProps, ForceHideMenuItems, MenuItemType,
+  MenuItemType,
   PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
 
@@ -45,13 +46,13 @@ const Tags = (props: TagsProps): JSX.Element => {
   const { onClickEditTagsButton } = props;
 
   return (
-    <div className="grw-taglabels-container d-flex align-items-center">
+    <div className="grw-tag-labels-container d-flex align-items-center">
       <button
         type="button"
         className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
         onClick={onClickEditTagsButton}
       >
-        <i className="icon-tag me-2" />
+        <span className="material-symbols-outlined me-2">local_offer</span>
         Tags
       </button>
     </div>
@@ -106,7 +107,7 @@ type CommonProps = {
 type PageControlsSubstanceProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId: string | null,
+  revisionId?: string | null,
   path?: string | null,
   pageInfo: IPageInfoForOperation,
   expandContentWidth?: boolean,
@@ -177,7 +178,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     const page: IPageToRenameWithMeta = {
       data: {
         _id: pageId,
-        revision: revisionId,
+        revision: revisionId ?? null,
         path,
       },
       meta: pageInfo,
@@ -194,7 +195,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
         _id: pageId,
-        revision: revisionId,
+        revision: revisionId ?? null,
         path,
       },
       meta: pageInfo,
@@ -310,7 +311,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 type PageControlsProps = CommonProps & {
   pageId: string,
   shareLinkId?: string | null,
-  revisionId?: string,
+  revisionId?: string | null,
   path?: string | null,
   expandContentWidth?: boolean,
 };
@@ -345,7 +346,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
       {...props}
       pageInfo={pageInfo}
       pageId={pageId}
-      revisionId={revisionId ?? null}
+      revisionId={revisionId}
       path={path}
       onClickEditTagsButton={onClickEditTagsButton}
       onClickDuplicateMenuItem={onClickDuplicateMenuItem}

+ 8 - 8
apps/app/src/components/PageDuplicateModal.tsx

@@ -160,10 +160,10 @@ const PageDuplicateModal = (): JSX.Element => {
 
     return (
       <>
-        <div><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
+        <div className="mt-3"><label className="form-label">{t('modal_duplicate.label.Current page name')}</label><br />
           <code>{path}</code>
         </div>
-        <div>
+        <div className="mt-3">
           <label className="form-label" htmlFor="duplicatePageName">{ t('modal_duplicate.label.New page name') }</label><br />
           <div className="input-group">
             <div>
@@ -196,7 +196,7 @@ const PageDuplicateModal = (): JSX.Element => {
           <p className="text-danger">Error: Target path is duplicated.</p>
         ) }
 
-        <div className="form-check form-check-warning">
+        <div className="form-check form-check-warning mt-3">
           <input
             className="form-check-input"
             name="recursively"
@@ -210,7 +210,7 @@ const PageDuplicateModal = (): JSX.Element => {
             <p className="form-text text-muted my-0">{ t('modal_duplicate.help.recursive') }</p>
           </label>
 
-          <div>
+          <div className="mt-3">
             {isDuplicateRecursively && existingPaths.length !== 0 && (
               <div className="form-check form-check-warning">
                 <input
@@ -230,7 +230,7 @@ const PageDuplicateModal = (): JSX.Element => {
           </div>
         </div>
 
-        <div className="form-check form-check-warning mb-3">
+        <div className="form-check form-check-warning mt-2">
           <input
             className="form-check-input"
             id="cbOnlyDuplicateUserRelatedResources"
@@ -239,11 +239,11 @@ const PageDuplicateModal = (): JSX.Element => {
             onChange={() => setOnlyDuplicateUserRelatedResources(!onlyDuplicateUserRelatedResources)}
           />
           <label className="form-label form-check-label" htmlFor="cbOnlyDuplicateUserRelatedResources">
-            { t('modal_duplicate.label.Only duplicate user related resources') }
-            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_user_related_resources') }</p>
+            { t('modal_duplicate.label.Only duplicate user related pages') }
+            <p className="form-text text-muted my-0">{ t('modal_duplicate.help.only_inherit_user_related_groups') }</p>
           </label>
         </div>
-        <div>
+        <div className="mt-3">
           {isDuplicateRecursively && existingPaths.length !== 0 && (
             <DuplicatePathsTable existingPaths={existingPaths} fromPath={path} toPath={pageNameInput} />
           ) }

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov