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

Merge branch 'dev/7.0.x' into fix/140329-141060-fix-display-of-PageTitle-in-PageTree

kosei-n 2 лет назад
Родитель
Сommit
549cacce45
100 измененных файлов с 881 добавлено и 748 удалено
  1. 1 1
      .devcontainer/Dockerfile
  2. 6 6
      .github/workflows/ci-app-prod.yml
  3. 3 3
      .github/workflows/ci-app.yml
  4. 3 3
      .github/workflows/ci-slackbot-proxy.yml
  5. 1 1
      .github/workflows/list-unhealthy-branches.yml
  6. 1 1
      .github/workflows/release-slackbot-proxy.yml
  7. 2 2
      .github/workflows/release.yml
  8. 3 3
      .mergify.yml
  9. 1 1
      README.md
  10. 1 1
      README_JP.md
  11. 0 0
      apps/app/_obsolete/src/components/PageEditor/AbstractEditor.tsx
  12. 0 0
      apps/app/_obsolete/src/components/PageEditor/Editor.module.scss
  13. 1 1
      apps/app/_obsolete/src/components/PageEditor/Editor.tsx
  14. 0 0
      apps/app/_obsolete/src/components/PageEditor/EditorIcon.jsx
  15. 0 0
      apps/app/_obsolete/src/components/PageEditor/PasteHelper.js
  16. 0 0
      apps/app/_obsolete/src/components/PageEditor/PreventMarkdownListInterceptor.js
  17. 0 0
      apps/app/_obsolete/src/components/PageEditor/TextAreaEditor.jsx
  18. 4 4
      apps/app/docker/Dockerfile
  19. 5 5
      apps/app/package.json
  20. 4 3
      apps/app/public/static/locales/en_US/admin.json
  21. 13 0
      apps/app/public/static/locales/en_US/translation.json
  22. 4 3
      apps/app/public/static/locales/ja_JP/admin.json
  23. 13 0
      apps/app/public/static/locales/ja_JP/translation.json
  24. 4 3
      apps/app/public/static/locales/zh_CN/admin.json
  25. 13 0
      apps/app/public/static/locales/zh_CN/translation.json
  26. 1 1
      apps/app/src/client/services/create-page/use-create-template-page.ts
  27. 12 1
      apps/app/src/client/services/page-operation.ts
  28. 1 1
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  29. 3 1
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  30. 2 2
      apps/app/src/client/util/bookmark-utils.ts
  31. 9 10
      apps/app/src/components/Admin/AuditLog/SearchUsernameTypeahead.tsx
  32. 5 1
      apps/app/src/components/Admin/Security/SamlSecuritySettingContents.jsx
  33. 1 1
      apps/app/src/components/Admin/Security/SecurityManagementContents.jsx
  34. 2 2
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  35. 9 3
      apps/app/src/components/Admin/SlackIntegration/Bridge.jsx
  36. 1 1
      apps/app/src/components/Admin/SlackIntegration/CustomBotWithoutProxySettingsAccordion.jsx
  37. 1 1
      apps/app/src/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  38. 22 23
      apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx
  39. 2 3
      apps/app/src/components/Admin/UserGroup/UserGroupModal.tsx
  40. 4 2
      apps/app/src/components/Admin/UserGroup/UserGroupPage.tsx
  41. 1 1
      apps/app/src/components/Admin/UserGroup/UserGroupTable.tsx
  42. 1 1
      apps/app/src/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  43. 4 3
      apps/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  44. 0 169
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  45. 122 0
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.tsx
  46. 6 5
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  47. 2 2
      apps/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  48. 1 1
      apps/app/src/components/Admin/UserManagement.module.scss
  49. 4 3
      apps/app/src/components/Admin/UserManagement.tsx
  50. 1 1
      apps/app/src/components/Admin/Users/ExternalAccountTable.tsx
  51. 2 1
      apps/app/src/components/Admin/Users/UserMenu.tsx
  52. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderItemControl.tsx
  53. 0 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  54. 1 1
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  55. 3 2
      apps/app/src/components/Common/PagePathHierarchicalLink/PagePathHierarchicalLink.tsx
  56. 6 2
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  57. 1 1
      apps/app/src/components/ContentLinkButtons.tsx
  58. 4 2
      apps/app/src/components/ExpandOrContractButton.tsx
  59. 0 5
      apps/app/src/components/ItemsTree/ItemsTree.module.scss
  60. 4 2
      apps/app/src/components/ItemsTree/ItemsTree.tsx
  61. 1 1
      apps/app/src/components/Me/ExternalAccountLinkedMe.jsx
  62. 14 0
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  63. 3 1
      apps/app/src/components/Navbar/PageEditorModeManager.tsx
  64. 3 3
      apps/app/src/components/Page/PageView.tsx
  65. 1 1
      apps/app/src/components/PageAccessoriesModal/PageAttachment.tsx
  66. 2 0
      apps/app/src/components/PageAlert/PageAlerts.tsx
  67. 53 0
      apps/app/src/components/PageAlert/WipPageAlert.tsx
  68. 2 2
      apps/app/src/components/PageAttachment/DeleteAttachmentModal.tsx
  69. 3 3
      apps/app/src/components/PageComment/CommentEditor.tsx
  70. 2 2
      apps/app/src/components/PageComment/ReplyComments.tsx
  71. 4 0
      apps/app/src/components/PageContentFooter.tsx
  72. 6 6
      apps/app/src/components/PageControls/PageControls.tsx
  73. 2 31
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  74. 1 1
      apps/app/src/components/PageEditor/HandsontableModal.tsx
  75. 211 170
      apps/app/src/components/PageEditor/OptionsSelector.tsx
  76. 39 44
      apps/app/src/components/PageEditor/PageEditor.tsx
  77. 1 0
      apps/app/src/components/PageEditor/Preview.module.scss
  78. 5 6
      apps/app/src/components/PageEditor/Preview.tsx
  79. 1 1
      apps/app/src/components/PageEditor/page-path-rename-utils.ts
  80. 5 1
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  81. 3 2
      apps/app/src/components/PagePresentationModal.tsx
  82. 8 5
      apps/app/src/components/PageSideContents/PageSideContents.tsx
  83. 1 1
      apps/app/src/components/PageStatusAlert.tsx
  84. 7 19
      apps/app/src/components/PageTags/TagsInput.tsx
  85. 1 1
      apps/app/src/components/PageTimeline.tsx
  86. 29 1
      apps/app/src/components/SavePageControls.tsx
  87. 5 5
      apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx
  88. 8 17
      apps/app/src/components/SearchPage.tsx
  89. 5 5
      apps/app/src/components/SearchPage/OperateAllControl.tsx
  90. 9 0
      apps/app/src/components/SearchPage/SearchControl.module.scss
  91. 24 22
      apps/app/src/components/SearchPage/SearchControl.tsx
  92. 4 3
      apps/app/src/components/SearchPage/SearchOptionModal.tsx
  93. 15 17
      apps/app/src/components/SearchPage/SearchResultContent.tsx
  94. 3 0
      apps/app/src/components/SearchPage/SortControl.module.scss
  95. 32 35
      apps/app/src/components/SearchPage/SortControl.tsx
  96. 24 24
      apps/app/src/components/SearchTypeahead.tsx
  97. 1 1
      apps/app/src/components/ShareLinkPageView.tsx
  98. 4 6
      apps/app/src/components/Sidebar/Bookmarks.tsx
  99. 4 6
      apps/app/src/components/Sidebar/Custom/CustomSidebar.tsx
  100. 13 4
      apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

+ 1 - 1
.devcontainer/Dockerfile

@@ -3,7 +3,7 @@
 # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
 #-------------------------------------------------------------------------------------------------------------
 
-FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-18
+FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-20
 
 # The node image includes a non-root user with sudo access. Use the
 # "remoteUser" property in devcontainer.json to use it. On Linux, update

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

@@ -51,16 +51,16 @@ jobs:
   test-prod-node16:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@dev/7.0.x
     with:
-      node-version: 16.x
+      node-version: 18.x
       skip-cypress: true
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  test-prod-node18:
+  test-prod-node20:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@dev/7.0.x
     with:
-      node-version: 18.x
+      node-version: 20.x
       skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       cypress-report-artifact-name: Cypress report
       cypress-config-video: ${{ inputs.cypress-config-video || false }}
@@ -68,15 +68,15 @@ jobs:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
-  run-reg-suit-node18:
-    needs: [test-prod-node18]
+  run-reg-suit-node20:
+    needs: [test-prod-node20]
 
     uses: weseek/growi/.github/workflows/reusable-app-reg-suit.yml@dev/7.0.x
 
     if: always()
 
     with:
-      node-version: 18.x
+      node-version: 20.x
       skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
       cypress-report-artifact-name: Cypress report
     secrets:

+ 3 - 3
.github/workflows/ci-app.yml

@@ -27,7 +27,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     steps:
       - uses: actions/checkout@v3
@@ -92,7 +92,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mongodb:
@@ -174,7 +174,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mongodb:

+ 3 - 3
.github/workflows/ci-slackbot-proxy.yml

@@ -29,7 +29,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     steps:
     - uses: actions/checkout@v3
@@ -94,7 +94,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mysql:
@@ -179,7 +179,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [18.x]
+        node-version: [20.x]
 
     services:
       mysql:

+ 1 - 1
.github/workflows/list-unhealthy-branches.yml

@@ -16,7 +16,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '16'
+        node-version: '18'
 
     - name: List branches
       id: list-branches

+ 1 - 1
.github/workflows/release-slackbot-proxy.yml

@@ -102,7 +102,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '16'
+        node-version: '18'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 

+ 2 - 2
.github/workflows/release.yml

@@ -24,7 +24,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '18'
+        node-version: '20'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
@@ -189,7 +189,7 @@ jobs:
 
     - uses: actions/setup-node@v3
       with:
-        node-version: '18'
+        node-version: '20'
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 

+ 3 - 3
.mergify.yml

@@ -3,9 +3,9 @@ pull_request_rules:
     conditions:
       - author = dependabot[bot]
       - '#approved-reviews-by >= 1'
-      - check-success = "lint (18.x)"
-      - check-success = "test (18.x)"
-      - check-success = "launch-dev (18.x)"
+      - check-success = "lint (20.x)"
+      - check-success = "test (20.x)"
+      - check-success = "launch-dev (20.x)"
       - check-success = "test-prod-node16 / launch-prod"
       - check-success = "test-prod-node18 / launch-prod"
     actions:

+ 1 - 1
README.md

@@ -79,7 +79,7 @@ See [GROWI Docs: Environment Variables](https://docs.growi.org/en/admin-guide/ad
 
 ## Dependencies
 
-- Node.js v16.x or v18.x
+- Node.js v18.x or v20.x
 - npm 6.x
 - yarn
 - [Turborepo](https://turbo.build/repo)

+ 1 - 1
README_JP.md

@@ -78,7 +78,7 @@ Crowi からの移行は **[こちら](https://docs.growi.org/en/admin-guide/mig
 
 ## 依存関係
 
-- Node.js v16.x or v18.x
+- Node.js v18.x or v20.x
 - npm 6.x
 - yarn
 - [Turborepo](https://turbo.build/repo)

+ 0 - 0
apps/app/src/components/PageEditor/AbstractEditor.tsx → apps/app/_obsolete/src/components/PageEditor/AbstractEditor.tsx


+ 0 - 0
apps/app/src/components/PageEditor/Editor.module.scss → apps/app/_obsolete/src/components/PageEditor/Editor.module.scss


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

@@ -343,7 +343,7 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
               className="btn btn-outline-secondary btn-open-dropzone"
               onClick={addAttachmentHandler}
             >
-              <i className="icon-paper-clip" aria-hidden="true"></i>&nbsp;
+              <span className="material-symbols-outlined" aria-hidden="true">attachment</span>&nbsp;
               Attach files
               <span className="d-none d-sm-inline">
               &nbsp;by dragging &amp; dropping,&nbsp;

+ 0 - 0
apps/app/src/components/PageEditor/EditorIcon.jsx → apps/app/_obsolete/src/components/PageEditor/EditorIcon.jsx


+ 0 - 0
apps/app/src/components/PageEditor/PasteHelper.js → apps/app/_obsolete/src/components/PageEditor/PasteHelper.js


+ 0 - 0
apps/app/src/components/PageEditor/PreventMarkdownListInterceptor.js → apps/app/_obsolete/src/components/PageEditor/PreventMarkdownListInterceptor.js


+ 0 - 0
apps/app/src/components/PageEditor/TextAreaEditor.jsx → apps/app/_obsolete/src/components/PageEditor/TextAreaEditor.jsx


+ 4 - 4
apps/app/docker/Dockerfile

@@ -4,7 +4,7 @@
 ##
 ## base
 ##
-FROM node:18-slim AS base
+FROM node:20-slim AS base
 
 ENV optDir /opt
 
@@ -18,7 +18,7 @@ RUN turbo prune --scope=@growi/app --docker
 ##
 ## deps-resolver
 ##
-FROM node:18-slim AS deps-resolver
+FROM node:20-slim AS deps-resolver
 
 ENV optDir /opt
 
@@ -62,7 +62,7 @@ RUN tar -cf node_modules.tar \
 ##
 ## builder
 ##
-FROM node:18-slim AS builder
+FROM node:20-slim AS builder
 
 ENV optDir /opt
 
@@ -107,7 +107,7 @@ RUN tar -cf packages.tar \
 ##
 ## release
 ##
-FROM node:18-slim
+FROM node:20-slim
 LABEL maintainer Yuki Takei <yuki@weseek.co.jp>
 
 ENV NODE_ENV production

+ 5 - 5
apps/app/package.json

@@ -62,7 +62,7 @@
     "@akebifiky/remark-simple-plantuml": "^1.0.2",
     "@aws-sdk/client-s3": "3.454.0",
     "@aws-sdk/s3-request-presigner": "3.454.0",
-    "@azure/identity": "^3.3.2",
+    "@azure/identity": "^4.0.1",
     "@azure/storage-blob": "^12.16.0",
     "@browser-bunyan/console-formatted-stream": "^1.8.0",
     "@elastic/elasticsearch7": "npm:@elastic/elasticsearch@^7.17.0",
@@ -101,7 +101,7 @@
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
     "csurf": "^1.11.0",
-    "csv-to-markdown-table": "^1.1.0",
+    "csv-to-markdown-table": "^1.4.1",
     "date-fns": "^2.23.0",
     "dayjs": "^1.11.7",
     "detect-indent": "^7.0.0",
@@ -131,7 +131,7 @@
     "is-iso-date": "^0.0.1",
     "ldapjs": "^3.0.2",
     "lucene-query-parser": "^1.2.0",
-    "markdown-table": "^1.1.1",
+    "markdown-table": "^3.0.3",
     "md5": "^2.2.1",
     "mermaid": "^10.1.0",
     "method-override": "^3.0.0",
@@ -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",
@@ -214,7 +214,7 @@
     "xss": "^1.0.14",
     "y-mongodb-provider": "^0.1.7",
     "y-socket.io": "^1.1.0",
-    "yjs": "^13.6.7"
+    "yjs": "^13.6.12"
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",

+ 4 - 3
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.",
@@ -834,9 +834,10 @@
       "dropdown_desc": "Choose an action for private pages",
       "select_group": "Select a group",
       "no_groups": "No groups to select",
-      "publish_pages": "Publish all",
+      "publish_pages": "Publish pages that are publishable",
       "delete_pages": "Delete all",
-      "transfer_pages": "Transfer to another group"
+      "transfer_pages": "Transfer to another group",
+      "option_explanation": "A \"publishable\" page is a page visible only to the group you want to delete. Pages that can be viewed by other groups will not be published."
     },
     "update_parent_confirm_modal": {
       "header": "The parent of the group will be changed",

+ 13 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -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",
@@ -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"
   }
 }

+ 4 - 3
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": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
@@ -844,9 +844,10 @@
       "dropdown_desc": "削除するグループの限定公開ページの処理を選択してください",
       "select_group": "グループを選択してください",
       "no_groups": "グループがありません",
-      "publish_pages": "全て公開する",
+      "publish_pages": "公開可能なページを公開する",
       "delete_pages": "全て削除する",
-      "transfer_pages": "全て他のグループに移譲する"
+      "transfer_pages": "全て他のグループに移譲する",
+      "option_explanation": "「公開可能なページ」とは、削除するグループにのみ限定公開されているページを指します。他のグループも閲覧可能なページは公開対象となりません。"
     },
     "update_parent_confirm_modal": {
       "header": "グループの親が変更されます",

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

@@ -571,6 +571,7 @@
     "search_again" : "再検索",
     "number_of_list_to_display" : "表示件数",
     "page_number_unit" : "件",
+    "hit_number_unit" : "件",
     "sort_axis": {
       "relationScore": "関連度順",
       "createdAt": "作成日時",
@@ -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 を表示"
   }
 }

+ 4 - 3
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": "创建的用户具有临时密码",
@@ -843,9 +843,10 @@
       "dropdown_desc": "为私人页选择操作",
       "select_group": "选择组",
       "no_groups": "没有可选择的组",
-      "publish_pages": "全部发布",
+      "publish_pages": "发布可以发布的页面",
       "delete_pages": "全部删除",
-      "transfer_pages": "转移到另一组"
+      "transfer_pages": "转移到另一组",
+      "option_explanation": "\"可发布页面\"是指仅对您要删除的群组可见的页面。其他群组可以查看的页面将不会被发布。"
     },
     "update_parent_confirm_modal": {
       "header": "该组的父组被改变",

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

@@ -541,6 +541,7 @@
     "search_again" : "再次搜索",
     "number_of_list_to_display" : "显示器的数量",
     "page_number_unit" : "例",
+    "hit_number_unit" : "例",
     "sort_axis": {
       "relationScore": "按相关性排序",
       "createdAt": "按创建日期排序",
@@ -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"
   }
 }

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

@@ -25,7 +25,7 @@ export const useCreateTemplatePage: UseCreateTemplatePage = () => {
     if (isLoadingPagePath || !isCreatable) return;
 
     return createAndTransit(
-      { path: normalizePath(`${currentPagePath}/${label}`) },
+      { path: normalizePath(`${currentPagePath}/${label}`), wip: false },
       { shouldCheckPageExists: true },
     );
   }, [currentPagePath, isCreatable, isLoadingPagePath, createAndTransit]);

+ 12 - 1
apps/app/src/client/services/page-operation.ts

@@ -1,5 +1,6 @@
 import { useCallback } from 'react';
 
+import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
@@ -124,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
@@ -159,3 +160,13 @@ 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;
+};

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

@@ -34,7 +34,7 @@ export const useDrawioModalLauncherForView = (opts?: {
   const { open: openDrawioModal } = useDrawioModal();
 
   const saveByDrawioModal = useCallback(async(drawioMxFile: string, bol: number, eol: number) => {
-    if (currentPage == null || shareLinkId != null) {
+    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 

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

@@ -33,7 +33,7 @@ export const useHandsontableModalLauncherForView = (opts?: {
   const { open: openHandsontableModal } = useHandsontableModal();
 
   const saveByHandsontableModal = useCallback(async(table: MarkdownTable, bol: number, eol: number) => {
-    if (currentPage == null || shareLinkId != null) {
+    if (currentPage == null || currentPage.revision == null || shareLinkId != null) {
       return;
     }
 
@@ -64,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));

+ 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}

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

@@ -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>

+ 2 - 2
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>
@@ -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/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)}>

+ 22 - 23
apps/app/src/components/Admin/UserGroup/UserGroupDeleteModal.tsx

@@ -1,6 +1,5 @@
-import React, {
-  FC, useCallback, useState, useMemo,
-} from 'react';
+import type { FC } from 'react';
+import React, { useCallback, useState, useMemo } from 'react';
 
 import type { IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -8,6 +7,8 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
+
 
 /**
  * Delete User Group Select component
@@ -19,26 +20,19 @@ import {
 type Props = {
   userGroups: IUserGroupHasId[],
   deleteUserGroup?: IUserGroupHasId,
-  onDelete?: (deleteGroupId: string, actionName: string, transferToUserGroupId: string) => Promise<void> | void,
+  onDelete?: (deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => Promise<void> | void,
   isShow: boolean,
   onHide?: () => Promise<void> | void,
 };
 
 type AvailableOption = {
   id: number,
-  actionForPages: string,
+  actionForPages: PageActionOnGroupDelete,
   iconClass: string,
   styleClass: string,
   label: string,
 };
 
-// actionName master constants
-const actionForPages = {
-  public: 'public',
-  delete: 'delete',
-  transfer: 'transfer',
-};
-
 export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
@@ -51,21 +45,21 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     return [
       {
         id: 1,
-        actionForPages: actionForPages.public,
+        actionForPages: PageActionOnGroupDelete.publicize,
         iconClass: 'icon-people',
         styleClass: '',
         label: t('admin:user_group_management.delete_modal.publish_pages'),
       },
       {
         id: 2,
-        actionForPages: actionForPages.delete,
+        actionForPages: PageActionOnGroupDelete.delete,
         iconClass: 'icon-trash',
         styleClass: 'text-danger',
         label: t('admin:user_group_management.delete_modal.delete_pages'),
       },
       {
         id: 3,
-        actionForPages: actionForPages.transfer,
+        actionForPages: PageActionOnGroupDelete.transfer,
         iconClass: 'icon-options',
         styleClass: '',
         label: t('admin:user_group_management.delete_modal.transfer_pages'),
@@ -76,14 +70,14 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   /*
    * State
    */
-  const [actionName, setActionName] = useState<string>('');
+  const [actionName, setActionName] = useState<PageActionOnGroupDelete | null>(null);
   const [transferToUserGroupId, setTransferToUserGroupId] = useState<string>('');
 
   /*
    * Function
    */
   const resetStates = useCallback(() => {
-    setActionName('');
+    setActionName(null);
     setTransferToUserGroupId('');
   }, []);
 
@@ -107,7 +101,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   }, []);
 
   const handleSubmit = useCallback((e) => {
-    if (onDelete == null || deleteUserGroup == null) {
+    if (onDelete == null || deleteUserGroup == null || actionName == null) {
       return;
     }
 
@@ -130,7 +124,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
         name="actionName"
         className="form-control"
         placeholder="select"
-        value={actionName}
+        value={actionName ?? ''}
         onChange={handleActionChange}
       >
         <option value="" disabled>{t('admin:user_group_management.delete_modal.dropdown_desc')}</option>
@@ -158,7 +152,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
     return (
       <select
         name="transferToUserGroupId"
-        className={`form-control ${actionName === actionForPages.transfer ? '' : 'd-none'}`}
+        className={`form-control ${actionName === PageActionOnGroupDelete.transfer ? '' : 'd-none'}`}
         value={transferToUserGroupId}
         onChange={handleGroupChange}
       >
@@ -171,10 +165,10 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
   const validateForm = useCallback(() => {
     let isValid = true;
 
-    if (actionName === '') {
+    if (actionName === null) {
       isValid = false;
     }
-    else if (actionName === actionForPages.transfer) {
+    else if (actionName === PageActionOnGroupDelete.transfer) {
       isValid = transferToUserGroupId !== '';
     }
 
@@ -196,7 +190,7 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
       </ModalBody>
       <ModalFooter>
         <form className="d-flex justify-content-between w-100" onSubmit={handleSubmit}>
-          <div className="d-flex mb-0">
+          <div className="d-flex mb-0 me-3">
             {renderPageActionSelector()}
             {renderGroupSelector()}
           </div>
@@ -204,6 +198,11 @@ export const UserGroupDeleteModal: FC<Props> = (props: Props) => {
             <span className="material-symbols-outlined">delete_forever</span> {t('Delete')}
           </button>
         </form>
+        {actionName === PageActionOnGroupDelete.publicize && (
+          <div className="form-text text-muted">
+            <small>{t('admin:user_group_management.delete_modal.option_explanation')}</small>
+          </div>
+        )}
       </ModalFooter>
     </Modal>
   );

+ 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';

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

@@ -1,4 +1,5 @@
-import React, { FC, useState, useCallback } from 'react';
+import type { FC } from 'react';
+import React, { useState, useCallback } from 'react';
 
 import type { IUserGroup, IUserGroupHasId } from '@growi/core';
 import dynamic from 'next/dynamic';
@@ -9,6 +10,7 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useIsAclEnabled } from '~/stores/context';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
+import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 
 
 const UserGroupDeleteModal = dynamic(() => import('./UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
@@ -126,7 +128,7 @@ export const UserGroupPage: FC = () => {
     }
   }, [t, mutateUserGroups, hideUpdateModal]);
 
-  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     try {
       await apiv3Delete(`/user-groups/${deleteGroupId}`, {
         actionName,

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

@@ -205,7 +205,7 @@ 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}>

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

@@ -28,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 ? (

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

@@ -13,8 +13,9 @@ import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
-import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
+import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
+import { SearchTypes } from '~/interfaces/user-group';
 import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
@@ -296,7 +297,7 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
     setDeleteModalShown(false);
   }, [setSelectedUserGroup, setDeleteModalShown]);
 
-  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: string, transferToUserGroupId: string) => {
+  const deleteChildUserGroupById = useCallback(async(deleteGroupId: string, actionName: PageActionOnGroupDelete, transferToUserGroupId: string) => {
     const url = isExternalGroup ? `/external-user-groups/${deleteGroupId}` : `/user-groups/${deleteGroupId}`;
     try {
       const res = await apiv3Delete(url, {

+ 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>

+ 2 - 2
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

+ 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>
                   )
                   : ''
               }

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

@@ -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>

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

@@ -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>

+ 1 - 1
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

+ 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;

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

@@ -181,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>

+ 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>

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

@@ -20,6 +20,7 @@ const { isTrashPage } = pagePathUtils;
 type Props = {
   pagePath: string,
   pageId?: string | null,
+  isWipPage?: boolean,
   isSingleLineMode?: boolean,
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
@@ -37,7 +38,7 @@ const Separator = (): JSX.Element => {
 
 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);
@@ -94,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>

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

@@ -40,7 +40,7 @@ RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
 
 export type ContentLinkButtonsProps = {
-  author?: IUserHasId,
+  author: IUserHasId | null,
 }
 
 export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {

+ 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 - 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 {

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

@@ -91,6 +91,7 @@ const isSecondStageRenderingCondition = (condition: RenderingCondition|SecondSta
 type ItemsTreeProps = {
   isEnableActions: boolean
   isReadOnlyUser: boolean
+  isWipPageShown?: boolean
   targetPath: string
   targetPathOrId?: Nullable<string>
   targetAndAncestorsData?: TargetAndAncestors
@@ -103,7 +104,7 @@ type ItemsTreeProps = {
  */
 export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const {
-    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, CustomTreeItem, onClickTreeItem,
+    targetPath, targetPathOrId, targetAndAncestorsData, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
   } = props;
 
   const { t } = useTranslation();
@@ -274,13 +275,14 @@ 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}

+ 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') }

+ 14 - 0
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';
 
@@ -167,6 +168,8 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { currentPage } = props;
 
+  const { t } = useTranslation();
+
   const router = useRouter();
 
   const { data: shareLinkId } = useShareLinkId();
@@ -317,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 && (

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

@@ -7,6 +7,8 @@ import { toastError } from '~/client/util/toastr';
 import { useIsNotFound } from '~/stores/page';
 import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 
+import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
+
 
 import styles from './PageEditorModeManager.module.scss';
 
@@ -72,7 +74,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
     try {
       await createAndTransit(
-        { path },
+        { path, wip: shouldCreateWipPage(path) },
         { shouldCheckPageExists: true },
       );
     }

+ 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} />;
     }
 

+ 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(() => {

+ 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 />

+ 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>

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

@@ -44,11 +44,11 @@ const SlackNotification = dynamic(() => import('../SlackNotification').then(mod
 
 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',
   },
 };
@@ -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>

+ 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">

+ 6 - 6
apps/app/src/components/PageControls/PageControls.tsx

@@ -52,7 +52,7 @@ const Tags = (props: TagsProps): JSX.Element => {
         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>
@@ -107,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,
@@ -178,7 +178,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     const page: IPageToRenameWithMeta = {
       data: {
         _id: pageId,
-        revision: revisionId,
+        revision: revisionId ?? null,
         path,
       },
       meta: pageInfo,
@@ -195,7 +195,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
         _id: pageId,
-        revision: revisionId,
+        revision: revisionId ?? null,
         path,
       },
       meta: pageInfo,
@@ -311,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,
 };
@@ -346,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}

+ 2 - 31
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -26,7 +26,6 @@ const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSel
 
 const EditorNavbarBottom = (): JSX.Element => {
 
-  const [isExpanded, setExpanded] = useState(false);
   const [isSlackExpanded, setSlackExpanded] = useState(false);
 
   const { data: editorMode } = useEditorMode();
@@ -57,23 +56,8 @@ const EditorNavbarBottom = (): JSX.Element => {
     setSlackChannelsStr(slackChannels);
   }, []);
 
-
-  const renderExpandButton = () => (
-    <div className="d-md-none ms-2">
-      <button
-        type="button"
-        className={`btn btn-outline-secondary btn-expand border-0 ${isExpanded ? 'expand' : ''}`}
-        onClick={() => setExpanded(!isExpanded)}
-      >
-        <i className="icon-arrow-up"></i>
-      </button>
-    </div>
-  );
-
-  const isCollapsedOptionsSelectorEnabled = !isDeviceLargerThanLg;
-
   return (
-    <div className={`${isCollapsedOptionsSelectorEnabled ? 'fixed-bottom' : ''} `} data-testid="grw-editor-navbar-bottom">
+    <div data-testid="grw-editor-navbar-bottom">
       {/* Collapsed SlackNotification */}
       {isSlackConfigured && (
         <Collapse isOpen={isSlackExpanded && !isDeviceLargerThanLg}>
@@ -95,7 +79,7 @@ const EditorNavbarBottom = (): JSX.Element => {
       }
       <div className={`flex-expand-horiz align-items-center px-2 px-md-3 ${moduleClass}`}>
         <form>
-          { isDeviceLargerThanMd && <OptionsSelector /> }
+          <OptionsSelector collapsed={!isDeviceLargerThanMd} />
         </form>
         <form className="row row-cols-lg-auto g-3 align-items-center ms-auto">
           {/* Responsive Design for the SlackNotification */}
@@ -125,21 +109,8 @@ const EditorNavbarBottom = (): JSX.Element => {
             </div>
           ))}
           <SavePageControls slackChannels={slackChannelsStr} />
-          { isCollapsedOptionsSelectorEnabled && renderExpandButton() }
         </form>
       </div>
-      {/* Collapsed OptionsSelector */}
-      { isCollapsedOptionsSelectorEnabled && (
-        <Collapse isOpen={isExpanded}>
-          <div className="px-2"> {/* set padding for border-top */}
-            <div className={`navbar navbar-expand border-top px-0 ${moduleClass}`}>
-              <form className="ms-auto">
-                <OptionsSelector />
-              </form>
-            </div>
-          </div>
-        </Collapse>
-      ) }
     </div>
   );
 };

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

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
 
 import { useHandsontableModalForEditor } from '@growi/editor/src/stores/use-handsontable';
 import { HotTable } from '@handsontable/react';
-import Handsontable from 'handsontable';
+import type Handsontable from 'handsontable';
 import { useTranslation } from 'next-i18next';
 import {
   Collapse,

+ 211 - 170
apps/app/src/components/PageEditor/OptionsSelector.tsx

@@ -2,67 +2,114 @@ import React, {
   memo, useCallback, useMemo, useState,
 } from 'react';
 
+import type {
+  EditorTheme, KeyMapMode,
+} from '@growi/editor';
 import { useTranslation } from 'next-i18next';
+import Image from 'next/image';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown, DropdownToggle, DropdownMenu, Input, FormGroup,
 } from 'reactstrap';
 
 import { useIsIndentSizeForced } from '~/stores/context';
 import { useEditorSettings, useCurrentIndentSize } from '~/stores/editor';
 
-import { DEFAULT_THEME, type KeyMapMode } from '../../interfaces/editor-settings';
+import {
+  DEFAULT_THEME, DEFAULT_KEYMAP,
+} from '../../interfaces/editor-settings';
 
 
-const AVAILABLE_THEMES = [
-  'DefaultLight', 'Eclipse', 'Basic', 'Ayu', 'Rosé Pine', 'DefaultDark', 'Material', 'Nord', 'Cobalt', 'Kimbie',
-];
+type RadioListItemProps = {
+  onClick: () => void,
+  icon?: React.ReactNode,
+  text: string,
+  checked?: boolean
+}
 
-const TYPICAL_INDENT_SIZE = [2, 4];
+const RadioListItem = (props: RadioListItemProps): JSX.Element => {
+  const {
+    onClick, icon, text, checked,
+  } = props;
+  return (
+    <li className="list-group-item border-0 d-flex align-items-center">
+      <input
+        onClick={onClick}
+        className="form-check-input me-3"
+        type="radio"
+        name="listGroupRadio"
+        id={`editor_config_radio_item_${text}`}
+        checked={checked}
+      />
+      {icon}
+      <label className="form-check-label stretched-link fs-6" htmlFor={`editor_config_radio_item_${text}`}>{text}</label>
+    </li>
+  );
+};
+
+
+type SelectorProps = {
+  header: string,
+  onClickBefore: () => void,
+  items: JSX.Element,
+}
 
+const Selector = (props: SelectorProps): JSX.Element => {
+
+  const { header, onClickBefore, items } = props;
+  return (
+    <div className="d-flex flex-column w-100">
+      <button type="button" className="btn border-0 d-flex align-items-center text-muted ms-2" onClick={onClickBefore}>
+        <span className="material-symbols-outlined fs-5 py-0 me-1">navigate_before</span>
+        <label>{header}</label>
+      </button>
+      <hr className="my-1" />
+      <ul className="list-group d-flex ms-2">
+        { items }
+      </ul>
+    </div>
+  );
+
+};
+
+
+type EditorThemeToLabel = {
+  [key in EditorTheme]: string;
+}
 
-const ThemeSelector = (): JSX.Element => {
+const EDITORTHEME_LABEL_MAP: EditorThemeToLabel = {
+  defaultlight: 'DefaultLight',
+  eclipse: 'Eclipse',
+  basic: 'Basic',
+  ayu: 'Ayu',
+  rosepine: 'Rosé Pine',
+  defaultdark: 'DefaultDark',
+  material: 'Material',
+  nord: 'Nord',
+  cobalt: 'Cobalt',
+  kimbie: 'Kimbie',
+};
 
-  const [isThemeMenuOpened, setIsThemeMenuOpened] = useState(false);
+const ThemeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
 
   const { data: editorSettings, update } = useEditorSettings();
+  const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME;
 
-  const menuItems = useMemo(() => (
+  const listItems = useMemo(() => (
     <>
-      { AVAILABLE_THEMES.map((theme) => {
+      { (Object.keys(EDITORTHEME_LABEL_MAP) as EditorTheme[]).map((theme) => {
+        const themeLabel = EDITORTHEME_LABEL_MAP[theme];
         return (
-          <DropdownItem className="menuitem-label" onClick={() => update({ theme })}>
-            {theme}
-          </DropdownItem>
+          <RadioListItem onClick={() => update({ theme })} text={themeLabel} checked={theme === selectedTheme} />
         );
       }) }
     </>
-  ), [update]);
-
-  const selectedTheme = editorSettings?.theme ?? DEFAULT_THEME;
+  ), [update, selectedTheme]);
 
   return (
-    <div className="input-group flex-nowrap">
-      <div>
-        <span className="input-group-text" id="igt-theme">Theme</span>
-      </div>
-
-      <Dropdown
-        direction="up"
-        isOpen={isThemeMenuOpened}
-        toggle={() => setIsThemeMenuOpened(!isThemeMenuOpened)}
-      >
-        <DropdownToggle color="outline-secondary" caret>
-          {selectedTheme}
-        </DropdownToggle>
-
-        <DropdownMenu container="body">
-          {menuItems}
-        </DropdownMenu>
-
-      </Dropdown>
-    </div>
+    <Selector header="Theme" onClickBefore={onClickBefore} items={listItems} />
   );
-};
+});
+ThemeSelector.displayName = 'ThemeSelector';
 
 
 type KeyMapModeToLabel = {
@@ -76,105 +123,74 @@ const KEYMAP_LABEL_MAP: KeyMapModeToLabel = {
   vscode: 'Visual Studio Code',
 };
 
-const KeymapSelector = memo((): JSX.Element => {
-
-  const [isKeyMenuOpened, setIsKeyMenuOpened] = useState(false);
+const KeymapSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
 
   const { data: editorSettings, update } = useEditorSettings();
+  const selectedKeymapMode = editorSettings?.keymapMode ?? DEFAULT_KEYMAP;
 
-  const menuItems = useMemo(() => (
+  const listItems = useMemo(() => (
     <>
       { (Object.keys(KEYMAP_LABEL_MAP) as KeyMapMode[]).map((keymapMode) => {
         const keymapLabel = KEYMAP_LABEL_MAP[keymapMode];
         const icon = (keymapMode !== 'default')
-          ? <img src={`/images/icons/${keymapMode}.png`} width="16px" className="me-2"></img>
+          ? <Image src={`/images/icons/${keymapMode}.png`} width={16} height={16} className="me-2" alt={keymapMode} />
           : null;
         return (
-          <DropdownItem className="menuitem-label" onClick={() => update({ keymapMode })}>
-            {icon}{keymapLabel}
-          </DropdownItem>
+          <RadioListItem onClick={() => update({ keymapMode })} icon={icon} text={keymapLabel} checked={keymapMode === selectedKeymapMode} />
         );
       }) }
     </>
-  ), [update]);
+  ), [update, selectedKeymapMode]);
 
-  const selectedKeymapMode = editorSettings?.keymapMode ?? 'default';
 
   return (
-    <div className="input-group flex-nowrap">
-      <span className="input-group-text" id="igt-keymap">Keymap</span>
-      <Dropdown
-        direction="up"
-        isOpen={isKeyMenuOpened}
-        toggle={() => setIsKeyMenuOpened(!isKeyMenuOpened)}
-      >
-        <DropdownToggle color="outline-secondary" caret>
-          {selectedKeymapMode}
-        </DropdownToggle>
-
-        <DropdownMenu container="body">
-          {menuItems}
-        </DropdownMenu>
-
-      </Dropdown>
-    </div>
+    <Selector header="Keymap" onClickBefore={onClickBefore} items={listItems} />
   );
-
 });
-
 KeymapSelector.displayName = 'KeymapSelector';
 
-type IndentSizeSelectorProps = {
-  isIndentSizeForced: boolean,
-  selectedIndentSize: number,
-  onChange: (indentSize: number) => void,
-}
 
-const IndentSizeSelector = memo(({ isIndentSizeForced, selectedIndentSize, onChange }: IndentSizeSelectorProps): JSX.Element => {
+const TYPICAL_INDENT_SIZE = [2, 4];
+
+const IndentSizeSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
 
-  const [isIndentMenuOpened, setIsIndentMenuOpened] = useState(false);
+  const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
 
-  const menuItems = useMemo(() => (
+  const listItems = useMemo(() => (
     <>
       { TYPICAL_INDENT_SIZE.map((indent) => {
         return (
-          <DropdownItem className="menuitem-label" onClick={() => onChange(indent)}>
-            {indent}
-          </DropdownItem>
+          <RadioListItem onClick={() => mutateCurrentIndentSize(indent)} text={indent.toString()} checked={indent === currentIndentSize} />
         );
       }) }
     </>
-  ), [onChange]);
+  ), [currentIndentSize, mutateCurrentIndentSize]);
 
   return (
-    <div className="input-group flex-nowrap">
-      <span className="input-group-text" id="igt-indent">Indent</span>
-      <Dropdown
-        direction="up"
-        isOpen={isIndentMenuOpened}
-        toggle={() => setIsIndentMenuOpened(!isIndentMenuOpened)}
-        disabled={isIndentSizeForced}
-      >
-        <DropdownToggle color="outline-secondary" caret>
-          {selectedIndentSize}
-        </DropdownToggle>
-
-        <DropdownMenu container="body">
-          {menuItems}
-        </DropdownMenu>
-
-      </Dropdown>
-    </div>
+    <Selector header="Indent" onClickBefore={onClickBefore} items={listItems} />
   );
 });
-
 IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
-const ConfigurationDropdown = memo((): JSX.Element => {
-  const { t } = useTranslation();
+type SwitchItemProps = {
+  onClick: () => void,
+  checked: boolean,
+  text: string,
+};
+const SwitchItem = memo((props: SwitchItemProps): JSX.Element => {
+  const { onClick, checked, text } = props;
+  return (
+    <FormGroup switch>
+      <Input type="switch" checked={checked} onClick={onClick} />
+      <label>{text}</label>
+    </FormGroup>
 
-  const [isCddMenuOpened, setCddMenuOpened] = useState(false);
+  );
+});
+
+const ConfigurationSelector = memo((): JSX.Element => {
+  const { t } = useTranslation();
 
   const { data: editorSettings, update } = useEditorSettings();
 
@@ -185,20 +201,8 @@ const ConfigurationDropdown = memo((): JSX.Element => {
 
     const isActive = editorSettings.styleActiveLine;
 
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
     return (
-      <DropdownItem toggle={false} onClick={() => update({ styleActiveLine: !isActive })}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"></span>
-          <span className="menuitem-label">{ t('page_edit.Show active line') }</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
+      <SwitchItem onClick={() => update({ styleActiveLine: !isActive })} checked={isActive} text={t('page_edit.Show active line')} />
     );
   }, [editorSettings, update, t]);
 
@@ -209,81 +213,118 @@ const ConfigurationDropdown = memo((): JSX.Element => {
 
     const isActive = editorSettings.autoFormatMarkdownTable;
 
-    const iconClasses = ['text-info'];
-    if (isActive) {
-      iconClasses.push('ti ti-check');
-    }
-    const iconClassName = iconClasses.join(' ');
-
     return (
-      <DropdownItem toggle={false} onClick={() => update({ autoFormatMarkdownTable: !isActive })}>
-        <div className="d-flex justify-content-between">
-          <span className="icon-container"></span>
-          <span className="menuitem-label">{ t('page_edit.auto_format_table') }</span>
-          <span className="icon-container"><i className={iconClassName}></i></span>
-        </div>
-      </DropdownItem>
+      <SwitchItem onClick={() => update({ autoFormatMarkdownTable: !isActive })} checked={isActive} text={t('page_edit.auto_format_table')} />
     );
   }, [editorSettings, t, update]);
 
   return (
-    <div className="my-0">
-      <Dropdown
-        direction="up"
-        className="grw-editor-configuration-dropdown"
-        isOpen={isCddMenuOpened}
-        toggle={() => setCddMenuOpened(!isCddMenuOpened)}
-      >
-
-        <DropdownToggle color="outline-secondary" caret>
-          <i className="icon-settings"></i>
-        </DropdownToggle>
-
-        <DropdownMenu container="body">
-          {renderActiveLineMenuItem()}
-          {renderMarkdownTableAutoFormattingMenuItem()}
-          {/* <DropdownItem divider /> */}
-        </DropdownMenu>
-
-      </Dropdown>
+    <div className="mx-3 mt-1">
+      {renderActiveLineMenuItem()}
+      {renderMarkdownTableAutoFormattingMenuItem()}
     </div>
   );
+});
+ConfigurationSelector.displayName = 'ConfigurationSelector';
+
 
+type ChangeStateButtonProps = {
+  onClick: () => void,
+  header: string,
+  data: string,
+  disabled?: boolean,
+}
+const ChangeStateButton = memo((props: ChangeStateButtonProps): JSX.Element => {
+  const {
+    onClick, header, data, disabled,
+  } = props;
+  return (
+    <button type="button" className="d-flex align-items-center btn btn-sm border-0 my-1" disabled={disabled} onClick={onClick}>
+      <label className="ms-2 me-auto">{header}</label>
+      <label className="text-muted d-flex align-items-center ms-2 me-1">
+        {data}
+        <span className="material-symbols-outlined fs-5 py-0">navigate_next</span>
+      </label>
+    </button>
+  );
 });
 
-ConfigurationDropdown.displayName = 'ConfigurationDropdown';
 
+const OptionsStatus = {
+  Home: 'Home',
+  Theme: 'Theme',
+  Keymap: 'Keymap',
+  Indent: 'Indent',
+} as const;
+type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
+
+export const OptionsSelector = ({ collapsed }: {collapsed?: boolean}): JSX.Element => {
 
-export const OptionsSelector = (): JSX.Element => {
+  const [dropdownOpen, setDropdownOpen] = useState(false);
+
+  const [status, setStatus] = useState<OptionStatus>(OptionsStatus.Home);
   const { data: editorSettings } = useEditorSettings();
+  const { data: currentIndentSize } = useCurrentIndentSize();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
-  const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
 
-  if (editorSettings == null || isIndentSizeForced == null || currentIndentSize == null) {
+  if (editorSettings == null || currentIndentSize == null || isIndentSizeForced == null) {
     return <></>;
   }
 
   return (
-    <>
-      <div className="d-flex flex-row zindex-dropdown">
-        <span>
-          <ThemeSelector />
-        </span>
-        <span className="d-none d-sm-block ms-2 ms-sm-4">
-          <KeymapSelector />
-        </span>
-        <span className="ms-2 ms-sm-4">
-          <IndentSizeSelector
-            isIndentSizeForced={isIndentSizeForced}
-            selectedIndentSize={currentIndentSize}
-            onChange={newValue => mutateCurrentIndentSize(newValue)}
-          />
-        </span>
-        <span className="ms-2 ms-sm-4">
-          <ConfigurationDropdown />
-        </span>
-      </div>
-    </>
+    <Dropdown isOpen={dropdownOpen} toggle={() => { setStatus(OptionsStatus.Home); setDropdownOpen(!dropdownOpen) }} direction="up" className="">
+      <DropdownToggle
+        className={`btn btn-outline-neutral-secondary d-flex align-items-center justify-content-center p-1 m-1
+              ${collapsed ? 'border-0' : 'border border-secondary'}
+              ${dropdownOpen ? 'active' : ''}
+              `}
+      >
+        <span className="material-symbols-outlined py-0 fs-5"> settings </span>
+        {
+          collapsed ? <></>
+            : <label className="ms-1 me-1">Editor Config</label>
+        }
+      </DropdownToggle>
+      <DropdownMenu container="body">
+        {
+          status === OptionsStatus.Home && (
+            <div className="d-flex flex-column">
+              <label className="text-muted ms-3">
+                Editor Config
+              </label>
+              <hr className="my-1" />
+              <ChangeStateButton onClick={() => setStatus(OptionsStatus.Theme)} header="Theme" data={EDITORTHEME_LABEL_MAP[editorSettings.theme ?? ''] ?? ''} />
+              <hr className="my-1" />
+              <ChangeStateButton
+                onClick={() => setStatus(OptionsStatus.Keymap)}
+                header="Keymap"
+                data={KEYMAP_LABEL_MAP[editorSettings.keymapMode ?? ''] ?? ''}
+              />
+              <hr className="my-1" />
+              <ChangeStateButton
+                disabled={isIndentSizeForced}
+                onClick={() => setStatus(OptionsStatus.Indent)}
+                header="Indent"
+                data={currentIndentSize.toString() ?? ''}
+              />
+              <hr className="my-1" />
+              <ConfigurationSelector />
+            </div>
+          )
+        }
+        { status === OptionsStatus.Theme && (
+          <ThemeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
+        )
+        }
+        { status === OptionsStatus.Keymap && (
+          <KeymapSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
+        )
+        }
+        { status === OptionsStatus.Indent && (
+          <IndentSizeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
+        )
+        }
+      </DropdownMenu>
+    </Dropdown>
   );
-
 };

+ 39 - 44
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -1,3 +1,4 @@
+import type { CSSProperties } from 'react';
 import React, {
   useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
 } from 'react';
@@ -12,9 +13,9 @@ import {
   CodeMirrorEditorMain, GlobalCodeMirrorEditorKey,
   useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
 } from '@growi/editor';
+import { useRect } from '@growi/ui/dist/utils';
 import detectIndent from 'detect-indent';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { throttle, debounce } from 'throttle-debounce';
 
 
@@ -31,7 +32,7 @@ import {
 } from '~/stores/context';
 import {
   useEditorSettings,
-  useCurrentIndentSize, useIsSlackEnabled, usePageTagsForEditors,
+  useCurrentIndentSize, usePageTagsForEditors,
   useIsConflict,
   useEditingMarkdown,
   useWaitingSaveProcessing,
@@ -59,7 +60,6 @@ import { PageHeader } from '../PageHeader/PageHeader';
 
 // import { ConflictDiffModal } from './PageEditor/ConflictDiffModal';
 // import { ConflictDiffModal } from './ConflictDiffModal';
-// import Editor from './Editor';
 import EditorNavbarBottom from './EditorNavbarBottom';
 import Preview from './Preview';
 import { scrollEditor, scrollPreview } from './ScrollSyncHelper';
@@ -86,9 +86,8 @@ type Props = {
 export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   const { t } = useTranslation();
-  const router = useRouter();
 
-  const previewRef = useRef<HTMLDivElement>(null);
+  const [previewRect, previewRef] = useRect();
 
   const { data: isNotFound } = useIsNotFound();
   const { data: pageId } = useCurrentPageId();
@@ -105,7 +104,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: isEditable } = useIsEditable();
   const { mutate: mutateWaitingSaveProcessing } = useWaitingSaveProcessing();
   const { data: editorMode, mutate: mutateEditorMode } = useEditorMode();
-  const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: isIndentSizeForced } = useIsIndentSizeForced();
   const { data: currentIndentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { data: defaultIndentSize } = useDefaultIndentSize();
@@ -133,13 +131,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { resolvedTheme } = useNextThemes();
   mutateResolvedTheme({ themeData: resolvedTheme });
 
-  // TODO: remove workaround
-  // for https://redmine.weseek.co.jp/issues/125923
-  const [createdPageRevisionIdWithAttachment, setCreatedPageRevisionIdWithAttachment] = useState();
-
-  // TODO: remove workaround
-  // for https://redmine.weseek.co.jp/issues/125923
-  const currentRevisionId = currentPage?.revision?._id ?? createdPageRevisionIdWithAttachment;
+  const currentRevisionId = currentPage?.revision?._id;
 
   const initialValueRef = useRef('');
   const initialValue = useMemo(() => {
@@ -170,15 +162,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const setMarkdownPreviewWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string) => {
     setMarkdownToPreview(value);
   })), []);
-  // const mutateIsEnabledUnsavedWarningWithDebounce = useMemo(() => debounce(600, throttle(900, (value: string) => {
-  //   // Displays an unsaved warning alert
-  //   mutateIsEnabledUnsavedWarning(value !== initialValueRef.current);
-  // })), [mutateIsEnabledUnsavedWarning]);
 
   const markdownChangedHandler = useCallback((value: string) => {
     setMarkdownPreviewWithDebounce(value);
-    // mutateIsEnabledUnsavedWarningWithDebounce(value);
-  // }, [mutateIsEnabledUnsavedWarningWithDebounce, setMarkdownPreviewWithDebounce]);
   }, [setMarkdownPreviewWithDebounce]);
 
 
@@ -194,12 +180,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   }, [markdownToPreview, mutateIsConflict]);
 
-  // TODO: remove workaround
-  // for https://redmine.weseek.co.jp/issues/125923
-  useEffect(() => {
-    setCreatedPageRevisionIdWithAttachment(undefined);
-  }, [router]);
-
   useEffect(() => {
     if (socket == null) { return }
 
@@ -212,9 +192,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   }, [socket, checkIsConflict]);
 
   const save = useCallback(async(opts?: {slackChannels: string, overwriteScopesOfDescendants?: boolean}): Promise<IPageHasId | null> => {
-    if (pageId == null || currentPagePath == null || currentRevisionId == null || grantData == null) {
+    if (pageId == null || currentRevisionId == null || grantData == null) {
       logger.error('Some materials to save are invalid', {
-        pageId, currentPagePath, currentRevisionId, grantData,
+        pageId, currentRevisionId, grantData,
       });
       throw new Error('Some materials to save are invalid');
     }
@@ -254,7 +234,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
 
   // eslint-disable-next-line max-len
-  }, [codeMirrorEditor, grantData, pageId, currentPagePath, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
+  }, [codeMirrorEditor, grantData, pageId, currentRevisionId, mutateWaitingSaveProcessing, mutateRemotePageId, mutateRemoteRevisionId, mutateRemoteRevisionLastUpdatedAt, mutateRemoteRevisionLastUpdateUser]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: {slackChannels: string, overwriteScopesOfDescendants?: boolean}) => {
     const page = await save(opts);
@@ -359,7 +339,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     mutateIsConflict(false);
 
     // set resolved markdown in editing markdown
-    const markdown = pageData?.revision.body ?? '';
+    const markdown = pageData?.revision?.body ?? '';
     mutateEditingMarkdown(markdown);
 
   }, [mutateCurrentPage, mutateEditingMarkdown, mutateIsConflict, mutateTagsInfo, syncTagsInfoForEditor]);
@@ -402,6 +382,19 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     }
   }, [initialValue, isIndentSizeForced, mutateCurrentIndentSize]);
 
+  // set handler to set caret line
+  useEffect(() => {
+    const handler = (lineNumber?: number) => {
+      codeMirrorEditor?.setCaretLine(lineNumber);
+
+      // TODO: scroll to the caret line
+    };
+    globalEmitter.on('setCaretLine', handler);
+
+    return function cleanup() {
+      globalEmitter.removeListener('setCaretLine', handler);
+    };
+  }, [codeMirrorEditor]);
 
   // TODO: Check the reproduction conditions that made this code necessary and confirm reproduction
   // // when transitioning to a different page, if the initialValue is the same,
@@ -418,6 +411,17 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   //   };
   // }, [onRouterChangeComplete, router.events]);
 
+  const pastEndStyle: CSSProperties | undefined = useMemo(() => {
+    if (previewRect == null) {
+      return undefined;
+    }
+
+    const previewRectHeight = previewRect.height;
+
+    // containerHeight - 1.5 line height
+    return { paddingBottom: `calc(${previewRectHeight}px - 2em)` };
+  }, [previewRect]);
+
   if (!isEditable) {
     return <></>;
   }
@@ -433,18 +437,6 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       </div>
       <div className={`flex-expand-horiz ${props.visibility ? '' : 'd-none'}`}>
         <div className="page-editor-editor-container flex-expand-vert">
-          {/* <Editor
-            ref={editorRef}
-            value={initialValue}
-            isUploadable={isUploadable}
-            isUploadAllFileAllowed={isUploadAllFileAllowed}
-            indentSize={currentIndentSize}
-            onScroll={editorScrolledHandler}
-            onScrollCursorIntoView={editorScrollCursorIntoViewHandler}
-            onChange={markdownChangedHandler}
-            onUpload={uploadHandler}
-            onSave={saveWithShortcut}
-          /> */}
           <CodeMirrorEditorMain
             onChange={markdownChangedHandler}
             onSave={saveWithShortcut}
@@ -460,14 +452,17 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
             editorKeymap={editorSettings?.keymapMode}
           />
         </div>
-        <div ref={previewRef} onScroll={scrollPreviewHandlerThrottle} className="page-editor-preview-container flex-expand-vert d-none d-lg-flex">
+        <div
+          ref={previewRef}
+          onScroll={scrollPreviewHandlerThrottle}
+          className="page-editor-preview-container flex-expand-vert d-none d-lg-flex"
+        >
           <Preview
             rendererOptions={rendererOptions}
             markdown={markdownToPreview}
             pagePath={currentPagePath}
             expandContentWidth={shouldExpandContent}
-            // TODO: Dynamic changes by height or resizing the last element
-            pastEnd={500}
+            style={pastEndStyle}
           />
         </div>
         {/*

+ 1 - 0
apps/app/src/components/PageEditor/Preview.module.scss

@@ -3,6 +3,7 @@
 .page-editor-preview-body :global {
   .wiki {
     max-width: 980px;
+    padding: 0px 15px;
     margin: 0 auto;
   }
 }

+ 5 - 6
apps/app/src/components/PageEditor/Preview.tsx

@@ -1,6 +1,5 @@
-import React, {
-  SyntheticEvent, RefObject,
-} from 'react';
+import type { CSSProperties } from 'react';
+import React from 'react';
 
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
@@ -17,7 +16,7 @@ type Props = {
   markdown?: string,
   pagePath?: string | null,
   expandContentWidth?: boolean,
-  pastEnd?: number,
+  style?: CSSProperties,
   onScroll?: (scrollTop: number) => void,
 }
 
@@ -25,7 +24,7 @@ const Preview = React.forwardRef((props: Props): JSX.Element => {
 
   const {
     rendererOptions,
-    markdown, pagePath, pastEnd,
+    markdown, pagePath, style,
     expandContentWidth,
   } = props;
 
@@ -34,7 +33,7 @@ const Preview = React.forwardRef((props: Props): JSX.Element => {
   return (
     <div
       className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
-      style={{ paddingBottom: pastEnd }}
+      style={style}
     >
       { markdown != null && (
         <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown}></RevisionRenderer>

+ 1 - 1
apps/app/src/components/PageEditor/page-path-rename-utils.ts

@@ -40,7 +40,7 @@ export const usePagePathRenameHandler = (
     try {
       await apiv3Put('/pages/rename', {
         pageId: currentPage._id,
-        revisionId: currentPage.revision._id,
+        revisionId: currentPage.revision?._id,
         newPagePath,
       });
 

+ 5 - 1
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -92,7 +92,11 @@ export const PageTitleHeader: FC<Props> = (props) => {
         </h1>
       </div>
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''}`}>
+      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center`}>
+        { currentPage.wip && (
+          <span className="badge rounded-pill text-bg-secondary ms-2">WIP</span>
+        )}
+
         <CopyDropdown
           pageId={currentPage._id}
           pagePath={currentPage.path}

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

@@ -60,7 +60,7 @@ const PagePresentationModal = (): JSX.Element => {
     return <></>;
   }
 
-  const markdown = currentPage?.revision.body;
+  const markdown = currentPage?.revision?.body;
 
   return (
     <Modal
@@ -71,11 +71,12 @@ const PagePresentationModal = (): JSX.Element => {
     >
       <div className="grw-presentation-controls d-flex">
         <button
-          className={`btn ${fullscreen.active ? 'icon-size-actual' : 'icon-size-fullscreen'}`}
+          className="btn material-symbols-outlined"
           type="button"
           aria-label="fullscreen"
           onClick={toggleFullscreenHandler}
         >
+          {fullscreen.active ? 'close_fullscreen' : 'open_in_full'}
         </button>
         <button className="btn-close" type="button" aria-label="Close" onClick={closeHandler}></button>
       </div>

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

@@ -1,6 +1,7 @@
 import React, { Suspense, useCallback } from 'react';
 
-import { getIdForRef, type IPageHasId, type IPageInfoForOperation } from '@growi/core';
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { getIdForRef, type IPageInfoForOperation } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -70,7 +71,7 @@ const Tags = (props: TagsProps): JSX.Element => {
 
 
 export type PageSideContentsProps = {
-  page: IPageHasId,
+  page: IPagePopulatedToShowRevision,
   isSharedUser?: boolean,
 }
 
@@ -91,9 +92,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   return (
     <>
       {/* Tags */}
-      <Suspense fallback={<PageTagsSkeleton />}>
-        <Tags pageId={page._id} revisionId={getIdForRef(page.revision)} />
-      </Suspense>
+      { page.revision != null && (
+        <Suspense fallback={<PageTagsSkeleton />}>
+          <Tags pageId={page._id} revisionId={page.revision._id} />
+        </Suspense>
+      ) }
 
       <div className={`${styles['grw-page-accessories-controls']} d-flex flex-column gap-2`}>
         {/* Page list */}

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

@@ -38,7 +38,7 @@ export const PageStatusAlert = (): JSX.Element => {
 
   const refreshPage = useCallback(async() => {
     const updatedPageData = await mutatePageData();
-    mutateEditingMarkdown(updatedPageData?.revision.body);
+    mutateEditingMarkdown(updatedPageData?.revision?.body);
   }, [mutateEditingMarkdown, mutatePageData]);
 
   const onClickResolveConflict = useCallback(() => {

+ 7 - 19
apps/app/src/components/PageTags/TagsInput.tsx

@@ -1,19 +1,12 @@
-import React, {
-  FC, useRef, useState, useCallback,
-} from 'react';
+import type { FC, KeyboardEvent } from 'react';
+import React, { useRef, useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
+import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
 import { useSWRxTagsSearch } from '~/stores/tag';
 
-type TypeaheadInstance = {
-  _handleMenuItemSelect: (activeItem: string, event: React.KeyboardEvent) => void,
-  state: {
-    initialItem: string,
-  },
-}
-
 type Props = {
   tags: string[],
   autoFocus: boolean,
@@ -24,7 +17,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { tags, autoFocus, onTagsUpdated } = props;
 
-  const tagsInputRef = useRef<TypeaheadInstance>(null);
+  const tagsInputRef = useRef<TypeaheadRef>(null);
   const [resultTags, setResultTags] = useState<string[]>([]);
   const [searchQuery, setSearchQuery] = useState('');
 
@@ -33,20 +26,17 @@ export const TagsInput: FC<Props> = (props: Props) => {
   const isLoading = error == null && tagsSearch === undefined;
 
   const changeHandler = useCallback((selected: string[]) => {
-    if (onTagsUpdated != null) {
-      onTagsUpdated(selected);
-    }
+    onTagsUpdated(selected);
   }, [onTagsUpdated]);
 
-  const searchHandler = useCallback(async(query: string) => {
+  const searchHandler = useCallback((query: string) => {
     const tagsSearchData = tagsSearch?.tags || [];
     setSearchQuery(query);
     tagsSearchData.unshift(query);
     setResultTags(Array.from(new Set(tagsSearchData)));
-
   }, [tagsSearch?.tags]);
 
-  const keyDownHandler = useCallback((event: React.KeyboardEvent) => {
+  const keyDownHandler = useCallback((event: KeyboardEvent<HTMLElement>) => {
     if (event.key === ' ') {
       event.preventDefault();
 
@@ -64,12 +54,10 @@ export const TagsInput: FC<Props> = (props: Props) => {
       <AsyncTypeahead
         id="tag-typeahead-asynctypeahead"
         ref={tagsInputRef}
-        caseSensitive={false}
         defaultSelected={tags}
         isLoading={isLoading}
         minLength={1}
         multiple
-        newSelectionPrefix=""
         onChange={changeHandler}
         onSearch={searchHandler}
         onKeyDown={keyDownHandler}

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

@@ -30,7 +30,7 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
         </Link>
       </div>
       <div className="card-body">
-        { rendererOptions != null && (
+        { rendererOptions != null && page.revision != null && (
           <RevisionLoader
             rendererOptions={rendererOptions}
             pageId={page._id}

+ 29 - 1
apps/app/src/components/SavePageControls.tsx

@@ -9,15 +9,19 @@ import {
   DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
+import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IPageGrantData } from '~/interfaces/page';
 import {
   useIsEditable, useIsAclEnabled,
 } from '~/stores/context';
 import { useWaitingSaveProcessing } from '~/stores/editor';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxCurrentPage, useSWRxCurrentPage } from '~/stores/page';
+import { mutatePageTree } from '~/stores/page-listing';
 import { useSelectedGrant } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
+import { unpublish } from '../client/services/page-operation';
+
 import { GrantSelector } from './SavePageControls/GrantSelector';
 
 
@@ -41,6 +45,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const { data: isAclEnabled } = useIsAclEnabled();
   const { data: grantData, mutate: mutateGrant } = useSelectedGrant();
   const { data: _isWaitingSaveProcessing } = useWaitingSaveProcessing();
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const isWaitingSaveProcessing = _isWaitingSaveProcessing === true; // ignore undefined
 
@@ -58,6 +63,25 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
     globalEmitter.emit('saveAndReturnToView', { overwriteScopesOfDescendants: true, slackChannels });
   }, [slackChannels]);
 
+  const clickUnpublishButtonHandler = useCallback(async() => {
+    const pageId = currentPage?._id;
+
+    if (pageId == null) {
+      return;
+    }
+
+    try {
+      await unpublish(pageId);
+      await mutateCurrentPage();
+      await mutatePageTree();
+      toastSuccess(t('wip_page.success_save_as_wip'));
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(t('wip_page.fail_save_as_wip'));
+    }
+  }, [currentPage?._id, mutateCurrentPage, t]);
+
 
   if (isEditable == null || isAclEnabled == null || grantData == null) {
     return null;
@@ -72,6 +96,7 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
   const isGrantSelectorDisabledPage = isTopPage(currentPage?.path ?? '') || isUsersProtectedPages(currentPage?.path ?? '');
   const labelSubmitButton = t('Update');
   const labelOverwriteScopes = t('page_edit.overwrite_scopes', { operation: labelSubmitButton });
+  const labelUnpublishPage = t('wip_page.save_as_wip');
 
   return (
     <div className="d-flex align-items-center flex-nowrap">
@@ -108,6 +133,9 @@ export const SavePageControls = (props: SavePageControlsProps): JSX.Element | nu
           <DropdownItem onClick={saveAndOverwriteScopesOfDescendants}>
             {labelOverwriteScopes}
           </DropdownItem>
+          <DropdownItem onClick={clickUnpublishButtonHandler}>
+            {labelUnpublishPage}
+          </DropdownItem>
         </DropdownMenu>
       </UncontrolledButtonDropdown>
 

+ 5 - 5
apps/app/src/components/SavePageControls/GrantSelector/GrantSelector.tsx

@@ -18,18 +18,18 @@ import { useMyUserGroups } from './use-my-user-groups';
 
 const AVAILABLE_GRANTS = [
   {
-    grant: PageGrant.GRANT_PUBLIC, iconClass: 'icon-people', btnStyleClass: 'outline-info', label: 'Public',
+    grant: PageGrant.GRANT_PUBLIC, iconName: 'group', btnStyleClass: 'outline-info', label: 'Public',
   },
   {
-    grant: PageGrant.GRANT_RESTRICTED, iconClass: 'icon-link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
+    grant: PageGrant.GRANT_RESTRICTED, iconName: 'link', btnStyleClass: 'outline-teal', label: 'Anyone with the link',
   },
   // { grant: 3, iconClass: '', label: 'Specified users only' },
   {
-    grant: PageGrant.GRANT_OWNER, iconClass: 'icon-lock', btnStyleClass: 'outline-danger', label: 'Only me',
+    grant: PageGrant.GRANT_OWNER, iconName: 'lock', btnStyleClass: 'outline-danger', label: 'Only me',
   },
   {
     grant: PageGrant.GRANT_USER_GROUP,
-    iconClass: 'icon-options',
+    iconName: 'more_horiz',
     btnStyleClass: 'outline-purple',
     label: 'Only inside the group',
     reselectLabel: 'Reselect the group',
@@ -119,7 +119,7 @@ export const GrantSelector = (props: Props): JSX.Element => {
 
       const labelElm = (
         <span>
-          <i className={`icon icon-fw ${opt.iconClass}`}></i>
+          <span className="material-symbols-outlined me-2">{opt.iconName}</span>
           <span className="label">{t(label)}</span>
         </span>
       );

+ 8 - 17
apps/app/src/components/SearchPage.tsx

@@ -28,8 +28,6 @@ const INITIAL_PAGIONG_SIZE = 20;
 
 type SearchResultListHeadProps = {
   searchResult: IFormattedSearchResult,
-  searchingKeyword: string,
-  offset: number,
   pagingSize: number,
   onPagingSizeChanged: (size: number) => void,
 }
@@ -38,13 +36,10 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   const { t } = useTranslation();
 
   const {
-    searchResult, searchingKeyword, offset, pagingSize,
-    onPagingSizeChanged,
+    searchResult, // pagingSize, onPagingSizeChanged,
   } = props;
 
-  const { took, total, hitsCount } = searchResult.meta;
-  const leftNum = offset + 1;
-  const rightNum = offset + hitsCount;
+  const { took, total } = searchResult.meta;
 
   if (total === 0) {
     return (
@@ -57,15 +52,14 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
   return (
     <div className="d-flex align-items-center justify-content-between">
       <div className="text-nowrap">
-        {t('search_result.result_meta')}
-        <span className="search-result-keyword ms-2">{`${searchingKeyword}`}</span>
-        <span className="ms-3">{`${leftNum}-${rightNum}`} / {total}</span>
+        <span className="ms-3 fw-bold">{total} {t('search_result.hit_number_unit', 'hit')}</span>
         { took != null && (
           // blackout 70px rectangle in VRT
           <span data-vrt-blackout className="ms-3 text-muted d-inline-block" style={{ minWidth: '70px' }}>({took}ms)</span>
         ) }
       </div>
-      <div className="input-group flex-nowrap search-result-select-group ms-auto d-md-flex d-none">
+      {/* TODO: infinite scroll for search result */}
+      {/* <div className="input-group flex-nowrap search-result-select-group ms-auto d-md-flex d-none">
         <div>
           <label className="form-label input-group-text text-muted" htmlFor="inputGroupSelect01">{t('search_result.number_of_list_to_display')}</label>
         </div>
@@ -79,7 +73,7 @@ const SearchResultListHead = React.memo((props: SearchResultListHeadProps): JSX.
             return <option key={limit} value={limit}>{limit} {t('search_result.page_number_unit')}</option>;
           })}
         </select>
-      </div>
+      </div> */}
     </div>
   );
 });
@@ -185,11 +179,10 @@ export const SearchPage = (): JSX.Element => {
           >
             <button
               type="button"
-              className="btn btn-outline-danger text-nowrap border-0 px-2"
+              className="btn border-0 text-danger"
               disabled={isDisabled}
               onClick={deleteAllButtonClickedHandler}
             >
-              <span className="material-symbols-outlined">delete</span>
               {t('search_result.delete_all_selected_page')}
             </button>
           </OperateAllControl>
@@ -221,13 +214,11 @@ export const SearchPage = (): JSX.Element => {
     return (
       <SearchResultListHead
         searchResult={data}
-        searchingKeyword={keyword ?? ''}
-        offset={offset}
         pagingSize={limit}
         onPagingSizeChanged={pagingSizeChangedHandler}
       />
     );
-  }, [data, keyword, limit, offset, pagingSizeChangedHandler]);
+  }, [data, limit, pagingSizeChangedHandler]);
 
   const searchPager = useMemo(() => {
     // when pager is not needed

+ 5 - 5
apps/app/src/components/SearchPage/OperateAllControl.tsx

@@ -1,11 +1,10 @@
-import React, {
-  ChangeEvent, forwardRef, ForwardRefRenderFunction, useImperativeHandle, useRef,
-} from 'react';
+import type { ChangeEvent, ForwardRefRenderFunction } from 'react';
+import React, { forwardRef, useImperativeHandle, useRef } from 'react';
 
 import { Input } from 'reactstrap';
 
-import { ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
-import { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
+import type { ISelectableAndIndeterminatable } from '~/client/interfaces/selectable-all';
+import type { IndeterminateInputElement } from '~/interfaces/indeterminate-input-elm';
 
 type Props = {
   isCheckboxDisabled?: boolean,
@@ -58,6 +57,7 @@ const OperateAllControlSubstance: ForwardRefRenderFunction<ISelectableAndIndeter
         type="checkbox"
         id="cb-check-all"
         data-testid="cb-select-all"
+        className="ms-2"
         innerRef={selectAllCheckboxElm}
         disabled={isCheckboxDisabled}
         onChange={checkboxChangedHandler}

+ 9 - 0
apps/app/src/components/SearchPage/SearchControl.module.scss

@@ -0,0 +1,9 @@
+
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '@growi/ui/scss/atoms/btn-muted';
+
+// == Colors
+.btn-delete {
+  @include btn-muted.colorize(bs.$red);
+}

+ 24 - 22
apps/app/src/components/SearchPage/SearchControl.tsx

@@ -5,13 +5,15 @@ import React, {
 import { useTranslation } from 'next-i18next';
 
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
-import { ISearchConditions, ISearchConfigurations } from '~/stores/search';
+import type { ISearchConditions, ISearchConfigurations } from '~/stores/search';
 
 import SearchForm from '../SearchForm';
 
 import SearchOptionModal from './SearchOptionModal';
 import SortControl from './SortControl';
 
+import styles from './SearchControl.module.scss';
+
 type Props = {
   isSearchServiceReachable: boolean,
   isEnableSort: boolean,
@@ -93,26 +95,12 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
             onSubmit={searchFormSubmittedHandler}
           />
         </div>
-
-        {/* sort option: show when screen is larger than lg */}
-        {isEnableSort && (
-          <div className="me-4 d-lg-flex d-none">
-            <SortControl
-              sort={sort}
-              order={order}
-              onChange={changeSortHandler}
-            />
-          </div>
-        )}
       </div>
       {/* TODO: replace the following elements deleteAll button , relevance button and include specificPath button component */}
       <div className="search-control d-flex align-items-center py-md-2 py-3 px-md-4 px-3 border-bottom border-gray">
-        <div className="d-flex">
-          {allControl}
-        </div>
-        {/* sort option: show when screen is smaller than lg */}
+        {/* sort option */}
         {isEnableSort && (
-          <div className="me-md-4 me-2 d-flex d-lg-none ms-auto">
+          <div className="flex-grow-1">
             <SortControl
               sort={sort}
               order={order}
@@ -129,11 +117,13 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
                 className="btn"
                 onClick={() => setIsFileterOptionModalShown(true)}
               >
-                <i className="icon-equalizer"></i>
+                <span className="material-symbols-outlined">
+                  tune
+                </span>
               </button>
             </div>
-            <div className="d-none d-lg-flex align-items-center ms-auto search-control-include-options">
-              <div className="border rounded px-2 py-1 me-3">
+            <div className="d-none d-lg-flex align-items-center search-control-include-options">
+              <div className="px-2 py-1">
                 <div className="form-check form-check-succsess">
                   <input
                     className="form-check-input me-2"
@@ -150,7 +140,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
                   </label>
                 </div>
               </div>
-              <div className="border rounded px-2 py-1">
+              <div className="px-2 py-1">
                 <div className="form-check form-check-succsess">
                   <input
                     className="form-check-input me-2"
@@ -160,7 +150,7 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
                     onChange={e => changeIncludeTrashPagesHandler(e.target.checked)}
                   />
                   <label
-                    className="form-label form-check-label d-flex align-items-center text-secondary with-no-font-weight"
+                    className="form-label form-check-label mb-0 d-flex align-items-center text-secondary with-no-font-weight"
                     htmlFor="flexCheckChecked"
                   >
                     {t('Include Subordinated Target Page', { target: '/trash' })}
@@ -170,6 +160,18 @@ const SearchControl = React.memo((props: Props): JSX.Element => {
             </div>
           </>
         )}
+        <div className="d-flex">
+          <div className="btn-group">
+            {/* TODO: imprv to delete all result UI */}
+            {/* <button className={`btn btn-sm rounded ${styles['btn-delete']}`} type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              <span className="material-symbols-outlined ">delete</span>
+              <span className="material-symbols-outlined ">expand_more</span>
+            </button> */}
+            {/* <ul className="dropdown-menu"> */}
+            {allControl}
+            {/* </ul> */}
+          </div>
+        </div>
       </div>
 
       <SearchOptionModal

+ 4 - 3
apps/app/src/components/SearchPage/SearchOptionModal.tsx

@@ -1,4 +1,5 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -51,7 +52,7 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
       </ModalHeader>
       <ModalBody>
         <div className="d-flex p-2">
-          <div className="border border-gray me-3">
+          <div className="me-3">
             <label className="form-label px-3 py-2 mb-0 d-flex align-items-center">
               <input
                 className="me-2"
@@ -62,7 +63,7 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
               {t('Include Subordinated Target Page', { target: '/user' })}
             </label>
           </div>
-          <div className="border border-gray">
+          <div className="">
             <label className="form-label px-3 py-2 mb-0 d-flex align-items-center">
               <input
                 className="me-2"

+ 15 - 17
apps/app/src/components/SearchPage/SearchResultContent.tsx

@@ -119,7 +119,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation();
 
-  const page = pageWithMeta?.data;
+  const page = pageWithMeta.data;
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
@@ -183,7 +183,10 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       return <></>;
     }
 
-    const revisionId = getIdForRef(page.revision);
+    const revisionId = page.revision != null ? getIdForRef(page.revision) : null;
+    const additionalMenuItemRenderer = revisionId != null
+      ? props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />
+      : undefined;
 
     return (
       <div className="d-flex flex-column align-items-end justify-content-center px-2 py-1">
@@ -194,7 +197,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           expandContentWidth={shouldExpandContent}
           showPageControlDropdown={showPageControlDropdown}
           forceHideMenuItems={forceHideMenuItems}
-          additionalMenuItemRenderer={props => <AdditionalMenuItems {...props} pageId={page._id} revisionId={revisionId} />}
+          additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickDuplicateMenuItem={duplicateItemClickedHandler}
           onClickRenameMenuItem={renameItemClickedHandler}
           onClickDeleteMenuItem={deleteItemClickedHandler}
@@ -205,8 +208,6 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   }, [page, shouldExpandContent, showPageControlDropdown, forceHideMenuItems,
       duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler, switchContentWidthHandler]);
 
-  const isRenderable = page != null && rendererOptions != null;
-
   const fluidLayoutClass = shouldExpandContent ? _fluidLayoutClass : '';
 
   return (
@@ -217,25 +218,23 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     >
       <RightComponent />
 
-      { isRenderable && (
-        <div className="container-lg grw-container-convertible pt-2 pb-2">
-          <PagePathNav pageId={page._id} pagePath={page.path} formerLinkClassName="small" latterLinkClassName="fs-3" />
-        </div>
-      ) }
+      <div className="container-lg grw-container-convertible pt-2 pb-2">
+        <PagePathNav pageId={page._id} pagePath={page.path} formerLinkClassName="small" latterLinkClassName="fs-3" />
+      </div>
 
       <div
         id="search-result-content-body-container"
         ref={scrollElementRef}
         className="search-result-content-body-container container-lg grw-container-convertible overflow-y-scroll"
       >
-        { isRenderable && (
+        { page.revision != null && rendererOptions != null && (
           <RevisionLoader
             rendererOptions={rendererOptions}
             pageId={page._id}
             revisionId={page.revision}
           />
         )}
-        { isRenderable && (
+        { page.revision != null && (
           <PageComment
             rendererOptions={rendererOptions}
             pageId={page._id}
@@ -245,11 +244,10 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
             isReadOnly
           />
         )}
-        { isRenderable && (
-          <PageContentFooter
-            page={page}
-          />
-        )}
+
+        <PageContentFooter
+          page={page}
+        />
       </div>
     </div>
   );

+ 3 - 0
apps/app/src/components/SearchPage/SortControl.module.scss

@@ -0,0 +1,3 @@
+.sort-control {
+  min-width: 180px;
+}

+ 32 - 35
apps/app/src/components/SearchPage/SortControl.tsx

@@ -1,7 +1,12 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
+
 import { useTranslation } from 'next-i18next';
+
 import { SORT_AXIS, SORT_ORDER } from '../../interfaces/search';
 
+import styles from './SortControl.module.scss';
+
 const { DESC, ASC } = SORT_ORDER;
 
 type Props = {
@@ -22,43 +27,35 @@ const SortControl: FC <Props> = (props: Props) => {
     }
   };
 
-  const renderOrderIcon = () => {
-    const iconClassName = ASC === order ? 'fa fa-sort-amount-asc' : 'fa fa-sort-amount-desc';
-    return <i className={iconClassName} aria-hidden="true" />;
-  };
 
   return (
     <>
-      <div className="input-group flex-nowrap">
-        <div>
-          <div className="input-group-text border text-muted" id="btnGroupAddon">
-            {renderOrderIcon()}
-          </div>
-        </div>
-        <div className="border rounded-end">
-          <button
-            type="button"
-            className="btn dropdown-toggle py-1"
-            data-bs-toggle="dropdown"
-          >
-            <span className="me-2 text-secondary">{t(`search_result.sort_axis.${sort}`)}</span>
-          </button>
-          <div className="dropdown-menu dropdown-menu-right">
-            {Object.values(SORT_AXIS).map((sortAxis) => {
-              const nextOrder = (sort !== sortAxis || order === ASC) ? DESC : ASC;
-              return (
-                <button
-                  key={sortAxis}
-                  className="dropdown-item"
-                  type="button"
-                  onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
-                >
-                  <span>{t(`search_result.sort_axis.${sortAxis}`)}</span>
-                </button>
-              );
-            })}
-          </div>
-        </div>
+      <div className={`btn-group ${styles['sort-control']}`}>
+        <button
+          className="d-flex align-items-center btn btn-sm btn-outline-neutral-secondary rounded-pill"
+          type="button"
+          data-bs-toggle="dropdown"
+          aria-expanded="false"
+        >
+          <span className="material-symbols-outlined py-0">sort</span>
+          <span className="ms-2 me-auto">{t(`search_result.sort_axis.${sort}`)}</span>
+          <span className="material-symbols-outlined py-0">expand_more</span>
+        </button>
+        <ul className="dropdown-menu">
+          {Object.values(SORT_AXIS).map((sortAxis) => {
+            const nextOrder = (sort !== sortAxis || order === ASC) ? DESC : ASC;
+            return (
+              <button
+                key={sortAxis}
+                className="dropdown-item"
+                type="button"
+                onClick={() => { onClickChangeSort(sortAxis, nextOrder) }}
+              >
+                <span>{t(`search_result.sort_axis.${sortAxis}`)}</span>
+              </button>
+            );
+          })}
+        </ul>
       </div>
     </>
   );

+ 24 - 24
apps/app/src/components/SearchTypeahead.tsx

@@ -1,14 +1,18 @@
+import type {
+  FC, ForwardRefRenderFunction,
+  KeyboardEvent, MouseEvent,
+} from 'react';
 import React, {
-  FC, ForwardRefRenderFunction, forwardRef, useImperativeHandle,
-  KeyboardEvent, useCallback, useRef, useState, MouseEvent, useEffect,
+  forwardRef, useImperativeHandle, useCallback, useRef, useState, useEffect,
 } from 'react';
 
 import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
+import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
-import { IFocusable } from '~/client/interfaces/focusable';
-import { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
-import { IPageWithSearchMeta } from '~/interfaces/search';
+import type { IFocusable } from '~/client/interfaces/focusable';
+import type { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahead';
+import type { IPageWithSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 
 
@@ -43,15 +47,6 @@ type Props = TypeaheadProps & {
   helpElement?: React.ReactNode,
 };
 
-// see https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
-type TypeaheadInstance = {
-  setState(input: { text: string | undefined; }): void;
-  clear: () => void,
-  focus: () => void,
-  toggleMenu: () => void,
-  state: { selected: IPageWithSearchMeta[] }
-}
-
 const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
   const {
     onSearchError, onSearch, onInputChange, onChange, onSubmit,
@@ -61,7 +56,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
   const [input, setInput] = useState(keywordOnInit);
   const [searchKeyword, setSearchKeyword] = useState('');
-  const [isForcused, setFocused] = useState(false);
+  const [isFocused, setFocused] = useState(false);
 
   const { data: searchResult, error: searchError, isLoading } = useSWRxSearch(
     disableIncrementalSearch ? null : searchKeyword,
@@ -69,7 +64,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     { limit: 10 },
   );
 
-  const typeaheadRef = useRef<TypeaheadInstance>(null);
+  const typeaheadRef = useRef<TypeaheadRef>(null);
 
   const focusToTypeahead = () => {
     const instance = typeaheadRef.current;
@@ -141,14 +136,18 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     if (selectedItems.length > 0) {
       setInput(selectedItems[0].data.path);
 
+      if (onInputChange != null) {
+        onInputChange(selectedItems[0].data.path);
+      }
+
       if (onChange != null) {
         onChange(selectedItems);
       }
     }
-  }, [onChange]);
+  }, [onChange, onInputChange]);
 
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
-    if (event.keyCode === 13) { // Enter key
+    if (event.key === 'Enter') {
       if (onSubmit != null && input != null && input.length > 0) {
         // schedule to submit with 100ms delay
         timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);
@@ -168,19 +167,20 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   useEffect(() => {
     // update input with Next Link
     // update input workaround. see: https://github.com/ericgio/react-bootstrap-typeahead/issues/266#issuecomment-414987723
-    if (typeaheadRef.current != null) {
+    if (typeaheadRef.current != null && keywordOnInit != null) {
       typeaheadRef.current.setState({
         text: keywordOnInit,
       });
     }
   }, [keywordOnInit]);
 
-  const labelKey = useCallback((option?: IPageWithSearchMeta) => {
-    return option?.data.path ?? '';
+
+  const labelKey = useCallback((option: IPageWithSearchMeta) => {
+    return option.data.path ?? '';
   }, []);
 
   const renderMenu = useCallback((options: IPageWithSearchMeta[], menuProps) => {
-    if (!isForcused) {
+    if (!isFocused) {
       return <></>;
     }
 
@@ -216,7 +216,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         ))}
       </Menu>
     );
-  }, [disableIncrementalSearch, helpElement, input, isForcused]);
+  }, [disableIncrementalSearch, helpElement, input, isFocused]);
 
   const isOpenAlways = helpElement != null;
 
@@ -232,7 +232,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         isLoading={isLoading}
         labelKey={labelKey}
         defaultInputValue={keywordOnInit}
-        options={searchResult?.data} // Search result (Some page names)
+        options={searchResult?.data ?? []} // Search result (Some page names)
         align="left"
         open={isOpenAlways || undefined}
         renderMenu={renderMenu}

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

@@ -67,7 +67,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
 
 
   const Contents = () => {
-    if (isNotFound) {
+    if (isNotFound || page.revision == null) {
       return <></>;
     }
 

+ 4 - 6
apps/app/src/components/Sidebar/Bookmarks.tsx

@@ -12,11 +12,9 @@ export const Bookmarks = () : JSX.Element => {
   const { data: isGuestUser } = useIsGuestUser();
 
   return (
-    <>
-      {/* TODO : #139425 Match the space specification method to others */}
-      {/* ref.  https://redmine.weseek.co.jp/issues/139425 */}
-      <div className="grw-sidebar-content-header p-3">
-        <h3 className="mb-0">{t('Bookmarks')}</h3>
+    <div className="px-3">
+      <div className="grw-sidebar-content-header">
+        <h4 className="mb-0 py-4">{t('Bookmarks')}</h4>
       </div>
       {isGuestUser ? (
         <h4 className="ps-3">
@@ -25,6 +23,6 @@ export const Bookmarks = () : JSX.Element => {
       ) : (
         <BookmarkContents />
       )}
-    </>
+    </div>
   );
 };

+ 4 - 6
apps/app/src/components/Sidebar/Custom/CustomSidebar.tsx

@@ -18,14 +18,12 @@ export const CustomSidebar = (): JSX.Element => {
   const { mutate, isLoading } = useSWRxPageByPath('/Sidebar');
 
   return (
-    // TODO : #139425 Match the space specification method to others
-    // ref.  https://redmine.weseek.co.jp/issues/139425
-    <div className="px-3">
-      <div className="grw-sidebar-content-header py-3 d-flex">
-        <h3 className="mb-0">
+    <div className="pt-4 pb-3 px-3">
+      <div className="grw-sidebar-content-header d-flex">
+        <h4 className="mb-0">
           {t('CustomSidebar')}
           { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
-        </h3>
+        </h4>
         { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
       </div>
 

+ 13 - 4
apps/app/src/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,16 +1,25 @@
-import Link from 'next/link';
+import { useCallback } from 'react';
+
 import { useTranslation } from 'react-i18next';
 
+import { useCreatePageAndTransit } from '~/client/services/create-page';
+
 export const SidebarNotFound = (): JSX.Element => {
   const { t } = useTranslation();
 
+  const { createAndTransit } = useCreatePageAndTransit();
+
+  const clickCreateButtonHandler = useCallback(async() => {
+    createAndTransit({ path: '/Sidebar', wip: false });
+  }, [createAndTransit]);
+
   return (
-    <div className="grw-sidebar-content-header h5 text-center py-3">
-      <Link href="/Sidebar#edit">
+    <div>
+      <button type="button" className="btn btn-lg btn-link" onClick={clickCreateButtonHandler}>
         <span className="material-symbols-outlined">edit_note</span>
         {/* eslint-disable-next-line react/no-danger */}
         <span dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}></span>
-      </Link>
+      </button>
     </div>
   );
 };

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