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

Merge branch 'dev/7.4.x' into fix/disable-logo-update-without-file

hikaruNAKANO 3 месяцев назад
Родитель
Сommit
b8029ebece
100 измененных файлов с 2436 добавлено и 1783 удалено
  1. 0 5
      .changeset/clever-paws-wink.md
  2. 0 5
      .changeset/healthy-pianos-brake.md
  3. 0 5
      .changeset/lazy-penguins-hammer.md
  4. 1 1
      .github/workflows/reusable-app-build-image.yml
  5. 1 0
      .gitignore
  6. 2 1
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  7. 1 1
      .serena/memories/page-transition-and-rendering-flow.md
  8. 40 1
      CHANGELOG.md
  9. 18 0
      apps/app/.eslintrc.js
  10. 3 0
      apps/app/.gitignore
  11. BIN
      apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c
  12. 2 0
      apps/app/config/logger/config.dev.js
  13. 3 0
      apps/app/docker/codebuild/.terraform.lock.hcl
  14. 1 1
      apps/app/docker/codebuild/main.tf
  15. 8 0
      apps/app/docker/codebuild/oidc.tf
  16. 2 1
      apps/app/package.json
  17. 11 0
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  18. 4 1
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  19. 6 2
      apps/app/playwright/23-editor/saving.spec.ts
  20. 0 2
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  21. 9 3
      apps/app/playwright/60-home/home.spec.ts
  22. 7 5
      apps/app/playwright/utils/CollapseSidebar.ts
  23. 34 0
      apps/app/public/images/customize-settings/collapsed-dark.svg
  24. 4 1
      apps/app/public/images/customize-settings/collapsed-light.svg
  25. 0 31
      apps/app/public/images/customize-settings/drawer-dark.svg
  26. 3 6
      apps/app/public/static/locales/en_US/admin.json
  27. 3 6
      apps/app/public/static/locales/fr_FR/admin.json
  28. 3 6
      apps/app/public/static/locales/ja_JP/admin.json
  29. 3 6
      apps/app/public/static/locales/ko_KR/admin.json
  30. 3 6
      apps/app/public/static/locales/zh_CN/admin.json
  31. 11 19
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  32. 5 40
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  33. 7 6
      apps/app/src/client/components/AlertSiteUrlUndefined.tsx
  34. 46 21
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  35. 27 21
      apps/app/src/client/components/Comments.tsx
  36. 54 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  37. 19 14
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  38. 2 2
      apps/app/src/client/components/CompleteUserRegistration.tsx
  39. 74 58
      apps/app/src/client/components/CompleteUserRegistrationForm.tsx
  40. 31 7
      apps/app/src/client/components/ContentLinkButtons.tsx
  41. 20 7
      apps/app/src/client/components/DataTransferForm.tsx
  42. 77 51
      apps/app/src/client/components/DescendantsPageList.tsx
  43. 16 19
      apps/app/src/client/components/DuplicatedPathsTable.tsx
  44. 3 5
      apps/app/src/client/components/EmptyTrashButton.tsx
  45. 4 5
      apps/app/src/client/components/ErrorBoudary.jsx
  46. 6 6
      apps/app/src/client/components/ExpandOrContractButton.tsx
  47. 11 6
      apps/app/src/client/components/ForbiddenPage.tsx
  48. 10 6
      apps/app/src/client/components/FormattedDistanceDate.jsx
  49. 0 40
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx
  50. 74 0
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  51. 25 16
      apps/app/src/client/components/IdenticalPathPage.tsx
  52. 20 19
      apps/app/src/client/components/InfiniteScroll.tsx
  53. 143 112
      apps/app/src/client/components/InstallerForm.tsx
  54. 50 38
      apps/app/src/client/components/InvitedForm.tsx
  55. 1 0
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  56. 17 24
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  57. 5 23
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  58. 15 13
      apps/app/src/client/components/NotAvailable.tsx
  59. 19 18
      apps/app/src/client/components/NotAvailableForGuest.tsx
  60. 17 17
      apps/app/src/client/components/NotAvailableForNow.tsx
  61. 19 16
      apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx
  62. 20 7
      apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx
  63. 4 3
      apps/app/src/client/components/NotCreatablePage.tsx
  64. 22 9
      apps/app/src/client/components/NotFoundPage.tsx
  65. 0 4
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  66. 4 0
      apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx
  67. 243 203
      apps/app/src/client/components/PageComment.tsx
  68. 37 51
      apps/app/src/client/components/PageControls/PageControls.tsx
  69. 139 76
      apps/app/src/client/components/PageCreateModal.tsx
  70. 8 13
      apps/app/src/client/components/PagePathAutoComplete.jsx
  71. 36 13
      apps/app/src/client/components/PageStatusAlert.tsx
  72. 6 4
      apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx
  73. 15 16
      apps/app/src/client/components/PageTimeline.tsx
  74. 46 27
      apps/app/src/client/components/PaginationWrapper.tsx
  75. 24 17
      apps/app/src/client/components/PasswordResetExecutionForm.tsx
  76. 28 22
      apps/app/src/client/components/PasswordResetRequestForm.tsx
  77. 45 24
      apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx
  78. 82 13
      apps/app/src/client/components/RecentActivity/ActivityListItem.tsx
  79. 8 6
      apps/app/src/client/components/RecentActivity/RecentActivity.tsx
  80. 169 117
      apps/app/src/client/components/SearchTypeahead.tsx
  81. 55 49
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  82. 2 6
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  83. 20 22
      apps/app/src/client/components/Sidebar/Bookmarks/BookmarkContents.tsx
  84. 14 6
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  85. 15 7
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  86. 11 12
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.tsx
  87. 18 10
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  88. 38 28
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  89. 51 39
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  90. 7 3
      apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.tsx
  91. 63 64
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendMenu.tsx
  92. 3 4
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.tsx
  93. 20 14
      apps/app/src/client/components/Sidebar/PageCreateButton/Hexagon.tsx
  94. 22 12
      apps/app/src/client/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  95. 4 6
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  96. 12 15
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  97. 5 6
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  98. 133 115
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  99. 3 4
      apps/app/src/client/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  100. 4 9
      apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

+ 0 - 5
.changeset/clever-paws-wink.md

@@ -1,5 +0,0 @@
----
-'@growi/core': minor
----
-
-Add global EventTarget instance provider

+ 0 - 5
.changeset/healthy-pianos-brake.md

@@ -1,5 +0,0 @@
----
-'@growi/core': major
----
-
-Remove global socket management and useSWRStatic

+ 0 - 5
.changeset/lazy-penguins-hammer.md

@@ -1,5 +0,0 @@
----
-'@growi/core': major
----
-
-Update IPage interfaces family

+ 1 - 1
.github/workflows/reusable-app-build-image.yml

@@ -40,7 +40,7 @@ jobs:
       with:
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
-        role-session-name: SessionForReleaseGROWI-RC
+        role-session-name: GitHubActions-SessionForReleaseGROWI-${{ github.run_id }}
 
     - name: Run CodeBuild
       uses: dark-mechanicum/aws-codebuild@v1

+ 1 - 0
.gitignore

@@ -33,6 +33,7 @@ yarn-error.log*
 # Terraform
 **/.terraform/*
 *.tfstate.*
+/aws/
 
 # IDE, dev #
 .idea

+ 2 - 1
.serena/memories/page-state-hooks-useLatestRevision-degradation.md

@@ -305,7 +305,8 @@ export const useFetchCurrentPage = () => {
       const { page: newData } = data;
 
       set(currentPageDataAtom, newData);
-      set(currentPageIdAtom, newData._id);
+      set(currentPageEntityIdAtom, newData._id);
+      set(currentPageEmptyIdAtom, undefined);
 
       // ✅ 追加: PageInfo を再フェッチ
       mutatePageInfo();  // 引数なし = revalidate (再フェッチ)

+ 1 - 1
.serena/memories/page-transition-and-rendering-flow.md

@@ -53,7 +53,7 @@
     - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
 4.  **アトミックな状態更新**:
     - **API成功時**:
-        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
+        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
         - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
     - **APIエラー時 (例: 404 Not Found)**:
         - `pageErrorAtom` にエラーオブジェクトを設定します。

+ 40 - 1
CHANGELOG.md

@@ -1,9 +1,48 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.9...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.0...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.0](https://github.com/growilabs/compare/v7.3.9...v7.4.0) - 2025-12-24
+
+### 💎 Features
+
+* feat: PageTree Virtualization (#10581) @yuki-takei
+* feat: Can set default user role as read-only for new users (#10623) @Ryosei-Fukushima
+* feat: Can create page when executing page edit shortcut key on empty page (#10594) @miya
+
+### 🚀 Improvement
+
+* imprv: Admin sidebar mode setting (#10617) @miya
+* imprv: Empty page operation (#10604) @yuki-takei
+* imprv: Support target attribute for anchor links (#10566) @yuki-takei
+* imprv: Use EventTarget instead of EventEmitter on the client side (#10472) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Aftercare for Revisions migration script-bug (#10620) @yuki-takei
+* fix: Omit file upload restriction feature for non image files (#10602) @miya
+
+### 🧰 Maintenance
+
+* support: Use jotai for state management (#10474) @yuki-takei
+* support: Omit importers for esa.io and Qiita (#10584) @yuki-takei
+* support: Configure biome for app client services (#10600) @arafubeatbox
+* support: Configure biome for app client utils (#10601) @arafubeatbox
+* support: Configure biome for app client models/interfaces (#10599) @arafubeatbox
+* support: Configure biome for app server services 4 (#10583) @arafubeatbox
+* support: Configure biome for app server services 3 (#10578) @arafubeatbox
+* ci(mergify): upgrade configuration to current format (#10372) @[mergify[bot]](https://github.com/apps/mergify)
+* support: Configure biome for app server services 2 (#10575) @arafubeatbox
+* support: Configure biome for some app server services (#10574) @arafubeatbox
+* support: Configure biome for apiv3 js files (#10537) @arafubeatbox
+* support: Reapply biome configuration for app apiv3 routes (app-settings, page) (#10555) @arafubeatbox
+* support: Configure biome for apiv3 routes (remaining ts files) (#10536) @arafubeatbox
+* support: Configure biome for app apiv3 routes (app-settings, page) (#10532) @arafubeatbox
+* support: Configure biome for app apiv3 routes (personal-setting, security-settings, interfaces, pages, user) (#10500) @arafubeatbox
+* support: Configure biome for app server middlewares (#10507) @arafubeatbox
+
 ## [v7.3.9](https://github.com/growilabs/compare/v7.3.8...v7.3.9) - 2025-12-09
 
 ### 🐛 Bug Fixes

+ 18 - 0
apps/app/.eslintrc.js

@@ -37,6 +37,11 @@ module.exports = {
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
+    'src/client/components/*.tsx',
+    'src/client/components/*.jsx',
+    'src/client/components/*.ts',
+    'src/client/components/*.js',
+    'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',
     'src/stores/**',
@@ -77,6 +82,19 @@ module.exports = {
     'src/server/service/system-events/**',
     'src/server/service/user-notification/**',
     'src/server/service/yjs/**',
+    'src/server/service/file-uploader/**',
+    'src/server/service/global-notification/**',
+    'src/server/service/growi-bridge/**',
+    'src/server/service/growi-info/**',
+    'src/server/service/import/**',
+    'src/server/service/in-app-notification/**',
+    'src/server/service/interfaces/**',
+    'src/server/service/normalize-data/**',
+    'src/server/service/page/**',
+    'src/client/interfaces/**',
+    'src/client/models/**',
+    'src/client/services/**',
+    'src/client/util/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 3 - 0
apps/app/.gitignore

@@ -14,3 +14,6 @@
 /public/uploads
 /src/styles/prebuilt
 /tmp/
+
+# cache
+/.swc/

BIN
apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c


+ 2 - 0
apps/app/config/logger/config.dev.js

@@ -15,6 +15,7 @@ module.exports = {
   'growi:routes:login': 'debug',
   'growi:routes:login-passport': 'debug',
   'growi:middleware:safe-redirect': 'debug',
+  'growi:services:page': 'debug',
   'growi:service:PassportService': 'debug',
   'growi:service:s2s-messaging:*': 'debug',
   'growi:service:yjs': 'debug',
@@ -31,6 +32,7 @@ module.exports = {
   'growi:service:g2g-transfer': 'debug',
 
   'growi:migration:add-installed-date-to-config': 'debug',
+  'growi:events:page:seen': 'debug',
 
   /*
    * configure level for client

+ 3 - 0
apps/app/docker/codebuild/.terraform.lock.hcl

@@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" {
   version     = "6.12.0"
   constraints = ">= 5.0.0, >= 6.0.0, ~> 6.0"
   hashes = [
+    "h1:8u90EMle+I3Auh4f/LPP6fEfRsAF6xCFnUZF4b7ngEs=",
     "h1:QiSzB4pjONZ4hek1L8Rcd6S9vtP+yMr5iOfczJg5/JI=",
     "zh:054bcbf13c6ac9ddd2247876f82f9b56493e2f71d8c88baeec142386a395165d",
     "zh:195489f16ad5621db2cec80be997d33060462a3b8d442c890bef3eceba34fa4d",
@@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" {
   version     = "3.7.2"
   constraints = ">= 2.1.0"
   hashes = [
+    "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=",
     "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
     "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
     "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
@@ -48,6 +50,7 @@ provider "registry.terraform.io/hashicorp/tls" {
   version     = "4.1.0"
   constraints = ">= 4.0.0"
   hashes = [
+    "h1:Ka8mEwRFXBabR33iN/WTIEW6RP0z13vFsDlwn11Pf2I=",
     "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
     "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
     "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",

+ 1 - 1
apps/app/docker/codebuild/main.tf

@@ -18,6 +18,6 @@ terraform {
 }
 
 provider "aws" {
-  profile = "weseek"
+  profile = "weseek-tf"
   region  = "ap-northeast-1"
 }

+ 8 - 0
apps/app/docker/codebuild/oidc.tf

@@ -23,4 +23,12 @@ data "aws_iam_policy_document" "policy_document" {
       module.codebuild.project_arn
     ]
   }
+  statement {
+    actions = [
+      "logs:GetLogEvents"
+    ]
+    resources = [
+      "arn:aws:logs:*:*:log-group:/aws/codebuild/${module.codebuild.project_name}:*"
+    ]
+  }
 }

+ 2 - 1
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.0-RC.0",
+  "version": "7.4.1-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -191,6 +191,7 @@
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
+    "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",

+ 11 - 0
apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts

@@ -5,6 +5,17 @@ test('Sub navigation sticky changes when scrolling down and up', async ({
 }) => {
   await page.goto('/Sandbox');
 
+  // Wait until the page is scrollable
+  await expect
+    .poll(async () => {
+      const { scrollHeight, innerHeight } = await page.evaluate(() => ({
+        scrollHeight: document.body.scrollHeight,
+        innerHeight: window.innerHeight,
+      }));
+      return scrollHeight > innerHeight + 250;
+    })
+    .toBe(true);
+
   // Sticky
   await page.evaluate(() => window.scrollTo(0, 250));
   await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(

+ 4 - 1
apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts

@@ -10,7 +10,10 @@ test.describe
       await page.goto('/Sandbox/Bootstrap5');
 
       // Create Sharelink
-      await page.getByTestId('open-page-item-control-btn').click();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
       await page
         .getByTestId(
           'open-page-accessories-modal-btn-with-share-link-management-data-tab',

+ 6 - 2
apps/app/playwright/23-editor/saving.spec.ts

@@ -14,8 +14,12 @@ test('Successfully create page under specific path', async ({ page }) => {
 
   await page.goto('/Sandbox');
 
-  await page.keyboard.press(openPageCreateModalShortcutKey);
-  await expect(page.getByTestId('page-create-modal')).toBeVisible();
+  await expect(async () => {
+    await page.keyboard.press(openPageCreateModalShortcutKey);
+    await expect(page.getByTestId('page-create-modal')).toBeVisible({
+      timeout: 1000,
+    });
+  }).toPass();
   page
     .getByTestId('page-create-modal')
     .locator('.rbt-input-main')

+ 0 - 2
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -13,8 +13,6 @@ test('admin/app is successfully loaded', async ({ page }) => {
   await page.goto('/admin/app');
 
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
-  // await expect(page.getByTestId('v5-page-migration')).toBeVisible();
-  await expect(page.locator('#cbFileUpload')).toBeChecked();
 });
 
 test('admin/security is successfully loaded', async ({ page }) => {

+ 9 - 3
apps/app/playwright/60-home/home.spec.ts

@@ -46,12 +46,18 @@ test('Access External account', async ({ page }) => {
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await page.getByTestId('external-accounts-tab-button').first().click();
 
-  // Expect an error toaster to be displayed when the AddExternalAccountsButton is pressed
+  // press AddExternalAccountButton
   await page.getByTestId('grw-external-account-add-button').click();
   await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
   await page.getByTestId('add-external-account-button').click();
-  await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await page.locator('.Toastify__close-button').click();
+
+  // Expect a few failed toasters to be displayed
+  await expect(page.locator('.Toastify__toast').first()).toBeVisible();
+  const toastCloseButtons = page.locator('.Toastify__close-button');
+  const count = await toastCloseButtons.count();
+  for (let i = 0; i < count; i++) {
+    await toastCloseButtons.first().click();
+  }
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
 });
 

+ 7 - 5
apps/app/playwright/utils/CollapseSidebar.ts

@@ -2,12 +2,14 @@ import { expect, type Page } from '@playwright/test';
 
 export const collapseSidebar = async (
   page: Page,
-  isCollapsed: boolean,
+  collapse: boolean,
 ): Promise<void> => {
-  const isSidebarContentsHidden = !(await page
-    .getByTestId('grw-sidebar-contents')
+  await expect(page.getByTestId('grw-sidebar')).toBeVisible();
+
+  const isSidebarCollapsed = !(await page
+    .locator('.grw-sidebar-dock')
     .isVisible());
-  if (isSidebarContentsHidden === isCollapsed) {
+  if (isSidebarCollapsed === collapse) {
     return;
   }
 
@@ -15,7 +17,7 @@ export const collapseSidebar = async (
   await expect(collapseSidebarToggle).toBeVisible();
   await collapseSidebarToggle.click();
 
-  if (isCollapsed) {
+  if (collapse) {
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
   } else {
     await expect(page.locator('.grw-sidebar-dock')).toBeVisible();

+ 34 - 0
apps/app/public/images/customize-settings/collapsed-dark.svg

@@ -0,0 +1,34 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
+  <g transform="translate(17766 9529)">
+    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
+    <g transform="translate(-17700 -9500)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7"/>
+      <rect width="42.646" height="5" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7"/>
+    </g>
+    <g transform="translate(-17700 -9435)">
+      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7"/>
+      <rect width="42.646" height="5" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7"/>
+      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7"/>
+    </g>
+    <g transform="translate(-217 -20)">
+      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
+      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
+    </g>
+    <g transform="translate(-217 -9)">
+      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7"/>
+      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7"/>
+      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7"/>
+    </g>
+    <g transform="translate(-37 186)" fill="#2a2d33" stroke-linecap="round" stroke-linejoin="round">
+      <path d="M -17689.794921875 -9624.6689453125 L -17689.794921875 -9628.078125 L -17689.794921875 -9645.810546875 L -17689.794921875 -9649.60546875 L -17687.201171875 -9646.8359375 L -17675.93359375 -9634.8095703125 L -17674.029296875 -9632.7783203125 L -17676.7734375 -9632.3056640625 L -17682.544921875 -9631.3115234375 L -17687.28125 -9626.9716796875 L -17689.794921875 -9624.6689453125 Z" stroke="none"/>
+      <path d="M -17688.294921875 -9645.810546875 L -17688.294921875 -9628.078125 L -17683.234375 -9632.71484375 L -17677.02734375 -9633.7841796875 L -17688.294921875 -9645.810546875 M -17688.294921875 -9648.810546875 C -17687.482421875 -9648.810546875 -17686.68359375 -9648.4794921875 -17686.10546875 -9647.861328125 L -17674.837890625 -9635.8349609375 C -17674.083984375 -9635.0302734375 -17673.83203125 -9633.8759765625 -17674.18359375 -9632.830078125 C -17674.533203125 -9631.7841796875 -17675.431640625 -9631.0146484375 -17676.517578125 -9630.828125 L -17681.857421875 -9629.908203125 L -17686.267578125 -9625.8662109375 C -17687.14453125 -9625.0634765625 -17688.4140625 -9624.8525390625 -17689.50390625 -9625.33203125 C -17690.591796875 -9625.8115234375 -17691.294921875 -9626.888671875 -17691.294921875 -9628.078125 L -17691.294921875 -9645.810546875 C -17691.294921875 -9647.0419921875 -17690.54296875 -9648.1474609375 -17689.3984375 -9648.6005859375 C -17689.0390625 -9648.7421875 -17688.666015625 -9648.810546875 -17688.294921875 -9648.810546875 Z" stroke="none" fill="#fff"/>
+    </g>
+  </g>
+</svg>

+ 4 - 1
apps/app/public/images/customize-settings/drawer-light.svg → apps/app/public/images/customize-settings/collapsed-light.svg

@@ -13,7 +13,6 @@
       <rect width="170" height="5" transform="translate(0 20)" fill="#abb4bd"/>
       <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
     </g>
-    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#25272f" opacity="0.271"/>
     <g transform="translate(-217 -20)">
       <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
       <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#f3f7fc"/>
@@ -27,5 +26,9 @@
       <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#abb4bd"/>
       <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
     </g>
+    <g transform="translate(-37 186)" fill="#2a2d33" stroke-linecap="round" stroke-linejoin="round">
+      <path d="M -17689.794921875 -9624.6689453125 L -17689.794921875 -9628.078125 L -17689.794921875 -9645.810546875 L -17689.794921875 -9649.60546875 L -17687.201171875 -9646.8359375 L -17675.93359375 -9634.8095703125 L -17674.029296875 -9632.7783203125 L -17676.7734375 -9632.3056640625 L -17682.544921875 -9631.3115234375 L -17687.28125 -9626.9716796875 L -17689.794921875 -9624.6689453125 Z" stroke="none"/>
+      <path d="M -17688.294921875 -9645.810546875 L -17688.294921875 -9628.078125 L -17683.234375 -9632.71484375 L -17677.02734375 -9633.7841796875 L -17688.294921875 -9645.810546875 M -17688.294921875 -9648.810546875 C -17687.482421875 -9648.810546875 -17686.68359375 -9648.4794921875 -17686.10546875 -9647.861328125 L -17674.837890625 -9635.8349609375 C -17674.083984375 -9635.0302734375 -17673.83203125 -9633.8759765625 -17674.18359375 -9632.830078125 C -17674.533203125 -9631.7841796875 -17675.431640625 -9631.0146484375 -17676.517578125 -9630.828125 L -17681.857421875 -9629.908203125 L -17686.267578125 -9625.8662109375 C -17687.14453125 -9625.0634765625 -17688.4140625 -9624.8525390625 -17689.50390625 -9625.33203125 C -17690.591796875 -9625.8115234375 -17691.294921875 -9626.888671875 -17691.294921875 -9628.078125 L -17691.294921875 -9645.810546875 C -17691.294921875 -9647.0419921875 -17690.54296875 -9648.1474609375 -17689.3984375 -9648.6005859375 C -17689.0390625 -9648.7421875 -17688.666015625 -9648.810546875 -17688.294921875 -9648.810546875 Z" stroke="none" fill="#fff"/>
+    </g>
   </g>
 </svg>

+ 0 - 31
apps/app/public/images/customize-settings/drawer-dark.svg

@@ -1,31 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="249" height="160" viewBox="0 0 249 160">
-  <g transform="translate(17766 9529)">
-    <rect width="249" height="160" rx="2" transform="translate(-17766 -9529)" fill="#2a2d33"/>
-    <g transform="translate(-17700 -9500)">
-      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-    </g>
-    <g transform="translate(-17700 -9435)">
-      <rect width="170" height="5" transform="translate(0 10)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="42.646" height="5" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 20)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="170" height="5" transform="translate(0 30)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-    </g>
-    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#16171d" opacity="0.586"/>
-    <g transform="translate(-217 -20)">
-      <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
-      <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#343a40"/>
-    </g>
-    <g transform="translate(-217 -9)">
-      <rect width="47" height="5" transform="translate(-17530 -9431)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9441)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9451)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9461)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9471)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="47" height="5" transform="translate(-17530 -9481)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-      <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#8e9aa7" stroke="rgba(0,0,0,0)" stroke-width="1"/>
-    </g>
-  </g>
-</svg>

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

@@ -356,9 +356,9 @@
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_mail_visibility": "Disclose e-mail for new users",
+    "default_read_only_for_new_user": "Editing Restrictions for New Users",
+    "set_read_only_for_new_user": "Set new users to read-only mode",
     "file_uploading": "File uploading",
-    "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
-    "attach_enable": "You can attach files other than image files if you enable this option.",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "enable_page_bulk_export": "Enable bulk export",
     "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
@@ -448,10 +448,7 @@
     "customize_settings": "Customize",
     "default_sidebar_mode": {
       "title": "Default sidebar mode",
-      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
-      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
-      "dock_mode_default_open": "Open the page as it was opened from the beginning",
-      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+      "desc": "You can set the sidebar mode for new users and guests visiting the page."
     },
     "layout": "Layout",
     "layout_options": {

+ 3 - 6
apps/app/public/static/locales/fr_FR/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
+    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
     "file_uploading": "Téléversement de fichiers",
-    "enable_files_except_image": "Autoriser tout les types de fichiers",
-    "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
@@ -448,10 +448,7 @@
     "customize_settings": "Interface",
     "default_sidebar_mode": {
       "title": "Barre latérale",
-      "desc": "Mode d'affichage et comportement par défaut de la barre latérale.",
-      "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
-      "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
-      "dock_mode_default_close": "Afficher la page comme si elle était fermée"
+      "desc": "Mode d'affichage et comportement par défaut de la barre latérale."
     },
     "layout": "Largeur du contenu",
     "layout_options": {

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

@@ -365,9 +365,9 @@
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_mail_visibility": "新規ユーザーの初期メール公開設定",
+    "default_read_only_for_new_user": "新規ユーザーの編集制限",
+    "set_read_only_for_new_user": "新規ユーザーを閲覧専用にする",
     "file_uploading": "ファイルアップロード",
-    "enable_files_except_image": "画像以外のファイルアップロードを許可",
-    "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "page_bulk_export_settings": "ページ一括エクスポート設定",
     "enable_page_bulk_export": "一括エクスポートを有効にする",
     "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
@@ -457,10 +457,7 @@
     "customize_settings": "カスタマイズ",
     "default_sidebar_mode": {
       "title": "デフォルトのサイドバーモード",
-      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
-      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
-      "dock_mode_default_open": "初めから開いた状態でページを開く",
-      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。"
     },
     "layout": "レイアウト",
     "layout_options": {

+ 3 - 6
apps/app/public/static/locales/ko_KR/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "예): 내부 전용",
     "default_language": "새 사용자를 위한 기본 언어",
     "default_mail_visibility": "새 사용자를 위한 이메일 공개",
+    "default_read_only_for_new_user": "신규 사용자의 편집 제한",
+    "set_read_only_for_new_user": "신규 사용자를 열람 전용으로 설정",
     "file_uploading": "파일 업로드",
-    "enable_files_except_image": "이 옵션을 활성화하면 모든 파일 형식을 업로드할 수 있습니다. 이 옵션이 없으면 이미지 파일 업로드만 지원됩니다.",
-    "attach_enable": "이 옵션을 활성화하면 이미지 파일 외의 파일을 첨부할 수 있습니다.",
     "page_bulk_export_settings": "페이지 대량 내보내기 설정",
     "enable_page_bulk_export": "대량 내보내기 활성화",
     "page_bulk_export_explanation": "모든 사용자가 메뉴에서 한 번에 페이지와 모든 하위 페이지를 내보낼 수 있는 기능을 활성화합니다. 내보낸 데이터는 저장 기간이 지나면 자동으로 삭제됩니다.",
@@ -448,10 +448,7 @@
     "customize_settings": "사용자 지정",
     "default_sidebar_mode": {
       "title": "기본 사이드바 모드",
-      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다.",
-      "dock_mode_default_desc": "독 모드가 선택되었을 때 사이드바의 초기 상태를 설정할 수 있습니다.",
-      "dock_mode_default_open": "처음부터 열린 상태로 페이지 열기",
-      "dock_mode_default_close": "처음부터 닫힌 상태로 페이지 열기"
+      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다."
     },
     "layout": "레이아웃",
     "layout_options": {

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

@@ -365,9 +365,9 @@
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "default_read_only_for_new_user": "新用戶編輯限制",
+    "set_read_only_for_new_user": "將新用戶設為僅限瀏覽",
     "file_uploading": "文件上传",
-    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
     "page_bulk_export_settings": "页面批量导出设置",
     "enable_page_bulk_export": "启用批量导出",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
@@ -457,10 +457,7 @@
     "customize_settings": "页面定制",
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
-      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
-      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
-      "dock_mode_default_open": "从头开始翻页",
-      "dock_mode_default_close": "从头开始打开关闭的页面"
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。"
     },
     "layout": "布局",
     "layout_options": {

+ 11 - 19
apps/app/src/client/components/Admin/App/AppSetting.jsx

@@ -35,14 +35,14 @@ const AppSetting = (props) => {
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       // Convert boolean to string for radio button value
       isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
-      fileUpload: adminAppContainer.state.fileUpload ?? false,
+      isReadOnlyForNewUser: adminAppContainer.state.isReadOnlyForNewUser ?? false,
     });
   }, [
     adminAppContainer.state.title,
     adminAppContainer.state.confidential,
     adminAppContainer.state.globalLang,
     adminAppContainer.state.isEmailPublishedForNewUser,
-    adminAppContainer.state.fileUpload,
+    adminAppContainer.state.isReadOnlyForNewUser,
     reset,
   ]);
 
@@ -57,7 +57,7 @@ const AppSetting = (props) => {
       // Convert string 'true'/'false' to boolean
       const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
       await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
-      await adminAppContainer.changeFileUpload(data.fileUpload);
+      await adminAppContainer.changeIsReadOnlyForNewUserShow(data.isReadOnlyForNewUser);
 
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
@@ -163,31 +163,23 @@ const AppSetting = (props) => {
         </div>
       </div>
 
-      <div className="row mb-2">
+      <div className="row mb-5">
         <label
           className="text-start text-md-end col-md-3 col-form-label"
         >
-          {/* {t('admin:app_setting.file_uploading')} */}
+          {t('admin:app_setting.default_read_only_for_new_user')}
         </label>
-        <div className="col-md-6">
-          <div className="form-check form-check-info">
+        <div className="col-md-6 py-2">
+
+          <div className="form-check form-check-inline">
             <input
               type="checkbox"
-              id="cbFileUpload"
+              id="checkbox-read-only-for-new-user"
               className="form-check-input"
-              {...register('fileUpload')}
+              {...register('isReadOnlyForNewUser')}
             />
-            <label
-              className="form-label form-check-label"
-              htmlFor="cbFileUpload"
-            >
-              {t('admin:app_setting.enable_files_except_image')}
-            </label>
+            <label className="form-label form-check-label" htmlFor="checkbox-read-only-for-new-user">{t('admin:app_setting.set_read_only_for_new_user')}</label>
           </div>
-
-          <p className="form-text text-muted">
-            {t('admin:app_setting.attach_enable')}
-          </p>
         </div>
       </div>
 

+ 5 - 40
apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx

@@ -12,11 +12,11 @@ const CustomizeSidebarsetting = (): JSX.Element => {
   const { t } = useTranslation(['admin', 'commons']);
 
   const {
-    data, update, setIsSidebarCollapsedMode, setIsSidebarClosedAtDockMode,
+    data, update, setIsSidebarCollapsedMode,
   } = useSWRxSidebarConfig();
 
   const { resolvedTheme } = useNextThemes();
-  const drawerIconFileName = `/images/customize-settings/drawer-${resolvedTheme}.svg`;
+  const collapsedIconFileName = `/images/customize-settings/collapsed-${resolvedTheme}.svg`;
   const dockIconFileName = `/images/customize-settings/dock-${resolvedTheme}.svg`;
 
   const onClickSubmit = useCallback(async() => {
@@ -33,7 +33,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
     return <LoadingSpinner />;
   }
 
-  const { isSidebarCollapsedMode, isSidebarClosedAtDockMode } = data;
+  const { isSidebarCollapsedMode } = data;
 
   return (
     <React.Fragment>
@@ -57,9 +57,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                   role="button"
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
-                  <img src={drawerIconFileName} alt="Drawer Mode" />
+                  <img src={collapsedIconFileName} alt="Collapsed Mode" />
                   <div className="card-body text-center">
-                    Drawer Mode
+                    Collapsed Mode
                   </div>
                 </div>
               </div>
@@ -79,41 +79,6 @@ const CustomizeSidebarsetting = (): JSX.Element => {
             </div>
           </div>
 
-          <Card className="card custom-card bg-body-tertiary my-5">
-            <CardBody className="px-0 py-2">
-              {t('customize_settings.default_sidebar_mode.dock_mode_default_desc')}
-            </CardBody>
-          </Card>
-
-          <div className="px-3">
-            <div className="form-check my-3">
-              <input
-                type="radio"
-                id="is-open"
-                className="form-check-input"
-                checked={isSidebarCollapsedMode === false && isSidebarClosedAtDockMode === false}
-                disabled={isSidebarCollapsedMode}
-                onChange={() => setIsSidebarClosedAtDockMode(false)}
-              />
-              <label className="form-label form-check-label" htmlFor="is-open">
-                {t('customize_settings.default_sidebar_mode.dock_mode_default_open')}
-              </label>
-            </div>
-            <div className="form-check my-3">
-              <input
-                type="radio"
-                id="is-closed"
-                className="form-check-input"
-                checked={isSidebarCollapsedMode === false && isSidebarClosedAtDockMode === true}
-                disabled={isSidebarCollapsedMode}
-                onChange={() => setIsSidebarClosedAtDockMode(true)}
-              />
-              <label className="form-label form-check-label" htmlFor="is-closed">
-                {t('customize_settings.default_sidebar_mode.dock_mode_default_close')}
-              </label>
-            </div>
-          </div>
-
           <div className="row my-3">
             <div className="mx-auto">
               <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>

+ 7 - 6
apps/app/src/client/components/AlertSiteUrlUndefined.tsx

@@ -1,5 +1,4 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useSiteUrl } from '~/states/global';
@@ -9,8 +8,7 @@ const isValidUrl = (str: string): boolean => {
     // eslint-disable-next-line no-new
     new URL(str);
     return true;
-  }
-  catch {
+  } catch {
     return false;
   }
 };
@@ -26,9 +24,12 @@ export const AlertSiteUrlUndefined = (): JSX.Element => {
   return (
     <div className="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
       <span className="material-symbols-outlined">error</span>
-      {
-        t('alert.siteUrl_is_not_set', { link: t('headers.app_settings') })
-      } &gt;&gt; <a href="/admin/app">{t('headers.app_settings')}<span className="material-symbols-outlined">login</span></a>
+      {t('alert.siteUrl_is_not_set', { link: t('headers.app_settings') })}{' '}
+      &gt;&gt;{' '}
+      <a href="/admin/app">
+        {t('headers.app_settings')}
+        <span className="material-symbols-outlined">login</span>
+      </a>
     </div>
   );
 };

+ 46 - 21
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,8 +1,11 @@
-import React, { useCallback, useState, type JSX } from 'react';
+import React, {
+  useCallback, useMemo, useState, type JSX,
+} from 'react';
 
 import nodePath from 'path';
 
 import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
@@ -59,17 +62,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     ...bookmarkedPage, parentFolder,
   };
 
+  const bookmarkedPageId = bookmarkedPage?._id;
+  const bookmarkedPagePath = bookmarkedPage?.path;
+  const bookmarkedPageRevision = bookmarkedPage?.revision;
+
   const onClickMoveToRootHandler = useCallback(async() => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
     try {
-      await addBookmarkToFolder(bookmarkedPage._id, null);
+      await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, bookmarkedPage]);
+  }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
   const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
     if (shouldBookmark) {
@@ -91,23 +98,23 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   }, []);
 
   const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
     if (inputText.trim() === '') {
       return cancel();
     }
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPage.path) {
+    if (newPagePath === bookmarkedPagePath) {
       setRenameInputShown(false);
       return;
     }
 
     try {
       setRenameInputShown(false);
-      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
       bookmarkFolderTreeMutation();
       mutatePageInfo();
     }
@@ -115,26 +122,26 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
-    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
       throw Error('_id and path must not be null.');
     }
 
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
-        _id: bookmarkedPage._id,
-        revision: bookmarkedPage.revision as string,
-        path: bookmarkedPage.path,
+        _id: bookmarkedPageId,
+        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
+        path: bookmarkedPagePath,
       },
       meta: pageInfo,
     };
 
     onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPage, onClickDeleteMenuItemHandler]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
 
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
@@ -156,15 +163,33 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
 
+  const {
+    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
+  } = useMemo(() => {
+    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+    if (bookmarkedPagePath == null) {
+      return {
+        pageTitle: '',
+        formerPagePath: '',
+        isFormerRoot: false,
+        bookmarkItemId,
+      };
+    }
+
+    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
+    return {
+      pageTitle: dPagePath.latter,
+      formerPagePath: dPagePath.former,
+      isFormerRoot: dPagePath.isFormerRoot,
+      bookmarkItemId,
+    };
+  }, [bookmarkedPagePath, bookmarkedPageId]);
+
   if (bookmarkedPage == null) {
     return <></>;
   }
 
-  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
-  const { latter: pageTitle, former: formerPagePath } = dPagePath;
-
-  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -215,7 +240,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
+          {isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
       </li>
     </DragAndDropWrapper>

+ 27 - 21
apps/app/src/client/components/Comments.tsx

@@ -1,11 +1,8 @@
-import React, {
-  useEffect, useMemo, useRef, type JSX,
-} from 'react';
-
+import React, { type JSX, useEffect, useMemo, useRef } from 'react';
+import dynamic from 'next/dynamic';
 import type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 
 import { useCurrentUser } from '~/states/global';
@@ -13,25 +10,28 @@ import { useIsTrashPage } from '~/states/page';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
-
 const { isTopPage } = pagePathUtils;
 
-
-const PageComment = dynamic(() => import('~/client/components/PageComment').then(mod => mod.PageComment), { ssr: false });
-const CommentEditorPre = dynamic(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditorPre), { ssr: false });
+const PageComment = dynamic(
+  () =>
+    import('~/client/components/PageComment').then((mod) => mod.PageComment),
+  { ssr: false },
+);
+const CommentEditorPre = dynamic(
+  () =>
+    import('./PageComment/CommentEditor').then((mod) => mod.CommentEditorPre),
+  { ssr: false },
+);
 
 type CommentsProps = {
-  pageId: string,
-  pagePath: string,
-  revision: IRevisionHasId,
-  onLoaded?: () => void,
-}
+  pageId: string;
+  pagePath: string;
+  revision: IRevisionHasId;
+  onLoaded?: () => void;
+};
 
 export const Comments = (props: CommentsProps): JSX.Element => {
-
-  const {
-    pageId, pagePath, revision, onLoaded,
-  } = props;
+  const { pageId, pagePath, revision, onLoaded } = props;
 
   const { t } = useTranslation('');
 
@@ -42,7 +42,10 @@ export const Comments = (props: CommentsProps): JSX.Element => {
 
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
 
-  const onLoadedDebounced = useMemo(() => debounce(500, () => onLoaded?.()), [onLoaded]);
+  const onLoadedDebounced = useMemo(
+    () => debounce(500, () => onLoaded?.()),
+    [onLoaded],
+  );
 
   useEffect(() => {
     const parent = pageCommentParentRef.current;
@@ -73,7 +76,11 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   return (
     <div className="page-comments-row mt-5 py-4 border-top d-edit-none d-print-none">
       <h4 className="mb-3">{t('page_comment.comments')}</h4>
-      <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
+      <div
+        id="page-comments-list"
+        className="page-comments-list"
+        ref={pageCommentParentRef}
+      >
         <PageComment
           pageId={pageId}
           pagePath={pagePath}
@@ -93,5 +100,4 @@ export const Comments = (props: CommentsProps): JSX.Element => {
       )}
     </div>
   );
-
 };

+ 54 - 2
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,4 +1,4 @@
-import { type IPageInfoForOperation } from '@growi/core/dist/interfaces';
+import { type IPageInfoForOperation, type IPageInfoForEmpty } from '@growi/core/dist/interfaces';
 import {
   fireEvent, screen, within,
 } from '@testing-library/dom';
@@ -8,14 +8,16 @@ import { mock } from 'vitest-mock-extended';
 import { PageItemControl } from './PageItemControl';
 
 
-// mock for isIPageInfoForOperation
+// mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 const mocks = vi.hoisted(() => ({
   isIPageInfoForOperationMock: vi.fn(),
+  isIPageInfoForEmptyMock: vi.fn(),
 }));
 
 vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForOperation: mocks.isIPageInfoForOperationMock,
+  isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 
 
@@ -32,6 +34,8 @@ describe('PageItemControl.tsx', () => {
           return true;
         }
       });
+      // return false for isIPageInfoForEmpty since we're using IPageInfoForOperation
+      mocks.isIPageInfoForEmptyMock.mockReturnValue(false);
 
       const props = {
         pageId: 'dummy-page-id',
@@ -51,5 +55,53 @@ describe('PageItemControl.tsx', () => {
       // then
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
+
+    it('with empty page (IPageInfoForEmpty)', async() => {
+      // setup - Create an empty page mock with required properties
+      const pageInfo: IPageInfoForEmpty = {
+        emptyPageId: 'empty-page-id',
+        isNotFound: false,
+        isEmpty: true,
+        isV5Compatible: true,
+        isMovable: true, // Allow rename operation
+        isDeletable: true,
+        isAbleToDeleteCompletely: false,
+        isRevertible: false,
+        bookmarkCount: 0,
+        isBookmarked: false,
+      };
+
+      const onClickRenameMenuItemMock = vi.fn();
+
+      // return false for isIPageInfoForOperation since this is an empty page
+      mocks.isIPageInfoForOperationMock.mockReturnValue(false);
+
+      // return true when the argument is pageInfo (empty page)
+      mocks.isIPageInfoForEmptyMock.mockImplementation((arg) => {
+        if (arg === pageInfo) {
+          return true;
+        }
+        return false;
+      });
+
+      const props = {
+        pageId: 'dummy-page-id',
+        isEnableActions: true,
+        pageInfo,
+        onClickRenameMenuItem: onClickRenameMenuItemMock,
+      };
+
+      render(<PageItemControl {...props} />);
+
+      // when
+      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      fireEvent.click(button);
+      const renameMenuItem = await screen.findByTestId('rename-page-btn');
+      fireEvent.click(renameMenuItem);
+
+      // then
+      expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith('dummy-page-id', pageInfo);
+    });
   });
 });

+ 19 - 14
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type IPageInfoExt, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -76,21 +76,24 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
-    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+    if (onClickBookmarkMenuItem == null) return;
+
+    if (!isIPageInfoForEmpty(pageInfo) && !isIPageInfoForOperation(pageInfo)) {
       return;
     }
+
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
-    if (onClickRenameMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isMovable) {
+    if (onClickRenameMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
+
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
@@ -111,10 +114,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
-    if (pageInfo == null || onClickDeleteMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isDeletable) {
+    if (onClickDeleteMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       return;
     }
@@ -173,7 +175,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -186,7 +188,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isMovable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
@@ -211,7 +214,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isRevertible && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -242,7 +246,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         {/* divider */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isDeletable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 2 - 2
apps/app/src/client/components/CompleteUserRegistration.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const CompleteUserRegistration: FC = () => {
@@ -15,7 +14,8 @@ export const CompleteUserRegistration: FC = () => {
           </p>
           {/* If the transition source is "/login", use <a /> tag since the transition will not occur if next/link is used. */}
           <a href="/login">
-            <span className="material-symbols-outlined">login</span>{t('Sign in is here')}
+            <span className="material-symbols-outlined">login</span>
+            {t('Sign in is here')}
           </a>
         </div>
       </div>

+ 74 - 58
apps/app/src/client/components/CompleteUserRegistrationForm.tsx

@@ -1,32 +1,28 @@
-import React, { useState, useEffect, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
 
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 
 import { toastError } from '../util/toastr';
-
 import { CompleteUserRegistration } from './CompleteUserRegistration';
 
-
 import styles from './CompleteUserRegistrationForm.module.scss';
 
 const moduleClass = styles['complete-user-registration-form'] ?? '';
 
-
 interface Props {
-  email: string,
-  token: string,
-  errorCode?: UserActivationErrorCode,
-  registrationMode: RegistrationMode,
-  isEmailAuthenticationEnabled: boolean,
+  email: string;
+  token: string;
+  errorCode?: UserActivationErrorCode;
+  registrationMode: RegistrationMode;
+  isEmailAuthenticationEnabled: boolean;
 }
 
 export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
-
   const { t } = useTranslation();
   const {
     email,
@@ -48,14 +44,13 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   const router = useRouter();
 
   useEffect(() => {
-    const delayDebounceFn = setTimeout(async() => {
+    const delayDebounceFn = setTimeout(async () => {
       try {
         const { data } = await apiv3Get('/check-username', { username });
         if (data.ok) {
           setUsernameAvailable(data.valid);
         }
-      }
-      catch (error) {
+      } catch (error) {
         toastError(error);
       }
     }, 500);
@@ -63,64 +58,83 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
     return () => clearTimeout(delayDebounceFn);
   }, [username]);
 
-  const handleSubmitRegistration = useCallback(async(e) => {
-    e.preventDefault();
-    setDisableForm(true);
-    try {
-      const res = await apiv3Post('/complete-registration', {
-        username, name, password, token,
-      });
-
-      setIsSuccessToRagistration(true);
-
-      const { redirectTo } = res.data;
-      if (redirectTo != null) {
-        router.push(redirectTo);
+  const handleSubmitRegistration = useCallback(
+    async (e) => {
+      e.preventDefault();
+      setDisableForm(true);
+      try {
+        const res = await apiv3Post('/complete-registration', {
+          username,
+          name,
+          password,
+          token,
+        });
+
+        setIsSuccessToRagistration(true);
+
+        const { redirectTo } = res.data;
+        if (redirectTo != null) {
+          router.push(redirectTo);
+        }
+      } catch (err) {
+        toastError(err);
+        setDisableForm(false);
+        setIsSuccessToRagistration(false);
       }
-    }
-    catch (err) {
-      toastError(err);
-      setDisableForm(false);
-      setIsSuccessToRagistration(false);
-    }
-  }, [username, name, password, token, router]);
-
-  if (isSuccessToRagistration && registrationMode === RegistrationMode.RESTRICTED) {
+    },
+    [username, name, password, token, router],
+  );
+
+  if (
+    isSuccessToRagistration &&
+    registrationMode === RegistrationMode.RESTRICTED
+  ) {
     return <CompleteUserRegistration />;
   }
 
   return (
     <>
-      <div className={`${moduleClass} nologin-dialog mx-auto rounded-4 rounded-top-0`} id="nologin-dialog">
+      <div
+        className={`${moduleClass} nologin-dialog mx-auto rounded-4 rounded-top-0`}
+        id="nologin-dialog"
+      >
         <div className="row mx-0">
           <div className="col-12 px-4">
+            {errorCode != null &&
+              errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND && (
+                <p className="alert alert-danger">
+                  <span>Token not found</span>
+                </p>
+              )}
 
-            { (errorCode != null && errorCode === UserActivationErrorCode.TOKEN_NOT_FOUND) && (
-              <p className="alert alert-danger">
-                <span>Token not found</span>
-              </p>
-            )}
-
-            { (errorCode != null && errorCode === UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE) && (
-              <p className="alert alert-danger">
-                <span>{t('message.incorrect_token_or_expired_url')}</span>
-              </p>
-            )}
+            {errorCode != null &&
+              errorCode ===
+                UserActivationErrorCode.USER_REGISTRATION_ORDER_IS_NOT_APPROPRIATE && (
+                <p className="alert alert-danger">
+                  <span>{t('message.incorrect_token_or_expired_url')}</span>
+                </p>
+              )}
 
-            { !isEmailAuthenticationEnabled && (
+            {!isEmailAuthenticationEnabled && (
               <p className="alert alert-danger">
                 <span>{t('message.email_authentication_is_not_enabled')}</span>
               </p>
             )}
 
-            <form role="form" onSubmit={handleSubmitRegistration} id="registration-form">
+            <form onSubmit={handleSubmitRegistration} id="registration-form">
               <input type="hidden" name="token" value={token} />
 
               <div className="input-group">
                 <span className="p-2 text-white opacity-75">
                   <span className="material-symbols-outlined">mail</span>
                 </span>
-                <input type="text" className="form-control rounded" placeholder={t('Email')} disabled value={email} />
+                <input
+                  type="text"
+                  className="form-control rounded"
+                  placeholder={t('Email')}
+                  disabled
+                  value={email}
+                />
               </div>
 
               <div className="input-group" id="input-group-username">
@@ -132,7 +146,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   className="form-control rounded"
                   placeholder={t('User ID')}
                   name="username"
-                  onChange={e => setUsername(e.target.value)}
+                  onChange={(e) => setUsername(e.target.value)}
                   required
                   disabled={forceDisableForm || disableForm}
                 />
@@ -158,7 +172,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   placeholder={t('Name')}
                   name="name"
                   value={name}
-                  onChange={e => setName(e.target.value)}
+                  onChange={(e) => setName(e.target.value)}
                   required
                   disabled={forceDisableForm || disableForm}
                 />
@@ -174,7 +188,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   placeholder={t('Password')}
                   name="password"
                   value={password}
-                  onChange={e => setPassword(e.target.value)}
+                  onChange={(e) => setPassword(e.target.value)}
                   required
                   disabled={forceDisableForm || disableForm}
                 />
@@ -187,7 +201,9 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   className="btn btn-secondary btn-register col-6 mx-auto d-flex"
                 >
                   <span>
-                    <span className="material-symbols-outlined">person_add</span>
+                    <span className="material-symbols-outlined">
+                      person_add
+                    </span>
                   </span>
                   <span className="flex-grow-1">{t('Create')}</span>
                 </button>
@@ -195,7 +211,8 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
 
               <div className="input-group mt-5 d-flex">
                 <a href="https://growi.org" className="link-growi-org">
-                  <span className="growi">GROWI</span><span className="org">.org</span>
+                  <span className="growi">GROWI</span>
+                  <span className="org">.org</span>
                 </a>
               </div>
             </form>
@@ -204,5 +221,4 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
       </div>
     </>
   );
-
 };

+ 31 - 7
apps/app/src/client/components/ContentLinkButtons.tsx

@@ -1,13 +1,18 @@
 import React, { type JSX } from 'react';
-
-import { USER_STATUS, type IUserHasId } from '@growi/core';
+import { type IUserHasId, USER_STATUS } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { Link as ScrollLink } from 'react-scroll';
 
+import {
+  BOOKMARKS_LIST_ID,
+  RECENT_ACTIVITY_LIST_ID,
+  RECENTLY_CREATED_LIST_ID,
+} from './UsersHomepageFooter.consts';
+
 const BookMarkLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to="bookmarks-list" offset={-120}>
+    <ScrollLink to={BOOKMARKS_LIST_ID} offset={-120}>
       <button
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
@@ -24,7 +29,7 @@ BookMarkLinkButton.displayName = 'BookMarkLinkButton';
 const RecentlyCreatedLinkButton = React.memo(() => {
   const { t } = useTranslation();
   return (
-    <ScrollLink to="recently-created-list" offset={-120}>
+    <ScrollLink to={RECENTLY_CREATED_LIST_ID} offset={-120}>
       <button
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
@@ -38,12 +43,30 @@ const RecentlyCreatedLinkButton = React.memo(() => {
 
 RecentlyCreatedLinkButton.displayName = 'RecentlyCreatedLinkButton';
 
+const RecentActivityLinkButton = React.memo(() => {
+  const { t } = useTranslation();
+  return (
+    <ScrollLink to={RECENT_ACTIVITY_LIST_ID} offset={-120}>
+      <button
+        type="button"
+        className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
+      >
+        <span className="material-symbols-outlined mx-1">update</span>
+        <span>{t('user_home_page.recent_activity')}</span>
+      </button>
+    </ScrollLink>
+  );
+});
+
+RecentActivityLinkButton.displayName = 'RecentActivityLinkButton';
 
 export type ContentLinkButtonsProps = {
-  author?: IUserHasId,
-}
+  author?: IUserHasId;
+};
 
-export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+export const ContentLinkButtons = (
+  props: ContentLinkButtonsProps,
+): JSX.Element => {
   const { author } = props;
 
   if (author == null || author.status === USER_STATUS.DELETED) {
@@ -54,6 +77,7 @@ export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element
     <div className="d-grid gap-2">
       <BookMarkLinkButton />
       <RecentlyCreatedLinkButton />
+      <RecentActivityLinkButton />
     </div>
   );
 };

+ 20 - 7
apps/app/src/client/components/DataTransferForm.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
@@ -15,19 +14,31 @@ const DataTransferForm = (): JSX.Element => {
   return (
     <div data-testid="installerForm" className="py-3 px-4">
       <p className="text-white fs-5 mt-2">
-        <strong>{ t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
+        <strong>{t('g2g_data_transfer.transfer_data_to_this_growi')}</strong>
       </p>
 
       <div className="row mt-3">
         <div className="col-md-12">
-          <button type="button" className="btn btn-primary w-100" onClick={generateTransferKey}>
+          <button
+            type="button"
+            className="btn btn-primary w-100"
+            onClick={generateTransferKey}
+          >
             {t('g2g_data_transfer.publish_transfer_key')}
           </button>
         </div>
         <div className="col-md-12 mt-2">
           <div className="d-flex">
-            <input className="form-control" type="text" value={transferKey} readOnly />
-            <CustomCopyToClipBoard textToBeCopied={transferKey} message="copied_to_clipboard" />
+            <input
+              className="form-control"
+              type="text"
+              value={transferKey}
+              readOnly
+            />
+            <CustomCopyToClipBoard
+              textToBeCopied={transferKey}
+              message="copied_to_clipboard"
+            />
           </div>
         </div>
       </div>
@@ -37,9 +48,11 @@ const DataTransferForm = (): JSX.Element => {
         <p className="mb-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
         <p
           className="mb-0"
-          // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML link
           dangerouslySetInnerHTML={{
-            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+            __html: t('g2g_data_transfer.transfer_to_growi_cloud', {
+              documentationUrl,
+            }),
           }}
         />
       </div>

+ 77 - 51
apps/app/src/client/components/DescendantsPageList.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import type {
   IDataWithMeta,
   IPageHasId,
@@ -11,10 +10,16 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPagingResult } from '~/interfaces/paging-result';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import {
   mutatePageTree,
-  useSWRxPageInfoForList, useSWRxPageList, mutateRecentlyUpdated,
+  mutateRecentlyUpdated,
+  useSWRxPageInfoForList,
+  useSWRxPageList,
 } from '~/stores/page-listing';
 
 import type { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
@@ -22,30 +27,36 @@ import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 
 type SubstanceProps = {
-  pagingResult: IPagingResult<IPageHasId> | undefined,
-  activePage: number,
-  setActivePage: (activePage: number) => void,
-  forceHideMenuItems?: ForceHideMenuItems,
-  onPagesDeleted?: OnDeletedFunction,
-  onPagePutBacked?: OnPutBackedFunction,
-}
-
-const convertToIDataWithMeta = (page: IPageHasId): IDataWithMeta<IPageHasId> => {
+  pagingResult: IPagingResult<IPageHasId> | undefined;
+  activePage: number;
+  setActivePage: (activePage: number) => void;
+  forceHideMenuItems?: ForceHideMenuItems;
+  onPagesDeleted?: OnDeletedFunction;
+  onPagePutBacked?: OnPutBackedFunction;
+};
+
+const convertToIDataWithMeta = (
+  page: IPageHasId,
+): IDataWithMeta<IPageHasId> => {
   return { data: page };
 };
 
 const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
-
   const { t } = useTranslation();
 
   const {
-    pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+    pagingResult,
+    activePage,
+    setActivePage,
+    forceHideMenuItems,
+    onPagesDeleted,
+    onPagePutBacked,
   } = props;
 
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
 
-  const pageIds = pagingResult?.items?.map(page => page._id);
+  const pageIds = pagingResult?.items?.map((page) => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
@@ -53,36 +64,43 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
   // initial data
   if (pagingResult != null) {
     // convert without meta at first
-    const dataWithMetas = pagingResult.items.map(page => convertToIDataWithMeta(page));
+    const dataWithMetas = pagingResult.items.map((page) =>
+      convertToIDataWithMeta(page),
+    );
     // inject data for listing
     pageWithMetas = injectTo(dataWithMetas);
   }
 
-  const pageDeletedHandler: OnDeletedFunction = useCallback((...args) => {
-    const path = args[0];
-    const isCompletely = args[2];
-    if (path == null || isCompletely == null) {
-      toastSuccess(t('deleted_page'));
-    }
-    else {
-      toastSuccess(t('deleted_pages_completely', { path }));
-    }
-    mutateRecentlyUpdated();
-    mutatePageTree();
-    if (onPagesDeleted != null) {
-      onPagesDeleted(...args);
-    }
-  }, [onPagesDeleted, t]);
-
-  const pagePutBackedHandler: OnPutBackedFunction = useCallback((path) => {
-    toastSuccess(t('page_has_been_reverted', { path }));
-
-    mutateRecentlyUpdated();
-    mutatePageTree();
-    if (onPagePutBacked != null) {
-      onPagePutBacked(path);
-    }
-  }, [onPagePutBacked, t]);
+  const pageDeletedHandler: OnDeletedFunction = useCallback(
+    (...args) => {
+      const path = args[0];
+      const isCompletely = args[2];
+      if (path == null || isCompletely == null) {
+        toastSuccess(t('deleted_page'));
+      } else {
+        toastSuccess(t('deleted_pages_completely', { path }));
+      }
+      mutateRecentlyUpdated();
+      mutatePageTree();
+      if (onPagesDeleted != null) {
+        onPagesDeleted(...args);
+      }
+    },
+    [onPagesDeleted, t],
+  );
+
+  const pagePutBackedHandler: OnPutBackedFunction = useCallback(
+    (path) => {
+      toastSuccess(t('page_has_been_reverted', { path }));
+
+      mutateRecentlyUpdated();
+      mutatePageTree();
+      if (onPagePutBacked != null) {
+        onPagePutBacked(path);
+      }
+    },
+    [onPagePutBacked, t],
+  );
 
   if (pagingResult == null) {
     return (
@@ -107,35 +125,43 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
         onPagePutBacked={pagePutBackedHandler}
       />
 
-      { showPager && (
+      {showPager && (
         <div className="my-4">
           <PaginationWrapper
             activePage={activePage}
-            changePage={selectedPageNumber => setActivePage(selectedPageNumber)}
+            changePage={(selectedPageNumber) =>
+              setActivePage(selectedPageNumber)
+            }
             totalItemsCount={pagingResult.totalCount}
             pagingLimit={pagingResult.limit}
             align="center"
           />
         </div>
-      ) }
+      )}
     </>
   );
 };
 
 export type DescendantsPageListProps = {
-  path: string,
-  limit?: number,
-  forceHideMenuItems?: ForceHideMenuItems,
-}
+  path: string;
+  limit?: number;
+  forceHideMenuItems?: ForceHideMenuItems;
+};
 
-export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Element => {
+export const DescendantsPageList = (
+  props: DescendantsPageListProps,
+): JSX.Element => {
   const { path, limit, forceHideMenuItems } = props;
 
   const [activePage, setActivePage] = useState(1);
 
   const isSharedUser = useIsSharedUser();
 
-  const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
+  const {
+    data: pagingResult,
+    error,
+    mutate,
+  } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
 
   if (error != null) {
     return (

+ 16 - 19
apps/app/src/client/components/DuplicatedPathsTable.tsx

@@ -1,23 +1,21 @@
-import React from 'react';
-
+import type React from 'react';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 
-
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 type DuplicatedPathsTableProps = {
-  existingPaths: string[],
-  fromPath: string,
-  toPath: string
-}
+  existingPaths: string[];
+  fromPath: string;
+  toPath: string;
+};
 
-const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: DuplicatedPathsTableProps) => {
+const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (
+  props: DuplicatedPathsTableProps,
+) => {
   const { t } = useTranslation();
 
-  const {
-    fromPath, toPath, existingPaths,
-  } = props;
+  const { fromPath, toPath, existingPaths } = props;
 
   return (
     <table className="table table-bordered grw-duplicated-paths-table">
@@ -29,17 +27,17 @@ const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: Duplic
       </thead>
       <tbody className="overflow-auto d-block">
         {existingPaths.map((existPath) => {
-          const convertedPath = convertToNewAffiliationPath(toPath, fromPath, existPath);
+          const convertedPath = convertToNewAffiliationPath(
+            toPath,
+            fromPath,
+            existPath,
+          );
           return (
             <tr key={existPath} className="d-flex">
               <td className="text-break w-50">
-                <a href={convertedPath}>
-                  {convertedPath}
-                </a>
-              </td>
-              <td className="text-break text-danger w-50">
-                {existPath}
+                <a href={convertedPath}>{convertedPath}</a>
               </td>
+              <td className="text-break text-danger w-50">{existPath}</td>
             </tr>
           );
         })}
@@ -48,5 +46,4 @@ const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: Duplic
   );
 };
 
-
 export default DuplicatedPathsTable;

+ 3 - 5
apps/app/src/client/components/EmptyTrashButton.tsx

@@ -1,13 +1,11 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 type EmptyTrashButtonProps = {
-  onEmptyTrashButtonClick: () => void,
-  disableEmptyButton: boolean
+  onEmptyTrashButtonClick: () => void;
+  disableEmptyButton: boolean;
 };
 
-
 const EmptyTrashButton = (props: EmptyTrashButtonProps): JSX.Element => {
   const { onEmptyTrashButtonClick, disableEmptyButton } = props;
   const { t } = useTranslation();

+ 4 - 5
apps/app/src/client/components/ErrorBoudary.jsx

@@ -1,12 +1,10 @@
 import React from 'react';
-
 import PropTypes from 'prop-types';
 
 /**
  * @see https://reactjs.org/docs/error-boundaries.html
  */
 class ErrorBoundary extends React.Component {
-
   constructor(props) {
     super(props);
     this.state = { error: null, errorInfo: null };
@@ -26,7 +24,6 @@ class ErrorBoundary extends React.Component {
   render() {
     const { error, errorInfo } = this.state;
     if (errorInfo != null) {
-
       // split componetStack
       // see https://regex101.com/r/Uc448G/1
       const firstStack = errorInfo.componentStack.split(/\s*in\s/)[1];
@@ -36,7 +33,10 @@ class ErrorBoundary extends React.Component {
           <div className="card-header">Error occured in {firstStack}</div>
           <div className="card-body">
             <h5>{error && error.toString()}</h5>
-            <details className="card custom-card small mb-0" style={{ whiteSpace: 'pre-wrap' }}>
+            <details
+              className="card custom-card small mb-0"
+              style={{ whiteSpace: 'pre-wrap' }}
+            >
               {errorInfo.componentStack}
             </details>
           </div>
@@ -47,7 +47,6 @@ class ErrorBoundary extends React.Component {
     // Normally, just render children
     return this.props.children;
   }
-
 }
 
 ErrorBoundary.propTypes = {

+ 6 - 6
apps/app/src/client/components/ExpandOrContractButton.tsx

@@ -4,14 +4,13 @@ import React from 'react';
 import styles from './ExpandOrContractButton.module.scss';
 
 type Props = {
-  isWindowExpanded: boolean,
-  contractWindow?: () => void,
-  expandWindow?: () => void,
+  isWindowExpanded: boolean;
+  contractWindow?: () => void;
+  expandWindow?: () => void;
 };
 
 const moduleClass = styles['btn-expand-or-contract'] ?? '';
 
-
 const ExpandOrContractButton: FC<Props> = (props: Props) => {
   const { isWindowExpanded, contractWindow, expandWindow } = props;
 
@@ -31,7 +30,9 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
     <button
       type="button"
       className={`btn ${moduleClass}`}
-      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+      onClick={
+        isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler
+      }
     >
       <span className="material-symbols-outlined fw-bold">
         {isWindowExpanded ? 'close_fullscreen' : 'open_in_full'}
@@ -40,5 +41,4 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
   );
 };
 
-
 export default ExpandOrContractButton;

+ 11 - 6
apps/app/src/client/components/ForbiddenPage.tsx

@@ -1,10 +1,9 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 type Props = {
-  isLinkSharingDisabled?: boolean,
-}
+  isLinkSharingDisabled?: boolean;
+};
 
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -14,7 +13,9 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
       <div className="row not-found-message-row mb-4">
         <div className="col-lg-12">
           <h2 className="text-muted">
-            <span className="material-symbols-outlined" aria-hidden="true">block</span>
+            <span className="material-symbols-outlined" aria-hidden="true">
+              block
+            </span>
             Forbidden
           </h2>
         </div>
@@ -23,8 +24,12 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
-            <span className="material-symbols-outlined" aria-hidden="true">lock</span>
-            { props.isLinkSharingDisabled ? t('share_links.link_sharing_is_disabled') : t('Browsing of this page is restricted')}
+            <span className="material-symbols-outlined" aria-hidden="true">
+              lock
+            </span>
+            {props.isLinkSharingDisabled
+              ? t('share_links.link_sharing_is_disabled')
+              : t('Browsing of this page is restricted')}
           </p>
         </div>
       </div>

+ 10 - 6
apps/app/src/client/components/FormattedDistanceDate.jsx

@@ -1,13 +1,12 @@
 import React from 'react';
-
-import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
+import { differenceInSeconds, format, formatDistanceStrict } from 'date-fns';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 
 const FormattedDistanceDate = (props) => {
-
   // cast to date if string
-  const date = (typeof props.date === 'string') ? new Date(props.date) : props.date;
+  const date =
+    typeof props.date === 'string' ? new Date(props.date) : props.date;
 
   const baseDate = props.baseDate || new Date();
 
@@ -23,14 +22,19 @@ const FormattedDistanceDate = (props) => {
   return (
     <>
       <span id={elemId}>{formatDistanceStrict(date, baseDate)}</span>
-      {props.isShowTooltip && <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>{dateFormatted}</UncontrolledTooltip>}
+      {props.isShowTooltip && (
+        <UncontrolledTooltip placement="bottom" fade={false} target={elemId}>
+          {dateFormatted}
+        </UncontrolledTooltip>
+      )}
     </>
   );
 };
 
 FormattedDistanceDate.propTypes = {
   id: PropTypes.string.isRequired,
-  date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]).isRequired,
+  date: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)])
+    .isRequired,
   baseDate: PropTypes.instanceOf(Date),
   // the number(sec) from 'baseDate' to avoid format
   differenceForAvoidingFormat: PropTypes.number,

+ 0 - 40
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,40 +0,0 @@
-import { useEffect } from 'react';
-
-import PropTypes from 'prop-types';
-
-import { useIsEditable } from '~/states/page';
-import { EditorMode, useEditorMode } from '~/states/ui/editor';
-
-const EditPage = (props) => {
-  const isEditable = useIsEditable();
-  const { setEditorMode } = useEditorMode();
-
-  // setup effect
-  useEffect(() => {
-    if (!isEditable) {
-      return;
-    }
-
-    // ignore when dom that has 'modal in' classes exists
-    if (document.getElementsByClassName('modal in').length > 0) {
-      return;
-    }
-
-    setEditorMode(EditorMode.Editor);
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [isEditable, props, setEditorMode]);
-
-  return null;
-};
-
-EditPage.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-EditPage.getHotkeyStrokes = () => {
-  return [['e']];
-};
-
-export default EditPage;

+ 74 - 0
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useStartEditing } from '~/client/services/use-start-editing';
+import { toastError } from '~/client/util/toastr';
+import { useCurrentPathname } from '~/states/global';
+import { useIsEditable, useCurrentPagePath } from '~/states/page';
+
+type Props = {
+  onDeleteRender: () => void,
+}
+
+/**
+ * Custom hook for edit page logic
+ */
+const useEditPage = (
+    onCompleted: () => void,
+    onError?: (path: string) => void,
+): void => {
+  const isEditable = useIsEditable();
+  const startEditing = useStartEditing();
+  const currentPagePath = useCurrentPagePath();
+  const currentPathname = useCurrentPathname();
+  const path = currentPagePath ?? currentPathname;
+  const isExecutedRef = useRef(false);
+
+  useEffect(() => {
+    (async() => {
+      // Prevent multiple executions
+      if (isExecutedRef.current) return;
+      isExecutedRef.current = true;
+
+      if (!isEditable) {
+        return;
+      }
+
+      // ignore when dom that has 'modal in' classes exists
+      if (document.getElementsByClassName('modal in').length > 0) {
+        return;
+      }
+
+      try {
+        await startEditing(path);
+      }
+      catch (err) {
+        onError?.(path);
+      }
+
+      onCompleted();
+    })();
+  }, [startEditing, isEditable, path, onCompleted, onError]);
+};
+
+/**
+ * EditPage component - handles hotkey 'e' for editing
+ */
+const EditPage = (props: Props): null => {
+  const { t } = useTranslation('commons');
+
+  const handleError = useCallback((path: string) => {
+    toastError(t('toaster.create_failed', { target: path }));
+  }, [t]);
+
+  useEditPage(props.onDeleteRender, handleError);
+
+  return null;
+};
+
+EditPage.getHotkeyStrokes = () => {
+  return [['e']];
+};
+
+export default EditPage;

+ 25 - 16
apps/app/src/client/components/IdenticalPathPage.tsx

@@ -1,23 +1,25 @@
 import type { FC, JSX } from 'react';
 import React from 'react';
-
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 
 import { useCurrentPathname } from '~/states/global';
-import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
+import {
+  useSWRxPageInfoForList,
+  useSWRxPagesByPath,
+} from '~/stores/page-listing';
 
 import { PageListItemL } from './PageList/PageListItemL';
 
-
 import styles from './IdenticalPathPage.module.scss';
 
-
 type IdenticalPathAlertProps = {
-  path? : string | null,
-}
+  path?: string | null;
+};
 
-const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+const IdenticalPathAlert: FC<IdenticalPathAlertProps> = (
+  props: IdenticalPathAlertProps,
+) => {
   const { path } = props;
   const { t } = useTranslation();
 
@@ -30,16 +32,26 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
     _pageName = devidedPath.latter;
   }
 
-
   return (
     <div className="alert alert-warning py-3">
-      <h5 className="fw-bold mt-1">{t('duplicated_page_alert.same_page_name_exists', { pageName: _pageName })}</h5>
+      <h5 className="fw-bold mt-1">
+        {t('duplicated_page_alert.same_page_name_exists', {
+          pageName: _pageName,
+        })}
+      </h5>
       <p>
-        {t('duplicated_page_alert.same_page_name_exists_at_path',
-          { path: _path, pageName: _pageName })}<br />
+        {t('duplicated_page_alert.same_page_name_exists_at_path', {
+          path: _path,
+          pageName: _pageName,
+        })}
+        <br />
         <span
-          // eslint-disable-next-line react/no-danger
-          dangerouslySetInnerHTML={{ __html: t('See_more_detail_on_new_schema', { title: t('GROWI.5.0_new_schema') }) }}
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML link
+          dangerouslySetInnerHTML={{
+            __html: t('See_more_detail_on_new_schema', {
+              title: t('GROWI.5.0_new_schema'),
+            }),
+          }}
         />
       </p>
       <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</p>
@@ -47,9 +59,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
   );
 };
 
-
 export const IdenticalPathPage = (): JSX.Element => {
-
   const currentPath = useCurrentPathname();
 
   const { data: pages } = useSWRxPagesByPath(currentPath);
@@ -83,7 +93,6 @@ export const IdenticalPathPage = (): JSX.Element => {
           })}
         </ul>
       </div>
-
     </>
   );
 };

+ 20 - 19
apps/app/src/client/components/InfiniteScroll.tsx

@@ -1,18 +1,17 @@
-import type { Ref, JSX } from 'react';
-import React, { useEffect, useState } from 'react';
-
+import type React from 'react';
+import type { JSX, Ref } from 'react';
+import { useEffect, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import type { SWRInfiniteResponse } from 'swr/infinite';
 
-
 type Props<T> = {
-  swrInifiniteResponse: SWRInfiniteResponse<T>
-  children: React.ReactNode,
-  loadingIndicator?: React.ReactNode
-  endingIndicator?: React.ReactNode
-  isReachingEnd?: boolean
-  offset?: number
-}
+  swrInifiniteResponse: SWRInfiniteResponse<T>;
+  children: React.ReactNode;
+  loadingIndicator?: React.ReactNode;
+  endingIndicator?: React.ReactNode;
+  isReachingEnd?: boolean;
+  offset?: number;
+};
 
 const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
   const [intersecting, setIntersecting] = useState<boolean>(false);
@@ -27,7 +26,12 @@ const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
     }
     return;
   }, [element]);
-  return [intersecting, (el) => { if (el != null) setElement(el); }];
+  return [
+    intersecting,
+    (el) => {
+      if (el != null) setElement(el);
+    },
+  ];
 };
 
 const LoadingIndicator = (): JSX.Element => {
@@ -38,11 +42,9 @@ const LoadingIndicator = (): JSX.Element => {
   );
 };
 
-const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
+const InfiniteScroll = <E,>(props: Props<E>): React.ReactElement<Props<E>> => {
   const {
-    swrInifiniteResponse: {
-      setSize, isValidating,
-    },
+    swrInifiniteResponse: { setSize, isValidating },
     children,
     loadingIndicator,
     endingIndicator,
@@ -54,7 +56,7 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
 
   useEffect(() => {
     if (intersecting && !isValidating && !isReachingEnd) {
-      setSize(size => size + 1);
+      setSize((size) => size + 1);
     }
   }, [setSize, intersecting, isValidating, isReachingEnd]);
 
@@ -65,8 +67,7 @@ const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
         <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
         {isReachingEnd
           ? endingIndicator
-          : loadingIndicator || <LoadingIndicator />
-        }
+          : loadingIndicator || <LoadingIndicator />}
       </div>
     </>
   );

+ 143 - 112
apps/app/src/client/components/InstallerForm.tsx

@@ -1,10 +1,9 @@
 import type { FormEventHandler, JSX } from 'react';
 import { memo, useCallback, useState } from 'react';
-
-import { Lang, AllLang } from '@growi/core';
+import { useRouter } from 'next/router';
+import { AllLang, Lang } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 
 import { i18n as i18nConfig } from '^/config/next-i18next.config';
 
@@ -13,15 +12,13 @@ import { useTWithOpt } from '~/client/util/t-with-opt';
 import { toastError } from '~/client/util/toastr';
 import type { IErrorV3 } from '~/interfaces/errors/v3-error';
 
-
 import styles from './InstallerForm.module.scss';
 
-
 const moduleClass = styles['installer-form'] ?? '';
 
 type Props = {
-  minPasswordLength: number,
-}
+  minPasswordLength: number;
+};
 
 const InstallerForm = memo((props: Props): JSX.Element => {
   const { t, i18n } = useTranslation();
@@ -35,86 +32,101 @@ const InstallerForm = memo((props: Props): JSX.Element => {
   const isSupportedLang = AllLang.includes(i18n.language as Lang);
 
   const [isLoading, setIsLoading] = useState(false);
-  const [currentLocale, setCurrentLocale] = useState(isSupportedLang ? i18n.language : Lang.en_US);
+  const [currentLocale, setCurrentLocale] = useState(
+    isSupportedLang ? i18n.language : Lang.en_US,
+  );
 
   const [registerErrors, setRegisterErrors] = useState<IErrorV3[]>([]);
 
-  const onClickLanguageItem = useCallback((locale) => {
-    i18n.changeLanguage(locale);
-    setCurrentLocale(locale);
-  }, [i18n]);
-
-  const submitHandler: FormEventHandler = useCallback(async(e: any) => {
-    e.preventDefault();
-
-    setIsLoading(true);
-
-    const formData = e.target.elements;
-
-    const {
-      'registerForm[username]': { value: username },
-      'registerForm[name]': { value: name },
-      'registerForm[email]': { value: email },
-      'registerForm[password]': { value: password },
-    } = formData;
-
-    const data = {
-      registerForm: {
-        username,
-        name,
-        email,
-        password,
-        'app:globalLang': currentLocale,
-      },
-    };
-
-    try {
-      setRegisterErrors([]);
-      await apiv3Post('/installer', data);
-      router.push('/');
-    }
-    catch (errs) {
-      const err = errs[0];
-      const code = err.code;
-      setIsLoading(false);
-      setRegisterErrors(errs);
-
-      if (code === 'failed_to_login_after_install') {
-        toastError(t('installer.failed_to_login_after_install'));
-        setTimeout(() => { router.push('/login') }, 700); // Wait 700 ms to show toastr
-      }
+  const onClickLanguageItem = useCallback(
+    (locale) => {
+      i18n.changeLanguage(locale);
+      setCurrentLocale(locale);
+    },
+    [i18n],
+  );
 
-      toastError(t('installer.failed_to_install'));
-    }
-  }, [currentLocale, router, t]);
+  const submitHandler: FormEventHandler = useCallback(
+    async (e: any) => {
+      e.preventDefault();
+
+      setIsLoading(true);
+
+      const formData = e.target.elements;
+
+      const {
+        'registerForm[username]': { value: username },
+        'registerForm[name]': { value: name },
+        'registerForm[email]': { value: email },
+        'registerForm[password]': { value: password },
+      } = formData;
+
+      const data = {
+        registerForm: {
+          username,
+          name,
+          email,
+          password,
+          'app:globalLang': currentLocale,
+        },
+      };
+
+      try {
+        setRegisterErrors([]);
+        await apiv3Post('/installer', data);
+        router.push('/');
+      } catch (errs) {
+        const err = errs[0];
+        const code = err.code;
+        setIsLoading(false);
+        setRegisterErrors(errs);
+
+        if (code === 'failed_to_login_after_install') {
+          toastError(t('installer.failed_to_login_after_install'));
+          setTimeout(() => {
+            router.push('/login');
+          }, 700); // Wait 700 ms to show toastr
+        }
+
+        toastError(t('installer.failed_to_install'));
+      }
+    },
+    [currentLocale, router, t],
+  );
 
   return (
-    <div data-testid="installerForm" className={`${moduleClass} nologin-dialog py-3 px-4 rounded-4 rounded-top-0 mx-auto`}>
+    <div
+      data-testid="installerForm"
+      className={`${moduleClass} nologin-dialog py-3 px-4 rounded-4 rounded-top-0 mx-auto`}
+    >
       <div className="row mt-3">
         <div className="col-md-12">
           <p className="alert alert-success">
-            <strong>{ t('installer.create_initial_account') }</strong><br />
-            <small>{ t('installer.initial_account_will_be_administrator_automatically') }</small>
+            <strong>{t('installer.create_initial_account')}</strong>
+            <br />
+            <small>
+              {t(
+                'installer.initial_account_will_be_administrator_automatically',
+              )}
+            </small>
           </p>
         </div>
       </div>
       <div className="row mt-2">
-
-        {
-          registerErrors != null && registerErrors.length > 0 && (
-            <div className="col-12">
-              <div className="alert alert-danger text-center">
-                {registerErrors.map(err => (
-                  <span>
-                    {tWithOpt(err.message, err.args)}<br />
-                  </span>
-                ))}
-              </div>
+        {registerErrors != null && registerErrors.length > 0 && (
+          <div className="col-12">
+            <div className="alert alert-danger text-center">
+              {registerErrors.map((err) => (
+                <span key={err.message}>
+                  {tWithOpt(err.message, err.args)}
+                  <br />
+                </span>
+              ))}
             </div>
-          )
-        }
+          </div>
+        )}
 
-        <form role="form" id="register-form" className="ps-1" onSubmit={submitHandler}>
+        <form id="register-form" className="ps-1" onSubmit={submitHandler}>
           <div className="dropdown mb-3">
             <div className="input-group dropdown-with-icon">
               <span className="p-2 text-white opacity-75">
@@ -129,43 +141,44 @@ const InstallerForm = memo((props: Props): JSX.Element => {
                 aria-haspopup="true"
                 aria-expanded="true"
               >
-                <span className="float-start">
-                  {t('meta.display_name')}
-                </span>
+                <span className="float-start">{t('meta.display_name')}</span>
               </button>
-              <input
-                type="hidden"
-                name="registerForm[app:globalLang]"
-              />
-              <div className="dropdown-menu" aria-labelledby="dropdownLanguage">
-                {
-                  i18nConfig.locales.map((locale) => {
-                    let fixedT;
-                    if (i18n != null) {
-                      fixedT = i18n.getFixedT(locale);
-                      i18n.loadLanguages(i18nConfig.locales);
-                    }
-
-                    return (
-                      <button
-                        key={locale}
-                        data-testid={`dropdownLanguageMenu-${locale}`}
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => { onClickLanguageItem(locale) }}
-                      >
-                        {fixedT?.('meta.display_name')}
-                      </button>
-                    );
-                  })
-                }
+              <input type="hidden" name="registerForm[app:globalLang]" />
+              <div className="dropdown-menu">
+                {i18nConfig.locales.map((locale) => {
+                  let fixedT: ((key: string) => string) | undefined;
+                  if (i18n != null) {
+                    fixedT = i18n.getFixedT(locale);
+                    i18n.loadLanguages(i18nConfig.locales);
+                  }
+
+                  return (
+                    <button
+                      key={locale}
+                      data-testid={`dropdownLanguageMenu-${locale}`}
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => {
+                        onClickLanguageItem(locale);
+                      }}
+                    >
+                      {fixedT?.('meta.display_name')}
+                    </button>
+                  );
+                })}
               </div>
             </div>
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('User ID')} htmlFor="tiUsername">
-              <span className="material-symbols-outlined" aria-hidden>person</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('User ID')}
+              htmlFor="tiUsername"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                person
+              </span>
             </label>
             <input
               id="tiUsername"
@@ -178,8 +191,14 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('Name')} htmlFor="tiName">
-              <span className="material-symbols-outlined" aria-hidden>sell</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('Name')}
+              htmlFor="tiName"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                sell
+              </span>
             </label>
             <input
               id="tiName"
@@ -192,8 +211,14 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('Email')} htmlFor="tiEmail">
-              <span className="material-symbols-outlined" aria-hidden>mail</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('Email')}
+              htmlFor="tiEmail"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                mail
+              </span>
             </label>
             <input
               id="tiEmail"
@@ -206,8 +231,14 @@ const InstallerForm = memo((props: Props): JSX.Element => {
           </div>
 
           <div className="input-group mb-3">
-            <label className="p-2 text-white opacity-75" aria-label={t('Password')} htmlFor="tiPassword">
-              <span className="material-symbols-outlined" aria-hidden>lock</span>
+            <label
+              className="p-2 text-white opacity-75"
+              aria-label={t('Password')}
+              htmlFor="tiPassword"
+            >
+              <span className="material-symbols-outlined" aria-hidden>
+                lock
+              </span>
             </label>
             <input
               minLength={minPasswordLength}
@@ -233,16 +264,16 @@ const InstallerForm = memo((props: Props): JSX.Element => {
                   <span className="material-symbols-outlined">person_add</span>
                 )}
               </span>
-              <span className="flex-grow-1">{ t('Create') }</span>
+              <span className="flex-grow-1">{t('Create')}</span>
             </button>
           </div>
 
           <div>
             <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span><span className="org">.org</span>
+              <span className="growi">GROWI</span>
+              <span className="org">.org</span>
             </a>
           </div>
-
         </form>
       </div>
     </div>

+ 50 - 38
apps/app/src/client/components/InvitedForm.tsx

@@ -1,22 +1,21 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
+import { useRouter } from 'next/router';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { useForm } from 'react-hook-form';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { useCurrentUser } from '~/states/global';
 
 type InvitedFormProps = {
-  invitedFormUsername: string,
-  invitedFormName: string,
-}
+  invitedFormUsername: string;
+  invitedFormName: string;
+};
 
 type InvitedFormValues = {
-  name: string,
-  username: string,
-  password: string,
+  name: string;
+  username: string;
+  password: string;
 };
 
 export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
@@ -39,42 +38,49 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
     },
   });
 
-  const submitHandler = useCallback(async(values: InvitedFormValues) => {
-    setIsLoading(true);
+  const submitHandler = useCallback(
+    async (values: InvitedFormValues) => {
+      setIsLoading(true);
 
-    const invitedForm = {
-      name: values.name,
-      username: values.username,
-      password: values.password,
-    };
+      const invitedForm = {
+        name: values.name,
+        username: values.username,
+        password: values.password,
+      };
 
-    try {
-      const res = await apiv3Post('/invited', { invitedForm });
-      const { redirectTo } = res.data;
-      router.push(redirectTo ?? '/');
-    }
-    catch (err) {
-      setLoginErrors(err);
-      setIsLoading(false);
-    }
-  }, [router]);
+      try {
+        const res = await apiv3Post('/invited', { invitedForm });
+        const { redirectTo } = res.data;
+        router.push(redirectTo ?? '/');
+      } catch (err) {
+        setLoginErrors(err);
+        setIsLoading(false);
+      }
+    },
+    [router],
+  );
 
   const formNotification = useCallback(() => {
-
     return (
       <>
-        { loginErrors != null && loginErrors.length > 0 ? (
+        {loginErrors != null && loginErrors.length > 0 ? (
           <p className="alert alert-danger">
-            { loginErrors.map((err) => {
-              return <span>{ t(err.message) }<br /></span>;
-            }) }
+            {loginErrors.map((err) => {
+              return (
+                <span key={err.message}>
+                  {t(err.message)}
+                  <br />
+                </span>
+              );
+            })}
           </p>
         ) : (
           <p className="alert alert-success">
-            <strong>{ t('invited.discription_heading') }</strong><br></br>
-            <small>{ t('invited.discription') }</small>
+            <strong>{t('invited.discription_heading')}</strong>
+            <br></br>
+            <small>{t('invited.discription')}</small>
           </p>
-        ) }
+        )}
       </>
     );
   }, [loginErrors, t]);
@@ -85,8 +91,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
   return (
     <div className="nologin-dialog px-3 pb-3 mx-auto" id="nologin-dialog">
-      { formNotification() }
-      <form role="form" onSubmit={handleSubmit(submitHandler)} id="invited-form">
+      {formNotification()}
+      <form onSubmit={handleSubmit(submitHandler)} id="invited-form">
         {/* Email Form */}
         <div className="input-group">
           <span className="input-group-text">
@@ -144,7 +150,12 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
         </div>
         {/* Create Button */}
         <div className="input-group justify-content-center d-flex mt-4">
-          <button type="submit" className="btn btn-fill" id="register" disabled={isLoading || isSubmitting}>
+          <button
+            type="submit"
+            className="btn btn-fill"
+            id="register"
+            disabled={isLoading || isSubmitting}
+          >
             <span className="btn-label">
               {isLoading ? (
                 <LoadingSpinner />
@@ -158,7 +169,8 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
       </form>
       <div className="input-group mt-4 d-flex justify-content-center">
         <a href="https://growi.org" className="link-growi-org">
-          <span className="growi">GROWI</span><span className="org">.ORG</span>
+          <span className="growi">GROWI</span>
+          <span className="org">.ORG</span>
         </a>
       </div>
     </div>

+ 1 - 0
apps/app/src/client/components/LoginForm/LoginForm.tsx

@@ -282,6 +282,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
       setIsSuccessToRagistration(true);
       resetRegisterErrors();
+      setIsLoading(false);
 
       const { redirectTo } = res.data;
 

+ 17 - 24
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -270,7 +270,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
 
   const { editorMode } = useEditorMode();
-  const pageId = useCurrentPageId();
+  const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -291,10 +291,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const [isStickyActive, setStickyActive] = useState(false);
 
-
   const path = currentPage?.path ?? currentPathname;
-  // const grant = currentPage?.grant ?? grantData?.grant;
-  // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
 
   const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
 
@@ -342,7 +339,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
     if (!isSharedPage) {
       await updateContentWidth(pageId, value);
-      fetchCurrentPage();
+      fetchCurrentPage({ force: true });
     }
   }, [isSharedPage, fetchCurrentPage]);
 
@@ -405,31 +402,27 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             id="grw-contextual-sub-nav"
           >
 
-            {pageId != null && (
-              <PageControls
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path ?? currentPathname} // If the page is empty, "path" is undefined
-                expandContentWidth={shouldExpandContent}
-                disableSeenUserInfoPopover={isSharedUser}
-                hideSubControls={hideSubControls}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-                onClickSwitchContentWidth={switchContentWidthHandler}
-              />
-            )}
+            <PageControls
+              pageId={pageId}
+              revisionId={revisionId}
+              shareLinkId={shareLinkId}
+              path={path ?? currentPathname} // If the page is empty, "path" is undefined
+              expandContentWidth={shouldExpandContent}
+              disableSeenUserInfoPopover={isSharedUser}
+              hideSubControls={hideSubControls}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={additionalMenuItemsRenderer}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
+              onClickSwitchContentWidth={switchContentWidthHandler}
+            />
 
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager
                 editorMode={editorMode}
                 isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
                 path={path}
-              // grant={grant}
-              // grantUserGroupId={grantUserGroupId}
               />
             )}
 

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

@@ -2,20 +2,15 @@ import React, {
   type ReactNode, useCallback, useMemo, type JSX,
 } from 'react';
 
-import { Origin } from '@growi/core';
-import { getParentPath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 
 import { useCreatePage } from '~/client/services/create-page';
+import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
-import { usePageNotFound } from '~/states/page';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode, EditorMode } from '~/states/ui/editor';
 
-import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
-
-
 import styles from './PageEditorModeManager.module.scss';
 
 
@@ -66,34 +61,21 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const { t } = useTranslation('commons');
 
-  const isNotFound = usePageNotFound();
   const { setEditorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const currentPageYjsData = useCurrentPageYjsData();
+  const startEditing = useStartEditing();
 
-  const { isCreating, create } = useCreatePage();
+  const { isCreating } = useCreatePage();
 
   const editButtonClickedHandler = useCallback(async () => {
-    if (!isNotFound) {
-      setEditorMode(EditorMode.Editor);
-      return;
-    }
-
-    // Create a new page if it does not exist and transit to the editor mode
     try {
-      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
-      await create(
-        {
-          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
-        },
-      );
-
-      setEditorMode(EditorMode.Editor);
+      await startEditing(path);
     }
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [create, isNotFound, setEditorMode, path, t]);
+  }, [startEditing, path, t]);
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 

+ 15 - 13
apps/app/src/client/components/NotAvailable.tsx

@@ -1,21 +1,23 @@
 import React, { type JSX } from 'react';
-
 import { Disable } from 'react-disable';
 import type { UncontrolledTooltipProps } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 type NotAvailableProps = {
-  children: JSX.Element
-  isDisabled: boolean
-  title: string
-  classNamePrefix?: string
-  placement?: UncontrolledTooltipProps['placement']
-}
+  children: JSX.Element;
+  isDisabled: boolean;
+  title: string;
+  classNamePrefix?: string;
+  placement?: UncontrolledTooltipProps['placement'];
+};
 
 export const NotAvailable = ({
-  children, isDisabled, title, classNamePrefix = 'grw-not-available', placement = 'top',
+  children,
+  isDisabled,
+  title,
+  classNamePrefix = 'grw-not-available',
+  placement = 'top',
 }: NotAvailableProps): JSX.Element => {
-
   if (!isDisabled) {
     return children;
   }
@@ -25,11 +27,11 @@ export const NotAvailable = ({
   return (
     <>
       <div id={id}>
-        <Disable disabled={isDisabled}>
-          {children}
-        </Disable>
+        <Disable disabled={isDisabled}>{children}</Disable>
       </div>
-      <UncontrolledTooltip placement={placement} target={id}>{title}</UncontrolledTooltip>
+      <UncontrolledTooltip placement={placement} target={id}>
+        {title}
+      </UncontrolledTooltip>
     </>
   );
 };

+ 19 - 18
apps/app/src/client/components/NotAvailableForGuest.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { useIsGuestUser } from '~/states/context';
@@ -7,24 +6,26 @@ import { useIsGuestUser } from '~/states/context';
 import { NotAvailable } from './NotAvailable';
 
 type NotAvailableForGuestProps = {
-  children: JSX.Element
-}
+  children: JSX.Element;
+};
 
-export const NotAvailableForGuest = React.memo(({ children }: NotAvailableForGuestProps): JSX.Element => {
-  const { t } = useTranslation();
-  const isGuestUser = useIsGuestUser();
+export const NotAvailableForGuest = React.memo(
+  ({ children }: NotAvailableForGuestProps): JSX.Element => {
+    const { t } = useTranslation();
+    const isGuestUser = useIsGuestUser();
 
-  const isDisabled = !!isGuestUser;
-  const title = t('Not available for guest');
+    const isDisabled = !!isGuestUser;
+    const title = t('Not available for guest');
 
-  return (
-    <NotAvailable
-      isDisabled={isDisabled}
-      title={title}
-      classNamePrefix="grw-not-available-for-guest"
-    >
-      {children}
-    </NotAvailable>
-  );
-});
+    return (
+      <NotAvailable
+        isDisabled={isDisabled}
+        title={title}
+        classNamePrefix="grw-not-available-for-guest"
+      >
+        {children}
+      </NotAvailable>
+    );
+  },
+);
 NotAvailableForGuest.displayName = 'NotAvailableForGuest';

+ 17 - 17
apps/app/src/client/components/NotAvailableForNow.tsx

@@ -1,27 +1,27 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { NotAvailable } from './NotAvailable';
 
-
 type NotAvailableForNowProps = {
-  children: JSX.Element
-}
+  children: JSX.Element;
+};
 
-export const NotAvailableForNow = React.memo(({ children }: NotAvailableForNowProps): JSX.Element => {
-  const { t } = useTranslation();
+export const NotAvailableForNow = React.memo(
+  ({ children }: NotAvailableForNowProps): JSX.Element => {
+    const { t } = useTranslation();
 
-  const title = t('Not available in this version');
+    const title = t('Not available in this version');
 
-  return (
-    <NotAvailable
-      isDisabled
-      title={title}
-      classNamePrefix="grw-not-available-for-now"
-    >
-      {children}
-    </NotAvailable>
-  );
-});
+    return (
+      <NotAvailable
+        isDisabled
+        title={title}
+        classNamePrefix="grw-not-available-for-now"
+      >
+        {children}
+      </NotAvailable>
+    );
+  },
+);
 NotAvailableForNow.displayName = 'NotAvailableForNow';

+ 19 - 16
apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx

@@ -1,11 +1,8 @@
 import type { ReactNode } from 'react';
-
 import { render, screen } from '@testing-library/react';
 import { Provider } from 'jotai';
 import { useHydrateAtoms } from 'jotai/utils';
-import {
-  describe, it, expect, vi,
-} from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
 
 import { isRomUserAllowedToCommentAtom } from '~/states/server-configurations';
 
@@ -18,21 +15,28 @@ vi.mock('~/states/context', () => ({
 }));
 
 vi.mock('react-disable', () => ({
-  Disable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => (
-    <div aria-hidden={disabled ? 'true' : undefined}>
-      {children}
-    </div>
-  ),
+  Disable: ({
+    children,
+    disabled,
+  }: {
+    children: ReactNode;
+    disabled: boolean;
+  }) => <div aria-hidden={disabled ? 'true' : undefined}>{children}</div>,
 }));
 
-const HydrateAtoms = ({ children, initialValues }: { children: ReactNode; initialValues: Array<[typeof isRomUserAllowedToCommentAtom, boolean]> }) => {
+const HydrateAtoms = ({
+  children,
+  initialValues,
+}: {
+  children: ReactNode;
+  initialValues: Array<[typeof isRomUserAllowedToCommentAtom, boolean]>;
+}) => {
   useHydrateAtoms(initialValues);
   return <>{children}</>;
 };
 
 describe('NotAvailableForReadOnlyUser.tsx', () => {
-
-  it('renders NotAvailable component as enable when user is read-only and comments by rom users is allowed', async() => {
+  it('renders NotAvailable component as enable when user is read-only and comments by rom users is allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(true);
 
     render(
@@ -53,7 +57,7 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     expect(wrapperElement).not.toHaveAttribute('aria-hidden', 'true');
   });
 
-  it('renders NotAvailable component as disable when user is read-only and comments by rom users is not allowed', async() => {
+  it('renders NotAvailable component as disable when user is read-only and comments by rom users is not allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(true);
 
     render(
@@ -74,7 +78,7 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     expect(wrapperElement).toHaveAttribute('aria-hidden', 'true');
   });
 
-  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is allowed', async() => {
+  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(false);
 
     render(
@@ -95,7 +99,7 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     expect(wrapperElement).not.toHaveAttribute('aria-hidden', 'true');
   });
 
-  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is not allowed', async() => {
+  it('renders NotAvailable component as enable when user is not read-only and comments by rom users is not allowed', async () => {
     useIsReadOnlyUser.mockReturnValue(false);
 
     render(
@@ -115,5 +119,4 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
     // then
     expect(wrapperElement).not.toHaveAttribute('aria-hidden', 'true');
   });
-
 });

+ 20 - 7
apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx

@@ -1,5 +1,5 @@
-import React, { type JSX } from 'react';
-
+import type React from 'react';
+import type { JSX } from 'react';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 
@@ -9,13 +9,19 @@ import { isRomUserAllowedToCommentAtom } from '~/states/server-configurations';
 import { NotAvailable } from './NotAvailable';
 
 // eslint-disable-next-line react/prop-types
-export const NotAvailableForReadOnlyUser: React.FC<{ children: JSX.Element }> = ({ children }) => {
+export const NotAvailableForReadOnlyUser: React.FC<{
+  children: JSX.Element;
+}> = ({ children }) => {
   const { t } = useTranslation();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isDisabled = !!isReadOnlyUser;
   const title = t('Not available for read only user');
   return (
-    <NotAvailable isDisabled={isDisabled} title={title} classNamePrefix="grw-not-available-for-read-only-user">
+    <NotAvailable
+      isDisabled={isDisabled}
+      title={title}
+      classNamePrefix="grw-not-available-for-read-only-user"
+    >
       {children}
     </NotAvailable>
   );
@@ -23,16 +29,23 @@ export const NotAvailableForReadOnlyUser: React.FC<{ children: JSX.Element }> =
 NotAvailableForReadOnlyUser.displayName = 'NotAvailableForReadOnlyUser';
 
 // eslint-disable-next-line react/prop-types
-export const NotAvailableIfReadOnlyUserNotAllowedToComment: React.FC<{ children: JSX.Element }> = ({ children }) => {
+export const NotAvailableIfReadOnlyUserNotAllowedToComment: React.FC<{
+  children: JSX.Element;
+}> = ({ children }) => {
   const { t } = useTranslation();
   const isReadOnlyUser = useIsReadOnlyUser();
   const isRomUserAllowedToComment = useAtomValue(isRomUserAllowedToCommentAtom);
   const isDisabled = !!isReadOnlyUser && !isRomUserAllowedToComment;
   const title = t('page_comment.comment_management_is_not_allowed');
   return (
-    <NotAvailable isDisabled={isDisabled} title={title} classNamePrefix="grw-not-available-for-read-only-user">
+    <NotAvailable
+      isDisabled={isDisabled}
+      title={title}
+      classNamePrefix="grw-not-available-for-read-only-user"
+    >
       {children}
     </NotAvailable>
   );
 };
-NotAvailableIfReadOnlyUserNotAllowedToComment.displayName = 'NotAvailableIfReadOnlyUserNotAllowedToComment';
+NotAvailableIfReadOnlyUserNotAllowedToComment.displayName =
+  'NotAvailableIfReadOnlyUserNotAllowedToComment';

+ 4 - 3
apps/app/src/client/components/NotCreatablePage.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 export const NotCreatablePage: FC = () => {
@@ -10,8 +9,10 @@ export const NotCreatablePage: FC = () => {
     <div className="row not-found-message-row">
       <div className="col-md-12">
         <h2 className="text-muted">
-          <span className="material-symbols-outlined" aria-hidden="true">block</span>
-          { t('not_creatable_page.message') }
+          <span className="material-symbols-outlined" aria-hidden="true">
+            block
+          </span>
+          {t('not_creatable_page.message')}
         </h2>
       </div>
     </div>

+ 22 - 9
apps/app/src/client/components/NotFoundPage.tsx

@@ -1,5 +1,4 @@
-import React, { useMemo, type JSX } from 'react';
-
+import React, { type JSX, useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import CustomNavAndContents from './CustomNavigation/CustomNavAndContents';
@@ -7,32 +6,46 @@ import { DescendantsPageList } from './DescendantsPageList';
 import { PageTimeline } from './PageTimeline';
 
 type NotFoundPageProps = {
-  path: string,
-}
+  path: string;
+};
+
+const PageListIcon = () => (
+  <span className="material-symbols-outlined">subject</span>
+);
+const TimelineIcon = () => (
+  <span className="material-symbols-outlined">timeline</span>
+);
 
 const NotFoundPage = (props: NotFoundPageProps): JSX.Element => {
   const { t } = useTranslation();
 
   const { path } = props;
 
+  const PageListContent = useMemo(() => {
+    return () => <DescendantsPageList path={path} />;
+  }, [path]);
+
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => <DescendantsPageList path={path} />,
+        Icon: PageListIcon,
+        Content: PageListContent,
         i18n: t('page_list'),
       },
       timeLine: {
-        Icon: () => <span className="material-symbols-outlined">timeline</span>,
+        Icon: TimelineIcon,
         Content: PageTimeline,
         i18n: t('Timeline View'),
       },
     };
-  }, [path, t]);
+  }, [PageListContent, t]);
 
   return (
     <div className="d-edit-none">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['py-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['py-4']}
+      />
     </div>
   );
 };

+ 0 - 4
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -18,8 +18,6 @@ import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import ExpandOrContractButton from '../ExpandOrContractButton';
 
-import { useAutoOpenModalByQueryParam } from './hooks';
-
 import styles from './PageAccessoriesModal.module.scss';
 
 
@@ -45,8 +43,6 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
   const status = usePageAccessoriesModalStatus();
   const { close, selectContents } = usePageAccessoriesModalActions();
 
-  useAutoOpenModalByQueryParam();
-
   // Memoize heavy navTabMapping calculation
   const navTabMapping = useMemo(() => {
     return {

+ 4 - 0
apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx

@@ -3,11 +3,15 @@ import type { JSX } from 'react';
 import { useLazyLoader } from '~/components/utils/use-lazy-loader';
 import { usePageAccessoriesModalStatus } from '~/states/ui/modal/page-accessories';
 
+import { useAutoOpenModalByQueryParam } from './hooks';
+
 type PageAccessoriesModalProps = Record<string, unknown>;
 
 export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
   const status = usePageAccessoriesModalStatus();
 
+  useAutoOpenModalByQueryParam();
+
   const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
     'page-accessories-modal',
     () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),

+ 243 - 203
apps/app/src/client/components/PageComment.tsx

@@ -1,12 +1,7 @@
 import type { FC, JSX } from 'react';
-import React, {
-  useState, useMemo, memo, useCallback,
-} from 'react';
-
+import React, { memo, useCallback, useMemo, useState } from 'react';
 import type { IRevision, Ref } from '@growi/core';
-import {
-  isPopulated, getIdStringForRef,
-} from '@growi/core';
+import { getIdStringForRef, isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
@@ -16,9 +11,11 @@ import type { RendererOptions } from '~/interfaces/renderer-options';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
-import type { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
+import type {
+  ICommentHasId,
+  ICommentHasIdList,
+} from '../../interfaces/comment';
 import { useSWRxPageComment } from '../../stores/comment';
-
 import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from './NotAvailableForReadOnlyUser';
 import { Comment } from './PageComment/Comment';
@@ -28,206 +25,249 @@ import { ReplyComments } from './PageComment/ReplyComments';
 
 import styles from './PageComment.module.scss';
 
-
 type PageCommentProps = {
-  rendererOptions?: RendererOptions,
-  pageId: string,
-  pagePath: string,
-  revision: Ref<IRevision>,
-  currentUser: any,
-  isReadOnly: boolean,
-}
-
-export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps): JSX.Element => {
-
-  const {
-    rendererOptions: rendererOptionsByProps,
-    pageId, pagePath, revision, currentUser, isReadOnly,
-  } = props;
-
-  const { data: comments, mutate } = useSWRxPageComment(pageId);
-  const { data: rendererOptionsForCurrentPage } = useCommentForCurrentPageOptions();
-
-  const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
-  const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
-  const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
-  const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
-
-  const { t } = useTranslation('');
-
-  const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
-  const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
-    () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
-  );
-  const allReplies = {};
-
-  if (commentsFromOldest != null) {
-    commentsFromOldest.forEach((comment) => {
-      if (comment.replyTo != null) {
-        allReplies[comment.replyTo] = allReplies[comment.replyTo] == null ? [comment] : [...allReplies[comment.replyTo], comment];
+  rendererOptions?: RendererOptions;
+  pageId: string;
+  pagePath: string;
+  revision: Ref<IRevision>;
+  currentUser: any;
+  isReadOnly: boolean;
+};
+
+export const PageComment: FC<PageCommentProps> = memo(
+  (props: PageCommentProps): JSX.Element => {
+    const {
+      rendererOptions: rendererOptionsByProps,
+      pageId,
+      pagePath,
+      revision,
+      currentUser,
+      isReadOnly,
+    } = props;
+
+    const { data: comments, mutate } = useSWRxPageComment(pageId);
+    const { data: rendererOptionsForCurrentPage } =
+      useCommentForCurrentPageOptions();
+
+    const [commentToBeDeleted, setCommentToBeDeleted] =
+      useState<ICommentHasId | null>(null);
+    const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] =
+      useState<boolean>(false);
+    const [showEditorIds, setShowEditorIds] = useState<Set<string>>(new Set());
+    const [errorMessageOnDelete, setErrorMessageOnDelete] =
+      useState<string>('');
+    const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
+
+    const { t } = useTranslation('');
+
+    const commentsFromOldest = useMemo(
+      () => (comments != null ? [...comments].reverse() : null),
+      [comments],
+    );
+    const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
+      () => commentsFromOldest?.filter((comment) => comment.replyTo == null),
+      [commentsFromOldest],
+    );
+    const allReplies = {};
+
+    if (commentsFromOldest != null) {
+      commentsFromOldest.forEach((comment) => {
+        if (comment.replyTo != null) {
+          allReplies[comment.replyTo] =
+            allReplies[comment.replyTo] == null
+              ? [comment]
+              : [...allReplies[comment.replyTo], comment];
+        }
+      });
+    }
+
+    const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
+      setCommentToBeDeleted(comment);
+      setIsDeleteConfirmModalShown(true);
+    }, []);
+
+    const onCancelDeleteComment = useCallback(() => {
+      setCommentToBeDeleted(null);
+      setIsDeleteConfirmModalShown(false);
+    }, []);
+
+    const onDeleteCommentAfterOperation = useCallback(() => {
+      onCancelDeleteComment();
+      mutate();
+      mutatePageInfo();
+    }, [mutate, onCancelDeleteComment, mutatePageInfo]);
+
+    const onDeleteComment = useCallback(async () => {
+      if (commentToBeDeleted == null) return;
+      try {
+        await apiPost('/comments.remove', {
+          comment_id: commentToBeDeleted._id,
+        });
+        onDeleteCommentAfterOperation();
+      } catch (error: unknown) {
+        const message =
+          error instanceof Error ? error.message : (error as any).toString();
+
+        setErrorMessageOnDelete(message);
+        toastError(message);
       }
-    });
-  }
-
-  const onClickDeleteButton = useCallback((comment: ICommentHasId) => {
-    setCommentToBeDeleted(comment);
-    setIsDeleteConfirmModalShown(true);
-  }, []);
-
-  const onCancelDeleteComment = useCallback(() => {
-    setCommentToBeDeleted(null);
-    setIsDeleteConfirmModalShown(false);
-  }, []);
-
-  const onDeleteCommentAfterOperation = useCallback(() => {
-    onCancelDeleteComment();
-    mutate();
-    mutatePageInfo();
-  }, [mutate, onCancelDeleteComment, mutatePageInfo]);
-
-  const onDeleteComment = useCallback(async() => {
-    if (commentToBeDeleted == null) return;
-    try {
-      await apiPost('/comments.remove', { comment_id: commentToBeDeleted._id });
-      onDeleteCommentAfterOperation();
+    }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
+
+    const removeShowEditorId = useCallback((commentId: string) => {
+      setShowEditorIds((previousState) => {
+        return new Set([...previousState].filter((id) => id !== commentId));
+      });
+    }, []);
+
+    const onReplyButtonClickHandler = useCallback((commentId: string) => {
+      setShowEditorIds(
+        (previousState) => new Set([...previousState, commentId]),
+      );
+    }, []);
+
+    const onCommentButtonClickHandler = useCallback(
+      (commentId: string) => {
+        removeShowEditorId(commentId);
+        mutate();
+        mutatePageInfo();
+      },
+      [removeShowEditorId, mutate, mutatePageInfo],
+    );
+
+    if (comments?.length === 0) {
+      return <></>;
     }
-    catch (error: unknown) {
-      const message = error instanceof Error
-        ? error.message
-        : (error as any).toString();
 
-      setErrorMessageOnDelete(message);
-      toastError(message);
+    const rendererOptions =
+      rendererOptionsByProps ?? rendererOptionsForCurrentPage;
+
+    if (
+      commentsFromOldest == null ||
+      commentsExceptReply == null ||
+      rendererOptions == null
+    ) {
+      return <></>;
     }
-  }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
-
-  const removeShowEditorId = useCallback((commentId: string) => {
-    setShowEditorIds((previousState) => {
-      return new Set([...previousState].filter(id => id !== commentId));
-    });
-  }, []);
-
-  const onReplyButtonClickHandler = useCallback((commentId: string) => {
-    setShowEditorIds(previousState => new Set([...previousState, commentId]));
-  }, []);
-
-  const onCommentButtonClickHandler = useCallback((commentId: string) => {
-    removeShowEditorId(commentId);
-    mutate();
-    mutatePageInfo();
-  }, [removeShowEditorId, mutate, mutatePageInfo]);
-
-  if (comments?.length === 0) {
-    return <></>;
-  }
-
-  const rendererOptions = rendererOptionsByProps ?? rendererOptionsForCurrentPage;
-
-  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null) {
-    return <></>;
-  }
-
-  const revisionId = getIdStringForRef(revision);
-  const revisionCreatedAt = (isPopulated(revision)) ? revision.createdAt : undefined;
-
-  const commentElement = (comment: ICommentHasId) => (
-    <Comment
-      rendererOptions={rendererOptions}
-      comment={comment}
-      revisionId={revisionId}
-      revisionCreatedAt={revisionCreatedAt as Date}
-      currentUser={currentUser}
-      isReadOnly={isReadOnly}
-      pageId={pageId}
-      pagePath={pagePath}
-      deleteBtnClicked={onClickDeleteButton}
-      onComment={mutate}
-    />
-  );
-
-  const replyCommentsElement = (replyComments: ICommentHasIdList) => (
-    <ReplyComments
-      rendererOptions={rendererOptions}
-      isReadOnly={isReadOnly}
-      revisionId={revisionId}
-      revisionCreatedAt={revisionCreatedAt as Date}
-      currentUser={currentUser}
-      replyList={replyComments}
-      pageId={pageId}
-      pagePath={pagePath}
-      deleteBtnClicked={onClickDeleteButton}
-      onComment={mutate}
-    />
-  );
-
-  return (
-    <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
-      <div className="page-comments">
-        <div className="page-comments-list mb-3" id="page-comments-list">
-          {commentsExceptReply.map((comment) => {
-
-            const defaultCommentThreadClasses = 'page-comment-thread mb-2';
-            const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
-
-            let commentThreadClasses = '';
-            commentThreadClasses = hasReply ? `${defaultCommentThreadClasses} page-comment-thread-no-replies` : defaultCommentThreadClasses;
-
-            return (
-              <div key={comment._id} className={commentThreadClasses}>
-                {/* Comment */}
-                {commentElement(comment)}
-                {/* Reply comments */}
-                {hasReply && replyCommentsElement(allReplies[comment._id])}
-
-                {(!isReadOnly && !showEditorIds.has(comment._id)) && (
-                  <div className="d-flex flex-row-reverse">
-                    <NotAvailableForGuest>
-                      <NotAvailableIfReadOnlyUserNotAllowedToComment>
-                        <button
-                          type="button"
-                          data-testid="comment-reply-button"
-                          className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
-                          onClick={() => onReplyButtonClickHandler(comment._id)}
-                        >
-                          <UserPicture user={currentUser} noLink noTooltip className="me-2" />
-                          <span className="material-symbols-outlined me-1 fs-5 pb-1">reply</span><small>{t('page_comment.reply')}...</small>
-                        </button>
-                      </NotAvailableIfReadOnlyUserNotAllowedToComment>
-                    </NotAvailableForGuest>
-                  </div>
-                )}
-
-                {/* Editor to reply */}
-                {(!isReadOnly && showEditorIds.has(comment._id)) && (
-                  <CommentEditor
-                    pageId={pageId}
-                    replyTo={comment._id}
-                    onCanceled={() => {
-                      removeShowEditorId(comment._id);
-                    }}
-                    onCommented={() => onCommentButtonClickHandler(comment._id)}
-                    revisionId={revisionId}
-                  />
-                )}
-              </div>
-            );
-
-          })}
+
+    const revisionId = getIdStringForRef(revision);
+    const revisionCreatedAt = isPopulated(revision)
+      ? revision.createdAt
+      : undefined;
+
+    const commentElement = (comment: ICommentHasId) => (
+      <Comment
+        rendererOptions={rendererOptions}
+        comment={comment}
+        revisionId={revisionId}
+        revisionCreatedAt={revisionCreatedAt as Date}
+        currentUser={currentUser}
+        isReadOnly={isReadOnly}
+        pageId={pageId}
+        pagePath={pagePath}
+        deleteBtnClicked={onClickDeleteButton}
+        onComment={mutate}
+      />
+    );
+
+    const replyCommentsElement = (replyComments: ICommentHasIdList) => (
+      <ReplyComments
+        rendererOptions={rendererOptions}
+        isReadOnly={isReadOnly}
+        revisionId={revisionId}
+        revisionCreatedAt={revisionCreatedAt as Date}
+        currentUser={currentUser}
+        replyList={replyComments}
+        pageId={pageId}
+        pagePath={pagePath}
+        deleteBtnClicked={onClickDeleteButton}
+        onComment={mutate}
+      />
+    );
+
+    return (
+      <div
+        className={`${styles['page-comment-styles']} page-comments-row comment-list`}
+      >
+        <div className="page-comments">
+          <div className="page-comments-list mb-3" id="page-comments-list">
+            {commentsExceptReply.map((comment) => {
+              const defaultCommentThreadClasses = 'page-comment-thread mb-2';
+              const hasReply: boolean = Object.keys(allReplies).includes(
+                comment._id,
+              );
+
+              let commentThreadClasses = '';
+              commentThreadClasses = hasReply
+                ? `${defaultCommentThreadClasses} page-comment-thread-no-replies`
+                : defaultCommentThreadClasses;
+
+              return (
+                <div key={comment._id} className={commentThreadClasses}>
+                  {/* Comment */}
+                  {commentElement(comment)}
+                  {/* Reply comments */}
+                  {hasReply && replyCommentsElement(allReplies[comment._id])}
+
+                  {!isReadOnly && !showEditorIds.has(comment._id) && (
+                    <div className="d-flex flex-row-reverse">
+                      <NotAvailableForGuest>
+                        <NotAvailableIfReadOnlyUserNotAllowedToComment>
+                          <button
+                            type="button"
+                            data-testid="comment-reply-button"
+                            className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
+                            onClick={() =>
+                              onReplyButtonClickHandler(comment._id)
+                            }
+                          >
+                            <UserPicture
+                              user={currentUser}
+                              noLink
+                              noTooltip
+                              className="me-2"
+                            />
+                            <span className="material-symbols-outlined me-1 fs-5 pb-1">
+                              reply
+                            </span>
+                            <small>{t('page_comment.reply')}...</small>
+                          </button>
+                        </NotAvailableIfReadOnlyUserNotAllowedToComment>
+                      </NotAvailableForGuest>
+                    </div>
+                  )}
+
+                  {/* Editor to reply */}
+                  {!isReadOnly && showEditorIds.has(comment._id) && (
+                    <CommentEditor
+                      pageId={pageId}
+                      replyTo={comment._id}
+                      onCanceled={() => {
+                        removeShowEditorId(comment._id);
+                      }}
+                      onCommented={() =>
+                        onCommentButtonClickHandler(comment._id)
+                      }
+                      revisionId={revisionId}
+                    />
+                  )}
+                </div>
+              );
+            })}
+          </div>
         </div>
-      </div>
 
-      {!isReadOnly && (
-        <DeleteCommentModalLazyLoaded
-          isShown={isDeleteConfirmModalShown}
-          comment={commentToBeDeleted}
-          errorMessage={errorMessageOnDelete}
-          cancelToDelete={onCancelDeleteComment}
-          confirmToDelete={onDeleteComment}
-        />
-      )}
-    </div>
-  );
-});
+        {!isReadOnly && (
+          <DeleteCommentModalLazyLoaded
+            isShown={isDeleteConfirmModalShown}
+            comment={commentToBeDeleted}
+            errorMessage={errorMessageOnDelete}
+            cancelToDelete={onCancelDeleteComment}
+            confirmToDelete={onDeleteComment}
+          />
+        )}
+      </div>
+    );
+  },
+);
 
 PageComment.displayName = 'PageComment';

+ 37 - 51
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -3,14 +3,14 @@ import React, {
 } from 'react';
 
 import type {
-  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
 } from '@growi/core';
 import {
+  isIPageInfoForEmpty,
+
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
-import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
@@ -21,7 +21,6 @@ import { toastError } from '~/client/util/toastr';
 import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/states/context';
 import { useCurrentPagePath } from '~/states/page';
-import { isUsersHomepageDeletionEnabledAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import {
   EditorMode, useEditorMode,
@@ -104,7 +103,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 type CommonProps = {
-  pageId: string,
+  pageId?: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
   path?: string | null,
@@ -121,7 +120,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfoForOperation,
+  pageInfo: IPageInfo | undefined,
   onClickEditTagsButton: () => void,
 }
 
@@ -138,11 +137,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { editorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const isSearchPage = useIsSearchPage();
-  const isUsersHomepageDeletionEnabled = useAtomValue(isUsersHomepageDeletionEnabledAtom);
   const currentPagePath = useCurrentPagePath();
 
-  const isUsersHomepage = currentPagePath == null ? false : pagePathUtils.isUsersHomepage(currentPagePath);
-
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
@@ -167,10 +163,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
   const subscribeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot subscribe to pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -179,10 +177,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const likeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot like pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -191,7 +191,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDuplicateMenuItem == null || path == null) {
+    if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
       return;
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -200,7 +201,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickRenameMenuItem == null || path == null) {
+    if (onClickRenameMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
       return;
     }
 
@@ -217,7 +219,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDeleteMenuItem == null || path == null) {
+    if (onClickDeleteMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
       return;
     }
 
@@ -234,22 +237,22 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(() => {
-    if (onClickSwitchContentWidth == null) {
+    if (isGuestUser || isReadOnlyUser) {
+      logger.warn('Guest or read-only users cannot switch content width');
       return;
     }
 
-    const newValue = !expandContentWidth;
-    if ((isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
-      logger.warn('Could not switch content width', {
-        isGuestUser,
-        isReadOnlyUser,
-      });
+    if (onClickSwitchContentWidth == null || pageId == null) {
+      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
+      logger.warn('PageInfo is not for entity');
       return;
     }
+
     try {
+      const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
     }
     catch (err) {
@@ -266,12 +269,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       return false;
     }
 
-    if (isUsersHomepage && !isUsersHomepageDeletionEnabled) {
-      return false;
-    }
-
     return true;
-  }, [currentPagePath, isGuestUser, isUsersHomepage, isUsersHomepageDeletionEnabled]);
+  }, [currentPagePath, isGuestUser]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -287,21 +286,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     return wideviewMenuItemRenderer;
   }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
-  const {
-    sumOfLikers, sumOfSeenUsers, isLiked,
-  } = pageInfo;
-
   const forceHideMenuItemsWithAdditions = [
     ...(forceHideMenuItems ?? []),
     MenuItemType.BOOKMARK,
     MenuItemType.REVERT,
   ];
 
-  const _isIPageInfoForOperation = isIPageInfoForOperation(pageInfo);
   const isViewMode = editorMode === EditorMode.View;
 
   return (
@@ -313,7 +303,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         </>
       )}
 
-      {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
+      {revisionId != null && !isViewMode && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
         />
@@ -321,38 +311,38 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
       {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <SubscribeButton
               status={pageInfo.subscriptionStatus}
               onClick={subscribeClickhandler}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <LikeButtons
               onLikeClicked={likeClickhandler}
-              sumOfLikers={sumOfLikers}
-              isLiked={isLiked}
+              sumOfLikers={pageInfo.sumOfLikers}
+              isLiked={pageInfo.isLiked}
               likers={likers}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
             <BookmarkButtons
               pageId={pageId}
               isBookmarked={pageInfo.isBookmarked}
               bookmarkCount={pageInfo.bookmarkCount}
             />
           )}
-          {revisionId != null && !isSearchPage && (
+          {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
-              sumOfSeenUsers={sumOfSeenUsers}
+              sumOfSeenUsers={pageInfo.sumOfSeenUsers}
               disabled={disableSeenUserInfoPopover}
             />
           )}
         </div>
       )}
 
-      {showPageControlDropdown && _isIPageInfoForOperation && (
+      {showPageControlDropdown && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
@@ -383,7 +373,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const { open: openTagEditModal } = useTagEditModalActions();
 
   const onClickEditTagsButton = useCallback(() => {
-    if (tagsInfoData == null || revisionId == null) {
+    if (tagsInfoData == null || pageId == null || revisionId == null) {
       return;
     }
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
@@ -393,10 +383,6 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
     return <></>;
   }
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
   return (
     <PageControlsSubstance
       pageInfo={pageInfo}

+ 139 - 76
apps/app/src/client/components/PageCreateModal.tsx

@@ -1,18 +1,20 @@
-import React, {
-  useEffect, useState, useMemo, useCallback,
-} from 'react';
-
-import path from 'path';
-
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { format } from 'date-fns/format';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
+import path from 'path';
 import {
-  Modal, ModalHeader, ModalBody, UncontrolledButtonDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  Modal,
+  ModalBody,
+  ModalHeader,
+  UncontrolledButtonDropdown,
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
@@ -21,15 +23,16 @@ import { useCreatePage } from '~/client/services/create-page/use-create-page';
 import { useToastrOnError } from '~/client/services/use-toastr-on-error';
 import { useCurrentUser } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
-import { usePageCreateModalStatus, usePageCreateModalActions } from '~/states/ui/modal/page-create';
+import {
+  usePageCreateModalActions,
+  usePageCreateModalStatus,
+} from '~/states/ui/modal/page-create';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
 import styles from './PageCreateModal.module.scss';
 
-const {
-  isCreatablePage, isUsersHomepage,
-} = pagePathUtils;
+const { isCreatablePage, isUsersHomepage } = pagePathUtils;
 
 const PageCreateModal: React.FC = () => {
   const { t } = useTranslation();
@@ -45,19 +48,34 @@ const PageCreateModal: React.FC = () => {
   const isReachable = useAtomValue(isSearchServiceReachableAtom);
 
   // Memoize computed values
-  const userHomepagePath = useMemo(() => pagePathUtils.userHomepagePath(currentUser), [currentUser]);
-  const isCreatable = useMemo(() => isCreatablePage(pathname) || isUsersHomepage(pathname), [pathname]);
-  const pageNameInputInitialValue = useMemo(() => (isCreatable ? pathUtils.addTrailingSlash(pathname) : '/'), [isCreatable, pathname]);
+  const userHomepagePath = useMemo(
+    () => pagePathUtils.userHomepagePath(currentUser),
+    [currentUser],
+  );
+  const isCreatable = useMemo(
+    () => isCreatablePage(pathname) || isUsersHomepage(pathname),
+    [pathname],
+  );
+  const pageNameInputInitialValue = useMemo(
+    () => (isCreatable ? pathUtils.addTrailingSlash(pathname) : '/'),
+    [isCreatable, pathname],
+  );
   const now = useMemo(() => format(new Date(), 'yyyy/MM/dd'), []);
   const todaysParentPath = useMemo(
-    () => [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/'),
+    () =>
+      [
+        userHomepagePath,
+        t('create_page_dropdown.todays.memo', { ns: 'commons' }),
+        now,
+      ].join('/'),
     [userHomepagePath, t, now],
   );
 
   const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
   const [template, setTemplate] = useState(null);
-  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
+  const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] =
+    useState(false);
 
   const checkIsUsersHomepageDebounce = useMemo(() => {
     return debounce(1000, (input: string) => {
@@ -96,11 +114,14 @@ const PageCreateModal: React.FC = () => {
   /**
    * access today page
    */
-  const createTodayPage = useCallback(async() => {
+  const createTodayPage = useCallback(async () => {
     const joinedPath = [todaysParentPath, todayInput].join('/');
     return create(
       {
-        path: joinedPath, parentPath: todaysParentPath, wip: true, origin: Origin.View,
+        path: joinedPath,
+        parentPath: todaysParentPath,
+        wip: true,
+        origin: Origin.View,
       },
       { onTerminated: closeCreateModal },
     );
@@ -109,7 +130,7 @@ const PageCreateModal: React.FC = () => {
   /**
    * access input page
    */
-  const createInputPage = useCallback(async() => {
+  const createInputPage = useCallback(async () => {
     const targetPath = normalizePath(pageNameInput);
     const parentPath = path.dirname(targetPath);
 
@@ -127,9 +148,8 @@ const PageCreateModal: React.FC = () => {
   /**
    * access template page
    */
-  const createTemplatePage = useCallback(async() => {
-
-    const label = (template === 'children') ? '_template' : '__template';
+  const createTemplatePage = useCallback(async () => {
+    const label = template === 'children' ? '_template' : '__template';
 
     await createTemplate?.(label);
     closeCreateModal();
@@ -146,22 +166,28 @@ const PageCreateModal: React.FC = () => {
     return (
       <div className="row">
         <fieldset className="col-12 mb-4">
-          <h3 className="pb-2">{t('create_page_dropdown.todays.desc', { ns: 'commons' })}</h3>
+          <h3 className="pb-2">
+            {t('create_page_dropdown.todays.desc', { ns: 'commons' })}
+          </h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
-
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
               <div className="d-flex align-items-center text-nowrap">
                 <span>{todaysParentPath}/</span>
               </div>
-              <form className="mt-1 mt-lg-0 ms-lg-2 w-100" onSubmit={(e) => { transitBySubmitEvent(e, createTodaysMemoWithToastr) }}>
+              <form
+                className="mt-1 mt-lg-0 ms-lg-2 w-100"
+                onSubmit={(e) => {
+                  transitBySubmitEvent(e, createTodaysMemoWithToastr);
+                }}
+              >
                 <input
                   type="text"
                   className="page-today-input2 form-control w-100"
                   id="page-today-input2"
                   placeholder={t('Input page name (optional)')}
                   value={todayInput}
-                  onChange={e => onChangeTodayInputHandler(e.target.value)}
+                  onChange={(e) => onChangeTodayInputHandler(e.target.value)}
                 />
               </form>
             </div>
@@ -173,16 +199,23 @@ const PageCreateModal: React.FC = () => {
                 className="grw-btn-create-page btn btn-outline-primary rounded-pill text-nowrap ms-3"
                 onClick={createTodaysMemoWithToastr}
               >
-                <span className="material-symbols-outlined">description</span>{t('Create')}
+                <span className="material-symbols-outlined">description</span>
+                {t('Create')}
               </button>
             </div>
-
           </div>
-
         </fieldset>
       </div>
     );
-  }, [isOpened, todaysParentPath, todayInput, t, onChangeTodayInputHandler, transitBySubmitEvent, createTodaysMemoWithToastr]);
+  }, [
+    isOpened,
+    todaysParentPath,
+    todayInput,
+    t,
+    onChangeTodayInputHandler,
+    transitBySubmitEvent,
+    createTodaysMemoWithToastr,
+  ]);
 
   const renderInputPageForm = useMemo(() => {
     if (!isOpened) {
@@ -195,28 +228,30 @@ const PageCreateModal: React.FC = () => {
 
           <div className="d-sm-flex align-items-center justify-items-between">
             <div className="flex-fill">
-              {isReachable
-                ? (
-                  <PagePathAutoComplete
-                    initializedPath={pageNameInputInitialValue}
-                    addTrailingSlash
-                    onSubmit={createInputPageWithToastr}
-                    onInputChange={value => setPageNameInput(value)}
-                    autoFocus
+              {isReachable ? (
+                <PagePathAutoComplete
+                  initializedPath={pageNameInputInitialValue}
+                  addTrailingSlash
+                  onSubmit={createInputPageWithToastr}
+                  onInputChange={(value) => setPageNameInput(value)}
+                  autoFocus
+                />
+              ) : (
+                <form
+                  onSubmit={(e) => {
+                    transitBySubmitEvent(e, createInputPageWithToastr);
+                  }}
+                >
+                  <input
+                    type="text"
+                    value={pageNameInput}
+                    className="form-control flex-fill"
+                    placeholder={t('Input page name')}
+                    onChange={(e) => setPageNameInput(e.target.value)}
+                    required
                   />
-                )
-                : (
-                  <form onSubmit={(e) => { transitBySubmitEvent(e, createInputPageWithToastr) }}>
-                    <input
-                      type="text"
-                      value={pageNameInput}
-                      className="form-control flex-fill"
-                      placeholder={t('Input page name')}
-                      onChange={e => setPageNameInput(e.target.value)}
-                      required
-                    />
-                  </form>
-                )}
+                </form>
+              )}
             </div>
 
             <div className="d-flex justify-content-end mt-1 mt-sm-0">
@@ -227,19 +262,29 @@ const PageCreateModal: React.FC = () => {
                 onClick={createInputPageWithToastr}
                 disabled={isMatchedWithUserHomepagePath}
               >
-                <span className="material-symbols-outlined">description</span>{t('Create')}
+                <span className="material-symbols-outlined">description</span>
+                {t('Create')}
               </button>
             </div>
-
           </div>
-          { isMatchedWithUserHomepagePath && (
-            <p className="text-danger mt-2">Error: Cannot create page under /user page directory.</p>
-          ) }
-
+          {isMatchedWithUserHomepagePath && (
+            <p className="text-danger mt-2">
+              Error: Cannot create page under /user page directory.
+            </p>
+          )}
         </fieldset>
       </div>
     );
-  }, [isOpened, isReachable, pageNameInputInitialValue, createInputPageWithToastr, pageNameInput, isMatchedWithUserHomepagePath, t, transitBySubmitEvent]);
+  }, [
+    isOpened,
+    isReachable,
+    pageNameInputInitialValue,
+    createInputPageWithToastr,
+    pageNameInput,
+    isMatchedWithUserHomepagePath,
+    t,
+    transitBySubmitEvent,
+  ]);
 
   const renderTemplatePageForm = useMemo(() => {
     if (!isOpened) {
@@ -248,28 +293,42 @@ const PageCreateModal: React.FC = () => {
     return (
       <div className="row">
         <fieldset className="col-12">
-
           <h3 className="pb-2">
-            {t('template.modal_label.Create template under')}<br />
-            <code className="h6" data-testid="grw-page-create-modal-path-name">{pathname}</code>
+            {t('template.modal_label.Create template under')}
+            <br />
+            <code className="h6" data-testid="grw-page-create-modal-path-name">
+              {pathname}
+            </code>
           </h3>
 
           <div className="d-sm-flex align-items-center justify-items-between">
-
-            <UncontrolledButtonDropdown id="dd-template-type" className="flex-fill text-center">
+            <UncontrolledButtonDropdown
+              id="dd-template-type"
+              className="flex-fill text-center"
+            >
               <DropdownToggle id="template-type" caret>
                 {template == null && t('template.option_label.select')}
                 {template === 'children' && t('template.children.label')}
                 {template === 'descendants' && t('template.descendants.label')}
               </DropdownToggle>
               <DropdownMenu>
-                <DropdownItem onClick={() => onChangeTemplateHandler('children')}>
-                  {t('template.children.label')} (_template)<br className="d-block d-md-none" />
-                  <small className="text-muted text-wrap">- {t('template.children.desc')}</small>
+                <DropdownItem
+                  onClick={() => onChangeTemplateHandler('children')}
+                >
+                  {t('template.children.label')} (_template)
+                  <br className="d-block d-md-none" />
+                  <small className="text-muted text-wrap">
+                    - {t('template.children.desc')}
+                  </small>
                 </DropdownItem>
-                <DropdownItem onClick={() => onChangeTemplateHandler('descendants')}>
-                  {t('template.descendants.label')} (__template) <br className="d-block d-md-none" />
-                  <small className="text-muted">- {t('template.descendants.desc')}</small>
+                <DropdownItem
+                  onClick={() => onChangeTemplateHandler('descendants')}
+                >
+                  {t('template.descendants.label')} (__template){' '}
+                  <br className="d-block d-md-none" />
+                  <small className="text-muted">
+                    - {t('template.descendants.desc')}
+                  </small>
                 </DropdownItem>
               </DropdownMenu>
             </UncontrolledButtonDropdown>
@@ -282,16 +341,22 @@ const PageCreateModal: React.FC = () => {
                 onClick={createTemplateWithToastr}
                 disabled={template == null}
               >
-                <span className="material-symbols-outlined">description</span>{t('Edit')}
+                <span className="material-symbols-outlined">description</span>
+                {t('Edit')}
               </button>
             </div>
-
           </div>
-
         </fieldset>
       </div>
     );
-  }, [isOpened, pathname, template, onChangeTemplateHandler, createTemplateWithToastr, t]);
+  }, [
+    isOpened,
+    pathname,
+    template,
+    onChangeTemplateHandler,
+    createTemplateWithToastr,
+    t,
+  ]);
 
   return (
     <Modal
@@ -311,9 +376,7 @@ const PageCreateModal: React.FC = () => {
         {renderTemplatePageForm}
       </ModalBody>
     </Modal>
-
   );
 };
 
-
 export default PageCreateModal;

+ 8 - 13
apps/app/src/client/components/PagePathAutoComplete.jsx

@@ -1,15 +1,11 @@
 import React from 'react';
-
 import { pathUtils } from '@growi/core/dist/utils';
 import PropTypes from 'prop-types';
 
 import SearchTypeahead from './SearchTypeahead';
 
 const PagePathAutoComplete = (props) => {
-
-  const {
-    addTrailingSlash, initializedPath,
-  } = props;
+  const { addTrailingSlash, initializedPath } = props;
 
   function getKeywordOnInit(path) {
     if (path == null) {
@@ -29,22 +25,21 @@ const PagePathAutoComplete = (props) => {
       autoFocus={props.autoFocus}
     />
   );
-
 };
 
 PagePathAutoComplete.propTypes = {
-  initializedPath:  PropTypes.string,
+  initializedPath: PropTypes.string,
   addTrailingSlash: PropTypes.bool,
 
-  onChange:         PropTypes.func,
-  onSubmit:         PropTypes.func,
-  onInputChange:    PropTypes.func,
-  autoFocus:        PropTypes.bool,
+  onChange: PropTypes.func,
+  onSubmit: PropTypes.func,
+  onInputChange: PropTypes.func,
+  autoFocus: PropTypes.bool,
 };
 
 PagePathAutoComplete.defaultProps = {
-  initializedPath:  '/',
-  autoFocus:        false,
+  initializedPath: '/',
+  autoFocus: false,
 };
 
 export default PagePathAutoComplete;

+ 36 - 13
apps/app/src/client/components/PageStatusAlert.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
@@ -30,10 +29,16 @@ export const PageStatusAlert = (): JSX.Element => {
     pageStatusAlertData?.onResolveConflict?.();
   }, [pageStatusAlertData]);
 
-  const hasResolveConflictHandler = pageStatusAlertData?.onResolveConflict != null;
+  const hasResolveConflictHandler =
+    pageStatusAlertData?.onResolveConflict != null;
   const hasRefreshPageHandler = pageStatusAlertData?.onRefleshPage != null;
 
-  if (!pageStatusAlertData?.isOpen || !!isGuestUser || !!isReadOnlyUser || !isRevisionOutdated) {
+  if (
+    !pageStatusAlertData?.isOpen ||
+    !!isGuestUser ||
+    !!isReadOnlyUser ||
+    !isRevisionOutdated
+  ) {
     return <></>;
   }
 
@@ -42,23 +47,41 @@ export const PageStatusAlert = (): JSX.Element => {
   }
 
   return (
-    <div className={`${styles['grw-page-status-alert']} card fixed-bottom animated fadeInUp faster text-bg-warning`}>
+    <div
+      className={`${styles['grw-page-status-alert']} card fixed-bottom animated fadeInUp faster text-bg-warning`}
+    >
       <div className="card-body">
         <p className="card-text grw-card-label-container">
-          {hasResolveConflictHandler
-            ? <>{t('modal_resolve_conflict.file_conflicting_with_newer_remote')}</>
-            : <><Username user={remoteRevisionLastUpdateUser} /> {t('edited this page')}</>
-          }
+          {hasResolveConflictHandler ? (
+            <>
+              {t('modal_resolve_conflict.file_conflicting_with_newer_remote')}
+            </>
+          ) : (
+            <>
+              <Username user={remoteRevisionLastUpdateUser} />{' '}
+              {t('edited this page')}
+            </>
+          )}
         </p>
         <p className="card-text grw-card-btn-container">
           {hasRefreshPageHandler && (
-            <button type="button" onClick={onClickRefreshPage} className="btn btn-outline-white">
-              <span className="material-symbols-outlined">refresh</span>{t('Load latest')}
+            <button
+              type="button"
+              onClick={onClickRefreshPage}
+              className="btn btn-outline-white"
+            >
+              <span className="material-symbols-outlined">refresh</span>
+              {t('Load latest')}
             </button>
           )}
           {hasResolveConflictHandler && (
-            <button type="button" onClick={onClickResolveConflict} className="btn btn-outline-white">
-              <span className="material-symbols-outlined">description</span>{t('modal_resolve_conflict.resolve_conflict')}
+            <button
+              type="button"
+              onClick={onClickResolveConflict}
+              className="btn btn-outline-white"
+            >
+              <span className="material-symbols-outlined">description</span>
+              {t('modal_resolve_conflict.resolve_conflict')}
             </button>
           )}
         </p>

+ 6 - 4
apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx

@@ -11,6 +11,7 @@ import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useTagEditModalStatus, useTagEditModalActions, type TagEditModalStatus } from '~/states/ui/modal/tag-edit';
+import { useSWRxTagsInfo } from '~/stores/page';
 
 import { TagsInput } from './TagsInput';
 
@@ -28,8 +29,8 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   const pageId = tagEditModalData.pageId;
   const revisionId = tagEditModalData.revisionId;
   const updateStateAfterSave = useUpdateStateAfterSave(pageId);
-
-  const [tags, setTags] = useState<string[]>([]);
+  const { mutate: mutateTags } = useSWRxTagsInfo(pageId);
+  const [tags, setTags] = useState<string[]>(initTags ?? []);
 
   // use to take initTags when redirect to other page
   useEffect(() => {
@@ -46,6 +47,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
   const handleSubmit = useCallback(async() => {
     try {
       await apiPost('/tags.update', updateTagsData);
+      mutateTags();
       updateStateAfterSave?.();
 
       toastSuccess('updated tags successfully');
@@ -54,7 +56,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
     catch (err) {
       toastError(err);
     }
-  }, [closeTagEditModal, updateTagsData, updateStateAfterSave]);
+  }, [updateTagsData, mutateTags, updateStateAfterSave, closeTagEditModal]);
 
   // Memoized tags update handler
   const handleTagsUpdate = useCallback((newTags: string[]) => {
@@ -67,7 +69,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
         {t('tag_edit_modal.edit_tags')}
       </ModalHeader>
       <ModalBody>
-        <TagsInput tags={initTags} onTagsUpdated={handleTagsUpdate} autoFocus />
+        <TagsInput tags={tags} onTagsUpdated={handleTagsUpdate} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" data-testid="tag-edit-done-btn" className="btn btn-primary" onClick={handleSubmit}>

+ 15 - 16
apps/app/src/client/components/PageTimeline.tsx

@@ -1,8 +1,7 @@
 import React, { type JSX } from 'react';
-
+import Link from 'next/link';
 import type { IPageHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 
 import { useCurrentPagePath } from '~/states/page';
 import { useSWRINFxPageTimeline } from '~/stores/page-timeline';
@@ -13,13 +12,11 @@ import { RevisionLoader } from './Page/RevisionLoader';
 
 import styles from './PageTimeline.module.scss';
 
-
 type TimelineCardProps = {
-  page: IPageHasId,
-}
+  page: IPageHasId;
+};
 
 const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
-
   const { data: rendererOptions } = useTimelineOptions(page.path);
 
   return (
@@ -30,29 +27,32 @@ const TimelineCard = ({ page }: TimelineCardProps): JSX.Element => {
         </Link>
       </div>
       <div className="card-body">
-        { rendererOptions != null && page.revision != null && (
+        {rendererOptions != null && page.revision != null && (
           <RevisionLoader
             rendererOptions={rendererOptions}
             pageId={page._id}
             revisionId={page.revision}
           />
-        ) }
+        )}
       </div>
     </div>
   );
 };
 
 export const PageTimeline = (): JSX.Element => {
-
   const PER_PAGE = 3;
   const { t } = useTranslation();
   const currentPagePath = useCurrentPagePath();
 
-  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(currentPagePath ?? undefined, PER_PAGE);
+  const swrInfinitexPageTimeline = useSWRINFxPageTimeline(
+    currentPagePath ?? undefined,
+    PER_PAGE,
+  );
   const { data } = swrInfinitexPageTimeline;
 
   const isEmpty = data?.[0]?.pages.length === 0;
-  const isReachingEnd = isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
+  const isReachingEnd =
+    isEmpty || (data != null && data[data.length - 1]?.pages.length < PER_PAGE);
 
   if (data == null || isEmpty) {
     return (
@@ -68,11 +68,10 @@ export const PageTimeline = (): JSX.Element => {
         swrInifiniteResponse={swrInfinitexPageTimeline}
         isReachingEnd={isReachingEnd}
       >
-        { data != null && data.flatMap(apiResult => apiResult.pages)
-          .map(page => (
-            <TimelineCard key={page._id} page={page} />
-          ))
-        }
+        {data != null &&
+          data
+            .flatMap((apiResult) => apiResult.pages)
+            .map((page) => <TimelineCard key={page._id} page={page} />)}
       </InfiniteScroll>
     </div>
   );

+ 46 - 27
apps/app/src/client/components/PaginationWrapper.tsx

@@ -1,25 +1,18 @@
 import type { FC } from 'react';
-import React, {
-  memo, useCallback, useMemo, type JSX,
-} from 'react';
-
+import React, { type JSX, memo, useCallback, useMemo } from 'react';
 import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
 
-
 type Props = {
-  activePage: number,
-  changePage?: (activePage: number) => void,
-  totalItemsCount: number,
-  pagingLimit?: number,
-  align?: string,
-  size?: string,
+  activePage: number;
+  changePage?: (activePage: number) => void;
+  totalItemsCount: number;
+  pagingLimit?: number;
+  align?: string;
+  size?: string;
 };
 
-
 const PaginationWrapper: FC<Props> = memo((props: Props) => {
-  const {
-    activePage, changePage, totalItemsCount, pagingLimit, align,
-  } = props;
+  const { activePage, changePage, totalItemsCount, pagingLimit, align } = props;
 
   /**
    * various numbers used to generate pagination dom
@@ -29,7 +22,9 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     const limit = pagingLimit || Infinity;
 
     // calc totalPageNumber
-    const totalPage = Math.floor(totalItemsCount / limit) + (totalItemsCount % limit === 0 ? 0 : 1);
+    const totalPage =
+      Math.floor(totalItemsCount / limit) +
+      (totalItemsCount % limit === 0 ? 0 : 1);
 
     let paginationStart = activePage - 2;
     let maxViewPageNum = activePage + 2;
@@ -67,14 +62,23 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     if (activePage !== 1) {
       paginationItems.push(
         <PaginationItem key="painationItemFirst">
-          <PaginationLink first onClick={() => { return changePage != null && changePage(1) }} />
+          <PaginationLink
+            first
+            onClick={() => {
+              return changePage != null && changePage(1);
+            }}
+          />
         </PaginationItem>,
         <PaginationItem key="painationItemPrevious">
-          <PaginationLink previous onClick={() => { return changePage != null && changePage(activePage - 1) }} />
+          <PaginationLink
+            previous
+            onClick={() => {
+              return changePage != null && changePage(activePage - 1);
+            }}
+          />
         </PaginationItem>,
       );
-    }
-    else {
+    } else {
       paginationItems.push(
         <PaginationItem key="painationItemFirst" disabled>
           <PaginationLink first />
@@ -96,8 +100,15 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     const paginationItems: JSX.Element[] = [];
     for (let number = paginationStart; number <= maxViewPageNum; number++) {
       paginationItems.push(
-        <PaginationItem key={`paginationItem-${number}`} active={number === activePage}>
-          <PaginationLink onClick={() => { return changePage != null && changePage(number) }}>
+        <PaginationItem
+          key={`paginationItem-${number}`}
+          active={number === activePage}
+        >
+          <PaginationLink
+            onClick={() => {
+              return changePage != null && changePage(number);
+            }}
+          >
             {number}
           </PaginationLink>
         </PaginationItem>,
@@ -116,14 +127,23 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
     if (totalPage !== activePage) {
       paginationItems.push(
         <PaginationItem key="painationItemNext">
-          <PaginationLink next onClick={() => { return changePage != null && changePage(activePage + 1) }} />
+          <PaginationLink
+            next
+            onClick={() => {
+              return changePage != null && changePage(activePage + 1);
+            }}
+          />
         </PaginationItem>,
         <PaginationItem key="painationItemLast">
-          <PaginationLink last onClick={() => { return changePage != null && changePage(totalPage) }} />
+          <PaginationLink
+            last
+            onClick={() => {
+              return changePage != null && changePage(totalPage);
+            }}
+          />
         </PaginationItem>,
       );
-    }
-    else {
+    } else {
       paginationItems.push(
         <PaginationItem key="painationItemNext" disabled>
           <PaginationLink next />
@@ -158,7 +178,6 @@ const PaginationWrapper: FC<Props> = memo((props: Props) => {
       </Pagination>
     </React.Fragment>
   );
-
 });
 
 PaginationWrapper.displayName = 'PaginationWrapper';

+ 24 - 17
apps/app/src/client/components/PasswordResetExecutionForm.tsx

@@ -1,16 +1,14 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:passwordReset');
 
-
 const PasswordResetExecutionForm: FC = () => {
   const { t } = useTranslation(['translation', 'commons']);
 
@@ -22,7 +20,7 @@ const PasswordResetExecutionForm: FC = () => {
   const pathname = window.location.pathname.split('/');
   const token = pathname[2];
 
-  const changePassword = async(e) => {
+  const changePassword = async (e) => {
     e.preventDefault();
 
     if (newPassword === '' || newPasswordConfirm === '') {
@@ -31,28 +29,32 @@ const PasswordResetExecutionForm: FC = () => {
     }
 
     if (newPassword !== newPasswordConfirm) {
-      setValidationErrorI18n('forgot_password.password_and_confirm_password_does_not_match');
+      setValidationErrorI18n(
+        'forgot_password.password_and_confirm_password_does_not_match',
+      );
       return;
     }
 
     try {
       await apiv3Put('/forgot-password', {
-        token, newPassword, newPasswordConfirm,
+        token,
+        newPassword,
+        newPasswordConfirm,
       });
 
       setValidationErrorI18n('');
 
-      toastSuccess(t('toaster.update_successed', { target: t('Password'), ns: 'commons' }));
-    }
-    catch (err) {
+      toastSuccess(
+        t('toaster.update_successed', { target: t('Password'), ns: 'commons' }),
+      );
+    } catch (err) {
       toastError(err);
       logger.error(err);
     }
-
   };
 
   return (
-    <form role="form" onSubmit={changePassword}>
+    <form onSubmit={changePassword}>
       <div>
         <div className="input-group">
           <input
@@ -60,7 +62,7 @@ const PasswordResetExecutionForm: FC = () => {
             placeholder={t('forgot_password.new_password')}
             className="form-control"
             type="password"
-            onChange={e => setNewPassword(e.target.value)}
+            onChange={(e) => setNewPassword(e.target.value)}
           />
         </div>
       </div>
@@ -71,7 +73,7 @@ const PasswordResetExecutionForm: FC = () => {
             placeholder={t('forgot_password.confirm_new_password')}
             className="form-control"
             type="password"
-            onChange={e => setNewPasswordConfirm(e.target.value)}
+            onChange={(e) => setNewPasswordConfirm(e.target.value)}
           />
         </div>
         {validationErrorI18n !== '' && (
@@ -79,14 +81,19 @@ const PasswordResetExecutionForm: FC = () => {
         )}
       </div>
       <div>
-        <input name="reset-password-btn" className="btn btn-lg btn-primary" value={t('forgot_password.reset_password')} type="submit" />
+        <input
+          name="reset-password-btn"
+          className="btn btn-lg btn-primary"
+          value={t('forgot_password.reset_password')}
+          type="submit"
+        />
       </div>
       <Link href="/login" prefetch={false}>
-        <span className="material-symbols-outlined">login</span>{t('forgot_password.sign_in_instead')}
+        <span className="material-symbols-outlined">login</span>
+        {t('forgot_password.sign_in_instead')}
       </Link>
     </form>
   );
 };
 
-
 export default PasswordResetExecutionForm;

+ 28 - 22
apps/app/src/client/components/PasswordResetRequestForm.tsx

@@ -1,12 +1,11 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
+import Link from 'next/link';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { isMailerSetupAtom } from '~/states/server-configurations';
 
 const PasswordResetRequestForm: FC = () => {
@@ -18,21 +17,23 @@ const PasswordResetRequestForm: FC = () => {
     setEmail(inputValue);
   }, []);
 
-  const sendPasswordResetRequestMail = useCallback(async(e) => {
-    e.preventDefault();
-    if (email === '') {
-      toastError(t('forgot_password.email_is_required'));
-      return;
-    }
+  const sendPasswordResetRequestMail = useCallback(
+    async (e) => {
+      e.preventDefault();
+      if (email === '') {
+        toastError(t('forgot_password.email_is_required'));
+        return;
+      }
 
-    try {
-      await apiv3Post('/forgot-password', { email });
-      toastSuccess(t('forgot_password.success_to_send_email'));
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [t, email]);
+      try {
+        await apiv3Post('/forgot-password', { email });
+        toastSuccess(t('forgot_password.success_to_send_email'));
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [t, email],
+  );
 
   return (
     <form onSubmit={sendPasswordResetRequestMail}>
@@ -43,8 +44,12 @@ const PasswordResetRequestForm: FC = () => {
       ) : (
         <>
           {/* lock-icon large */}
-          <h1><span className="material-symbols-outlined">lock</span></h1>
-          <h1 className="text-center">{ t('forgot_password.forgot_password') }</h1>
+          <h1>
+            <span className="material-symbols-outlined">lock</span>
+          </h1>
+          <h1 className="text-center">
+            {t('forgot_password.forgot_password')}
+          </h1>
           <h3>{t('forgot_password.password_reset_request_desc')}</h3>
           <div>
             <div className="input-group">
@@ -54,7 +59,7 @@ const PasswordResetRequestForm: FC = () => {
                 className="form-control"
                 type="email"
                 disabled={!isMailerSetup}
-                onChange={e => changeEmail(e.target.value)}
+                onChange={(e) => changeEmail(e.target.value)}
               />
             </div>
           </div>
@@ -70,7 +75,8 @@ const PasswordResetRequestForm: FC = () => {
         </>
       )}
       <Link href="/login" prefetch={false}>
-        <span className="material-symbols-outlined">login</span>{t('forgot_password.return_to_login')}
+        <span className="material-symbols-outlined">login</span>
+        {t('forgot_password.return_to_login')}
       </Link>
     </form>
   );

+ 45 - 24
apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx

@@ -1,17 +1,20 @@
-import React, { useState, useCallback, useMemo } from 'react';
-
+import type React from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import type { ILegacyPrivatePage, PrivateLegacyPagesMigrationModalSubmitedHandler } from '~/states/ui/modal/private-legacy-pages-migration';
-import { usePrivateLegacyPagesMigrationModalActions, usePrivateLegacyPagesMigrationModalStatus } from '~/states/ui/modal/private-legacy-pages-migration';
+import type {
+  ILegacyPrivatePage,
+  PrivateLegacyPagesMigrationModalSubmitedHandler,
+} from '~/states/ui/modal/private-legacy-pages-migration';
+import {
+  usePrivateLegacyPagesMigrationModalActions,
+  usePrivateLegacyPagesMigrationModalStatus,
+} from '~/states/ui/modal/private-legacy-pages-migration';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-
 /**
  * PrivateLegacyPagesMigrationModalSubstance - Presentation component (all logic here)
  */
@@ -24,7 +27,10 @@ type PrivateLegacyPagesMigrationModalSubstanceProps = {
   close: () => void;
 };
 
-const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLegacyPagesMigrationModalSubstanceProps): React.JSX.Element => {
+const PrivateLegacyPagesMigrationModalSubstance = ({
+  status,
+  close,
+}: PrivateLegacyPagesMigrationModalSubstanceProps): React.JSX.Element => {
   const { t } = useTranslation();
 
   const [isRecursively, setIsRecursively] = useState(true);
@@ -33,13 +39,13 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
   const [errs, setErrs] = useState<Error[] | null>(null);
 
   // Memoize submit handler
-  const submit = useCallback(async() => {
+  const submit = useCallback(async () => {
     if (status == null || status.pages == null || status.pages.length === 0) {
       return;
     }
 
     const { pages, onSubmit } = status;
-    const pageIds = pages.map(page => page.pageId);
+    const pageIds = pages.map((page) => page.pageId);
     try {
       await apiv3Post<void>('/pages/legacy-pages-migration', {
         pageIds,
@@ -49,16 +55,18 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
       if (onSubmit != null) {
         onSubmit(pages, isRecursively);
       }
-    }
-    catch (err) {
+    } catch (err) {
       setErrs([err]);
     }
   }, [status, isRecursively]);
 
   // Memoize checkbox handler
-  const handleRecursivelyChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    setIsRecursively(e.target.checked);
-  }, []);
+  const handleRecursivelyChange = useCallback(
+    (e: React.ChangeEvent<HTMLInputElement>) => {
+      setIsRecursively(e.target.checked);
+    },
+    [],
+  );
 
   // Memoize form rendering
   const renderForm = useMemo(() => {
@@ -71,9 +79,15 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
           checked={isRecursively}
           onChange={handleRecursivelyChange}
         />
-        <label className="form-label form-check-label" htmlFor="convertRecursively">
-          { t('private_legacy_pages.modal.convert_recursively_label') }
-          <p className="form-text text-muted mt-0"> { t('private_legacy_pages.modal.convert_recursively_desc') }</p>
+        <label
+          className="form-label form-check-label"
+          htmlFor="convertRecursively"
+        >
+          {t('private_legacy_pages.modal.convert_recursively_label')}
+          <p className="form-text text-muted mt-0">
+            {' '}
+            {t('private_legacy_pages.modal.convert_recursively_desc')}
+          </p>
         </label>
       </div>
     );
@@ -82,7 +96,11 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
   // Memoize page IDs rendering
   const renderPageIds = useMemo(() => {
     if (status != null && status.pages != null) {
-      return status.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
+      return status.pages.map((page) => (
+        <div key={page.pageId}>
+          <code>{page.path}</code>
+        </div>
+      ));
     }
     return <></>;
   }, [status]);
@@ -90,11 +108,12 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
   return (
     <div>
       <ModalHeader tag="h4" toggle={close}>
-        { t('private_legacy_pages.modal.title') }
+        {t('private_legacy_pages.modal.title')}
       </ModalHeader>
       <ModalBody>
         <div className="grw-scrollable-modal-body pb-1">
-          <label>{ t('private_legacy_pages.modal.converting_pages') }:</label><br />
+          <div>{t('private_legacy_pages.modal.converting_pages')}:</div>
+          <br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
           {renderPageIds}
@@ -104,8 +123,10 @@ const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLeg
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
         <button type="button" className="btn btn-primary" onClick={submit}>
-          <span className="material-symbols-outlined" aria-hidden="true">refresh</span>
-          { t('private_legacy_pages.modal.button_label') }
+          <span className="material-symbols-outlined" aria-hidden="true">
+            refresh
+          </span>
+          {t('private_legacy_pages.modal.button_label')}
         </button>
       </ModalFooter>
     </div>

+ 82 - 13
apps/app/src/client/components/RecentActivity/ActivityListItem.tsx

@@ -1,9 +1,10 @@
 import { formatDistanceToNow } from 'date-fns';
-import { useTranslation } from 'next-i18next';
 import { type Locale } from 'date-fns/locale';
-import { getLocale } from '~/server/util/locale-utils';
-import type { ActivityHasUserId, SupportedActivityActionType } from '~/interfaces/activity';
+import { useTranslation } from 'next-i18next';
+
+import type { SupportedActivityActionType, ActivityHasTargetPage } from '~/interfaces/activity';
 import { ActivityLogActions } from '~/interfaces/activity';
+import { getLocale } from '~/server/util/locale-utils';
 
 
 export const ActivityActionTranslationMap: Record<
@@ -36,6 +37,18 @@ export const IconActivityTranslationMap: Record<
   [ActivityLogActions.ACTION_COMMENT_CREATE]: 'comment',
 };
 
+type ActivityListItemProps = {
+  activity: ActivityHasTargetPage,
+}
+
+type AllowPageDisplayPayload = {
+  grant: number | undefined,
+  status: string,
+  wip: boolean,
+  deletedAt?: Date,
+  path: string,
+}
+
 const translateAction = (action: SupportedActivityActionType): string => {
   return ActivityActionTranslationMap[action] || 'unknown_action';
 };
@@ -53,29 +66,85 @@ const calculateTimePassed = (date: Date, locale: Locale): string => {
   return timePassed;
 };
 
+const pageAllowedForDisplay = (allowDisplayPayload: AllowPageDisplayPayload): boolean => {
+  const {
+    grant, status, wip, deletedAt,
+  } = allowDisplayPayload;
+  if (grant !== 1) return false;
+
+  if (status !== 'published') return false;
+
+  if (wip) return false;
+
+  if (deletedAt) return false;
+
+  return true;
+};
+
+const setPath = (path: string, allowed: boolean): string => {
+  if (allowed) return path;
 
-export const ActivityListItem = ({ activity }: { activity: ActivityHasUserId }): JSX.Element => {
+  return '';
+};
+
+
+export const ActivityListItem = ({ props }: { props: ActivityListItemProps }): JSX.Element => {
   const { t, i18n } = useTranslation();
   const currentLangCode = i18n.language;
   const dateFnsLocale = getLocale(currentLangCode);
 
+  const { activity } = props;
+
+  const {
+    path, grant, status, wip, deletedAt,
+  } = activity.target;
+
+
+  const allowDisplayPayload: AllowPageDisplayPayload = {
+    grant,
+    status,
+    wip,
+    deletedAt,
+    path,
+  };
+
+  const isPageAllowed = pageAllowedForDisplay(allowDisplayPayload);
+
   const action = activity.action as SupportedActivityActionType;
   const keyToTranslate = translateAction(action);
   const fullKeyPath = `user_home_page.${keyToTranslate}`;
 
   return (
     <div className="activity-row">
-      <p className="mb-1">
-        <span className="material-symbols-outlined me-2">{setIcon(action)}</span>
-
-        <span className="dark:text-white">
-          {' '}{t(fullKeyPath)}
+      <div className="d-flex align-items-center">
+        <span className="material-symbols-outlined me-2 flex-shrink-0">
+          {setIcon(action)}
         </span>
 
-        <span className="text-secondary small ms-3">
-          {calculateTimePassed(activity.createdAt, dateFnsLocale)}
-        </span>
-      </p>
+        <div className="flex-grow-1 ms-2">
+          <div className="activity-path-line mb-0">
+            <a
+              href={setPath(path, isPageAllowed)}
+              className="activity-target-link fw-bold text-wrap d-block"
+            >
+              <span>
+                {setPath(path, isPageAllowed)}
+              </span>
+            </a>
+          </div>
+
+          <div className="activity-details-line d-flex">
+            <span>
+              {t(fullKeyPath)}
+            </span>
+
+            <span className="text-secondary small ms-3 align-self-center">
+              {calculateTimePassed(activity.createdAt, dateFnsLocale)}
+            </span>
+
+          </div>
+        </div>
+      </div>
     </div>
   );
 };

+ 8 - 6
apps/app/src/client/components/RecentActivity/RecentActivity.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import { toastError } from '~/client/util/toastr';
-import type { IActivityHasId, ActivityHasUserId } from '~/interfaces/activity';
+import type { IActivityHasId, ActivityHasTargetPage } from '~/interfaces/activity';
 import { useSWRxRecentActivity } from '~/stores/recent-activity';
 import loggerFactory from '~/utils/logger';
 
@@ -18,15 +18,17 @@ type RecentActivityProps = {
   userId: string,
 }
 
-const hasUser = (activity: IActivityHasId): activity is ActivityHasUserId => {
+const hasTargetPage = (activity: IActivityHasId): activity is ActivityHasTargetPage => {
   return activity.user != null
-        && typeof activity.user === 'object';
+         && typeof activity.user === 'object'
+         && activity.target != null
+         && typeof activity.target === 'object';
 };
 
 export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
   const { userId } = props;
 
-  const [activities, setActivities] = useState<ActivityHasUserId[]>([]);
+  const [activities, setActivities] = useState<ActivityHasTargetPage[]>([]);
   const [activePage, setActivePage] = useState(1);
   const [limit] = useState(10);
   const [offset, setOffset] = useState(0);
@@ -49,7 +51,7 @@ export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
 
     if (paginatedData) {
       const activitiesWithPages = paginatedData.docs
-        .filter(hasUser);
+        .filter(hasTargetPage);
 
       setActivities(activitiesWithPages);
     }
@@ -63,7 +65,7 @@ export const RecentActivity = (props: RecentActivityProps): JSX.Element => {
       <ul className="page-list-ul page-list-ul-flat mb-3">
         {activities.map(activity => (
           <li key={`recent-activity-view:${activity._id}`} className="mt-4">
-            <ActivityListItem activity={activity} />
+            <ActivityListItem props={{ activity }} />
           </li>
         ))}
       </ul>

+ 169 - 117
apps/app/src/client/components/SearchTypeahead.tsx

@@ -1,12 +1,23 @@
+import type React from 'react';
 import type {
-  FC, ForwardRefRenderFunction,
-  KeyboardEvent, MouseEvent,
+  FC,
+  ForwardRefRenderFunction,
+  KeyboardEvent,
+  MouseEvent,
 } from 'react';
-import React, {
-  forwardRef, useImperativeHandle, useCallback, useRef, useState, useEffect,
+import {
+  forwardRef,
+  useCallback,
+  useEffect,
+  useImperativeHandle,
+  useRef,
+  useState,
 } from 'react';
-
-import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui/dist/components';
+import {
+  PageListMeta,
+  PagePathLabel,
+  UserPicture,
+} from '@growi/ui/dist/components';
 import type { TypeaheadRef } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead, Menu, MenuItem } from 'react-bootstrap-typeahead';
 
@@ -15,16 +26,16 @@ import type { TypeaheadProps } from '~/client/interfaces/react-bootstrap-typeahe
 import type { IPageWithSearchMeta } from '~/interfaces/search';
 import { useSWRxSearch } from '~/stores/search';
 
-
 import styles from './SearchTypeahead.module.scss';
 
-
 type ResetFormButtonProps = {
-  input?: string,
-  onReset: (e: MouseEvent<HTMLButtonElement>) => void,
-}
+  input?: string;
+  onReset: (e: MouseEvent<HTMLButtonElement>) => void;
+};
 
-const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps) => {
+const ResetFormButton: FC<ResetFormButtonProps> = (
+  props: ResetFormButtonProps,
+) => {
   const { input, onReset } = props;
 
   const isHidden = input == null || input.length === 0;
@@ -32,88 +43,113 @@ const ResetFormButton: FC<ResetFormButtonProps> = (props: ResetFormButtonProps)
   return isHidden ? (
     <span />
   ) : (
-    <button type="button" className="btn btn-outline-secondary search-clear text-muted border-0" onMouseDown={onReset}>
+    <button
+      type="button"
+      className="btn btn-outline-secondary search-clear text-muted border-0"
+      onMouseDown={onReset}
+    >
       <span className="material-symbols-outlined">close</span>
     </button>
   );
 };
 
-
 type Props = TypeaheadProps & {
-  onSearchError?: (err: Error) => void,
-  onSubmit?: (input: string) => void,
-  keywordOnInit?: string,
-  disableIncrementalSearch?: boolean,
-  helpElement?: React.ReactNode,
+  onSearchError?: (err: Error) => void;
+  onSubmit?: (input: string) => void;
+  keywordOnInit?: string;
+  disableIncrementalSearch?: boolean;
+  helpElement?: React.ReactNode;
 };
 
-const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Props, ref) => {
+const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (
+  props: Props,
+  ref,
+) => {
   const {
-    onSearchError, onSearch, onInputChange, onChange, onSubmit,
-    inputProps, keywordOnInit, disableIncrementalSearch, helpElement,
-    onBlur, onFocus,
+    onSearchError,
+    onSearch,
+    onInputChange,
+    onChange,
+    onSubmit,
+    inputProps,
+    keywordOnInit,
+    disableIncrementalSearch,
+    helpElement,
+    onBlur,
+    onFocus,
   } = props;
 
   const [input, setInput] = useState(keywordOnInit);
   const [searchKeyword, setSearchKeyword] = useState('');
   const [isFocused, setFocused] = useState(false);
 
-  const { data: searchResult, error: searchError, isLoading } = useSWRxSearch(
-    disableIncrementalSearch ? null : searchKeyword,
-    null,
-    { limit: 10 },
-  );
+  const {
+    data: searchResult,
+    error: searchError,
+    isLoading,
+  } = useSWRxSearch(disableIncrementalSearch ? null : searchKeyword, null, {
+    limit: 10,
+  });
 
   const typeaheadRef = useRef<TypeaheadRef>(null);
 
-  const focusToTypeahead = () => {
+  const focusToTypeahead = useCallback(() => {
     const instance = typeaheadRef.current;
     if (instance != null) {
       instance.focus();
     }
-  };
+  }, []);
 
-  const clearTypeahead = () => {
+  const clearTypeahead = useCallback(() => {
     const instance = typeaheadRef.current;
     if (instance != null) {
       instance.clear();
     }
-  };
+  }, []);
 
   // publish focus()
   useImperativeHandle(ref, () => ({
     focus: focusToTypeahead,
   }));
 
-  const resetForm = useCallback((e: MouseEvent<HTMLButtonElement>) => {
-    e.preventDefault();
+  const resetForm = useCallback(
+    (e: MouseEvent<HTMLButtonElement>) => {
+      e.preventDefault();
 
-    setInput('');
-    setSearchKeyword('');
+      setInput('');
+      setSearchKeyword('');
 
-    clearTypeahead();
-    focusToTypeahead();
+      clearTypeahead();
+      focusToTypeahead();
 
-    if (onSearch != null) {
-      onSearch('');
-    }
-  }, [onSearch]);
+      if (onSearch != null) {
+        onSearch('');
+      }
+    },
+    [onSearch, clearTypeahead, focusToTypeahead],
+  );
 
-  const searchHandler = useCallback((text: string) => {
-    setSearchKeyword(text);
+  const searchHandler = useCallback(
+    (text: string) => {
+      setSearchKeyword(text);
 
-    if (onSearch != null) {
-      onSearch(text);
-    }
-  }, [onSearch]);
+      if (onSearch != null) {
+        onSearch(text);
+      }
+    },
+    [onSearch],
+  );
 
-  const inputChangeHandler = useCallback((text: string) => {
-    setInput(text);
+  const inputChangeHandler = useCallback(
+    (text: string) => {
+      setInput(text);
 
-    if (onInputChange != null) {
-      onInputChange(text);
-    }
-  }, [onInputChange]);
+      if (onInputChange != null) {
+        onInputChange(text);
+      }
+    },
+    [onInputChange],
+  );
 
   /* -------------------------------------------------------------------------------------------------------
    *
@@ -127,38 +163,47 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
   const DELAY_FOR_SUBMISSION = 100;
   const timeoutIdRef = useRef<NodeJS.Timeout>();
 
-  const changeHandler = useCallback((selectedItems: IPageWithSearchMeta[]) => {
-    // cancel schedule to submit
-    if (timeoutIdRef.current != null) {
-      clearTimeout(timeoutIdRef.current);
-    }
+  const changeHandler = useCallback(
+    (selectedItems: IPageWithSearchMeta[]) => {
+      // cancel schedule to submit
+      if (timeoutIdRef.current != null) {
+        clearTimeout(timeoutIdRef.current);
+      }
 
-    if (selectedItems.length > 0) {
-      setInput(selectedItems[0].data.path);
+      if (selectedItems.length > 0) {
+        setInput(selectedItems[0].data.path);
 
-      if (onInputChange != null) {
-        onInputChange(selectedItems[0].data.path);
-      }
+        if (onInputChange != null) {
+          onInputChange(selectedItems[0].data.path);
+        }
 
-      if (onChange != null) {
-        onChange(selectedItems);
+        if (onChange != null) {
+          onChange(selectedItems);
+        }
       }
-    }
-  }, [onChange, onInputChange]);
-
-  const keyDownHandler = useCallback((event: KeyboardEvent) => {
-    if (event.key === 'Enter') {
-      // do nothing while composing
-      // "event.isComposing" is not supported
-      if (event.nativeEvent.isComposing) {
-        return;
-      }
-      if (onSubmit != null && input != null && input.length > 0) {
-        // schedule to submit with 100ms delay
-        timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);
+    },
+    [onChange, onInputChange],
+  );
+
+  const keyDownHandler = useCallback(
+    (event: KeyboardEvent) => {
+      if (event.key === 'Enter') {
+        // do nothing while composing
+        // "event.isComposing" is not supported
+        if (event.nativeEvent.isComposing) {
+          return;
+        }
+        if (onSubmit != null && input != null && input.length > 0) {
+          // schedule to submit with 100ms delay
+          timeoutIdRef.current = setTimeout(
+            () => onSubmit(input),
+            DELAY_FOR_SUBMISSION,
+          );
+        }
       }
-    }
-  }, [input, onSubmit]);
+    },
+    [input, onSubmit],
+  );
   /*
    * -------------------------------------------------------------------------------------------------------
    */
@@ -179,49 +224,59 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
     }
   }, [keywordOnInit]);
 
-
   const labelKey = useCallback((option: IPageWithSearchMeta) => {
     return option.data.path ?? '';
   }, []);
 
-  const renderMenu = useCallback((options: IPageWithSearchMeta[], menuProps) => {
-    if (!isFocused) {
-      return <></>;
-    }
+  const renderMenu = useCallback(
+    (options: IPageWithSearchMeta[], menuProps) => {
+      if (!isFocused) {
+        return <></>;
+      }
 
-    const isEmptyInput = input == null || input.length === 0;
-    if (isEmptyInput) {
-      if (helpElement == null) {
+      const isEmptyInput = input == null || input.length === 0;
+      if (isEmptyInput) {
+        if (helpElement == null) {
+          return <></>;
+        }
+
+        return (
+          <Menu {...menuProps}>
+            <div className="p-3">{helpElement}</div>
+          </Menu>
+        );
+      }
+
+      if (disableIncrementalSearch) {
         return <></>;
       }
 
       return (
         <Menu {...menuProps}>
-          <div className="p-3">
-            {helpElement}
-          </div>
+          {options.map((pageWithMeta, index) => (
+            <MenuItem
+              key={pageWithMeta.data._id}
+              option={pageWithMeta}
+              position={index}
+            >
+              <span>
+                <UserPicture
+                  user={pageWithMeta.data.lastUpdateUser}
+                  size="sm"
+                  noLink
+                />
+                <span className="ms-1 me-2 text-break text-wrap">
+                  <PagePathLabel path={pageWithMeta.data.path} />
+                </span>
+                <PageListMeta page={pageWithMeta.data} />
+              </span>
+            </MenuItem>
+          ))}
         </Menu>
       );
-    }
-
-    if (disableIncrementalSearch) {
-      return <></>;
-    }
-
-    return (
-      <Menu {...menuProps}>
-        {options.map((pageWithMeta, index) => (
-          <MenuItem key={pageWithMeta.data._id} option={pageWithMeta} position={index}>
-            <span>
-              <UserPicture user={pageWithMeta.data.lastUpdateUser} size="sm" noLink />
-              <span className="ms-1 me-2 text-break text-wrap"><PagePathLabel path={pageWithMeta.data.path} /></span>
-              <PageListMeta page={pageWithMeta.data} />
-            </span>
-          </MenuItem>
-        ))}
-      </Menu>
-    );
-  }, [disableIncrementalSearch, helpElement, input, isFocused]);
+    },
+    [disableIncrementalSearch, helpElement, input, isFocused],
+  );
 
   const isOpenAlways = helpElement != null;
 
@@ -233,7 +288,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
         ref={typeaheadRef}
         delay={400}
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
-        inputProps={{ autoComplete: 'off', ...(inputProps as any ?? {}) }}
+        inputProps={{ autoComplete: 'off', ...((inputProps as any) ?? {}) }}
         isLoading={isLoading}
         labelKey={labelKey}
         defaultInputValue={keywordOnInit}
@@ -259,10 +314,7 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
           }
         }}
       />
-      <ResetFormButton
-        input={input}
-        onReset={resetForm}
-      />
+      <ResetFormButton input={input} onReset={resetForm} />
     </div>
   );
 };

+ 55 - 49
apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx

@@ -1,77 +1,83 @@
-import React, { memo, type JSX } from 'react';
-
+import React, { type JSX, memo } from 'react';
 import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
 
-import { useAppTitle, useConfidential, useIsDefaultLogo } from '~/states/global';
+import {
+  useAppTitle,
+  useConfidential,
+  useIsDefaultLogo,
+} from '~/states/global';
 
 import { SidebarBrandLogo } from '../SidebarBrandLogo';
 
 import styles from './AppTitle.module.scss';
 
-
 type Props = {
-  className?: string,
+  className?: string;
   hideAppTitle?: boolean;
-}
+};
 
-const AppTitleSubstance = memo(({ className = '', hideAppTitle = false }: Props): JSX.Element => {
+const AppTitleSubstance = memo(
+  ({ className = '', hideAppTitle = false }: Props): JSX.Element => {
+    const isDefaultLogo = useIsDefaultLogo();
+    const appTitle = useAppTitle();
+    const confidential = useConfidential();
 
-  const isDefaultLogo = useIsDefaultLogo();
-  const appTitle = useAppTitle();
-  const confidential = useConfidential();
-
-  return (
-    <div className={`${styles['grw-app-title']} ${className} d-flex`}>
-      {/* Brand Logo  */}
-      <Link href="/" className="grw-logo d-block">
-        <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
-      </Link>
-      <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
-        {!hideAppTitle && (
-          <div id="grw-site-name" className="grw-site-name text-truncate">
-            <Link href="/" className="fs-4">
-              {appTitle}
-            </Link>
-          </div>
+    return (
+      <div className={`${styles['grw-app-title']} ${className} d-flex`}>
+        {/* Brand Logo  */}
+        <Link href="/" className="grw-logo d-block">
+          <SidebarBrandLogo isDefaultLogo={isDefaultLogo} />
+        </Link>
+        <div className="flex-grow-1 d-flex align-items-center justify-content-between gap-3 overflow-hidden">
+          {!hideAppTitle && (
+            <div id="grw-site-name" className="grw-site-name text-truncate">
+              <Link href="/" className="fs-4">
+                {appTitle}
+              </Link>
+            </div>
+          )}
+        </div>
+        {!(confidential == null || confidential === '') && (
+          <UncontrolledTooltip
+            className="d-none d-sm-block confidential-tooltip"
+            innerClassName="text-start"
+            data-testid="confidential-tooltip"
+            placement="top"
+            target="grw-site-name"
+            fade={false}
+          >
+            {confidential}
+          </UncontrolledTooltip>
         )}
       </div>
-      {!(confidential == null || confidential === '')
-      && (
-        <UncontrolledTooltip
-          className="d-none d-sm-block confidential-tooltip"
-          innerClassName="text-start"
-          data-testid="confidential-tooltip"
-          placement="top"
-          target="grw-site-name"
-          fade={false}
-        >
-          {confidential}
-        </UncontrolledTooltip>
-      )}
-    </div>
-  );
-});
+    );
+  },
+);
 
 export const AppTitleOnSubnavigation = memo((): JSX.Element => {
-  return <AppTitleSubstance className={`position-absolute ${styles['on-subnavigation']}`} />;
-});
-
-export const AppTitleOnSidebarHead = memo(({ hideAppTitle }: Props): JSX.Element => {
   return (
     <AppTitleSubstance
-      className={`position-absolute z-1 ${styles['on-sidebar-head']}`}
-      hideAppTitle={hideAppTitle}
+      className={`position-absolute ${styles['on-subnavigation']}`}
     />
   );
 });
 
+export const AppTitleOnSidebarHead = memo(
+  ({ hideAppTitle }: Props): JSX.Element => {
+    return (
+      <AppTitleSubstance
+        className={`position-absolute z-1 ${styles['on-sidebar-head']}`}
+        hideAppTitle={hideAppTitle}
+      />
+    );
+  },
+);
+
 export const AppTitleOnEditorSidebarHead = memo((): JSX.Element => {
   return (
     <div className={`${styles['on-editor-sidebar-head']}`}>
-      <AppTitleSubstance
-        className={`${styles['on-sidebar-head']}`}
-      />
+      <AppTitleSubstance className={`${styles['on-sidebar-head']}`} />
     </div>
   );
 });

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

@@ -1,13 +1,11 @@
-
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
 
 import { useIsGuestUser } from '~/states/context';
 
 import { BookmarkContents } from './Bookmarks/BookmarkContents';
 
-export const Bookmarks = () : JSX.Element => {
+export const Bookmarks = (): JSX.Element => {
   const { t } = useTranslation();
   const isGuestUser = useIsGuestUser();
 
@@ -17,9 +15,7 @@ export const Bookmarks = () : JSX.Element => {
         <h3 className="fs-6 fw-bold mb-0 py-4">{t('Bookmarks')}</h3>
       </div>
       {isGuestUser ? (
-        <h4 className="fs-6">
-          { t('Not available for guest') }
-        </h4>
+        <h4 className="fs-6">{t('Not available for guest')}</h4>
       ) : (
         <BookmarkContents />
       )}

+ 20 - 22
apps/app/src/client/components/Sidebar/Bookmarks/BookmarkContents.tsx

@@ -1,5 +1,4 @@
-import React, { useCallback, useState, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { BookmarkFolderNameInput } from '~/client/components/Bookmarks/BookmarkFolderNameInput';
@@ -10,12 +9,13 @@ import { useCurrentUser } from '~/states/global';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 
 export const BookmarkContents = (): JSX.Element => {
-
   const { t } = useTranslation();
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
   const currentUser = useCurrentUser();
-  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
+  const { mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(
+    currentUser?._id,
+  );
 
   const onClickNewBookmarkFolder = useCallback(() => {
     setIsCreateAction(true);
@@ -25,20 +25,22 @@ export const BookmarkContents = (): JSX.Element => {
     setIsCreateAction(false);
   }, []);
 
-  const create = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const create = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
-    try {
-      await addNewFolder(folderName.trim(), null);
-      await mutateBookmarkFolders();
-      setIsCreateAction(false);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [cancel, mutateBookmarkFolders]);
+      try {
+        await addNewFolder(folderName.trim(), null);
+        await mutateBookmarkFolders();
+        setIsCreateAction(false);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [cancel, mutateBookmarkFolders],
+  );
 
   return (
     <div>
@@ -48,7 +50,6 @@ export const BookmarkContents = (): JSX.Element => {
           className="btn btn-outline-secondary rounded-pill d-flex justify-content-start align-middle"
           onClick={onClickNewBookmarkFolder}
         >
-
           <div className="d-flex align-items-center">
             <span className="material-symbols-outlined">create_new_folder</span>
             <span className="ms-2">{t('bookmark_folder.new_folder')}</span>
@@ -57,10 +58,7 @@ export const BookmarkContents = (): JSX.Element => {
       </div>
       {isCreateAction && (
         <div className="col-12 mb-2 ">
-          <BookmarkFolderNameInput
-            onSubmit={create}
-            onCancel={cancel}
-          />
+          <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
         </div>
       )}
       <BookmarkFolderTree isOperable userId={currentUser?._id} />

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

@@ -1,5 +1,4 @@
-import { Suspense, type JSX } from 'react';
-
+import { type JSX, Suspense } from 'react';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useTranslation } from 'react-i18next';
@@ -9,8 +8,13 @@ import { useSWRxPageByPath } from '~/stores/page';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 import DefaultContentSkeleton from '../Skeleton/DefaultContentSkeleton';
 
-
-const CustomSidebarContent = dynamic(() => import('./CustomSidebarSubstance').then(mod => mod.CustomSidebarSubstance), { ssr: false });
+const CustomSidebarContent = dynamic(
+  () =>
+    import('./CustomSidebarSubstance').then(
+      (mod) => mod.CustomSidebarSubstance,
+    ),
+  { ssr: false },
+);
 
 export const CustomSidebar = (): JSX.Element => {
   const { t } = useTranslation();
@@ -22,9 +26,13 @@ export const CustomSidebar = (): JSX.Element => {
       <div className="grw-sidebar-content-header d-flex">
         <h3 className="fs-6 fw-bold mb-0">
           {t('Custom Sidebar')}
-          { !isLoading && <Link href="/Sidebar#edit" className="h6 ms-2"><span className="material-symbols-outlined">edit</span></Link> }
+          {!isLoading && (
+            <Link href="/Sidebar#edit" className="h6 ms-2">
+              <span className="material-symbols-outlined">edit</span>
+            </Link>
+          )}
         </h3>
-        { !isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} /> }
+        {!isLoading && <SidebarHeaderReloadButton onClick={() => mutate()} />}
       </div>
 
       <Suspense fallback={<DefaultContentSkeleton />}>

+ 15 - 7
apps/app/src/client/components/Sidebar/Custom/CustomSidebarNotFound.tsx

@@ -1,5 +1,4 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { Origin } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 
@@ -10,16 +9,25 @@ export const SidebarNotFound = (): JSX.Element => {
 
   const { create } = useCreatePage();
 
-  const clickCreateButtonHandler = useCallback(async() => {
-    create({ path: '/Sidebar', wip: false, origin: Origin.View }, { skipPageExistenceCheck: true });
+  const clickCreateButtonHandler = useCallback(async () => {
+    create(
+      { path: '/Sidebar', wip: false, origin: Origin.View },
+      { skipPageExistenceCheck: true },
+    );
   }, [create]);
 
   return (
     <div>
-      <button type="button" className="btn btn-lg btn-link" onClick={clickCreateButtonHandler}>
+      <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>
+        <span
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+          dangerouslySetInnerHTML={{ __html: t('Create Sidebar Page') }}
+        ></span>
       </button>
     </div>
   );

+ 11 - 12
apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.tsx

@@ -9,10 +9,8 @@ import { SidebarNotFound } from './CustomSidebarNotFound';
 
 import styles from './CustomSidebarSubstance.module.scss';
 
-
 const logger = loggerFactory('growi:components:CustomSidebarSubstance');
 
-
 export const CustomSidebarSubstance = (): JSX.Element => {
   const { data: rendererOptions } = useCustomSidebarOptions({ suspense: true });
   const { data: page } = useSWRxPageByPath('/Sidebar', { suspense: true });
@@ -22,16 +20,17 @@ export const CustomSidebarSubstance = (): JSX.Element => {
   const markdown = page?.revision?.body;
 
   return (
-    <div className={`py-4 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}>
-      { markdown == null
-        ? <SidebarNotFound />
-        : (
-          <RevisionRenderer
-            rendererOptions={rendererOptions}
-            markdown={markdown}
-          />
-        )
-      }
+    <div
+      className={`py-4 grw-custom-sidebar-content ${styles['grw-custom-sidebar-content']}`}
+    >
+      {markdown == null ? (
+        <SidebarNotFound />
+      ) : (
+        <RevisionRenderer
+          rendererOptions={rendererOptions}
+          markdown={markdown}
+        />
+      )}
     </div>
   );
 };

+ 18 - 10
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx

@@ -1,33 +1,41 @@
-import React, { Suspense, useState, type JSX } from 'react';
-
+import React, { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
-
 import { InAppNotificationForms } from './InAppNotificationSubstance';
 
-const InAppNotificationContent = dynamic(() => import('./InAppNotificationSubstance').then(mod => mod.InAppNotificationContent), { ssr: false });
+const InAppNotificationContent = dynamic(
+  () =>
+    import('./InAppNotificationSubstance').then(
+      (mod) => mod.InAppNotificationContent,
+    ),
+  { ssr: false },
+);
 
 export const InAppNotification = (): JSX.Element => {
   const { t } = useTranslation();
 
-  const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] = useState(false);
+  const [isUnopendNotificationsVisible, setUnopendNotificationsVisible] =
+    useState(false);
 
   return (
     <div className="px-3">
       <div className="grw-sidebar-content-header py-4 d-flex">
-        <h3 className="fs-6 fw-bold mb-0">
-          {t('In-App Notification')}
-        </h3>
+        <h3 className="fs-6 fw-bold mb-0">{t('In-App Notification')}</h3>
       </div>
 
       <InAppNotificationForms
-        onChangeUnopendNotificationsVisible={() => { setUnopendNotificationsVisible(!isUnopendNotificationsVisible) }}
+        isUnopendNotificationsVisible={isUnopendNotificationsVisible}
+        onChangeUnopendNotificationsVisible={() => {
+          setUnopendNotificationsVisible(!isUnopendNotificationsVisible);
+        }}
       />
 
       <Suspense fallback={<ItemsTreeContentSkeleton />}>
-        <InAppNotificationContent isUnopendNotificationsVisible={isUnopendNotificationsVisible} />
+        <InAppNotificationContent
+          isUnopendNotificationsVisible={isUnopendNotificationsVisible}
+        />
       </Suspense>
     </div>
   );

+ 38 - 28
apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx

@@ -1,28 +1,34 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import InAppNotificationList from '~/client/components/InAppNotification/InAppNotificationList';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { useSWRxInAppNotifications } from '~/stores/in-app-notification';
 
-
 type InAppNotificationFormsProps = {
-  onChangeUnopendNotificationsVisible: () => void
-}
-export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.Element => {
-  const { onChangeUnopendNotificationsVisible } = props;
+  isUnopendNotificationsVisible: boolean;
+  onChangeUnopendNotificationsVisible: () => void;
+};
+export const InAppNotificationForms = (
+  props: InAppNotificationFormsProps,
+): JSX.Element => {
+  const { isUnopendNotificationsVisible, onChangeUnopendNotificationsVisible } =
+    props;
   const { t } = useTranslation('commons');
 
   return (
     <div className="my-2">
       <div className="form-check form-switch">
-        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">{t('in_app_notification.only_unread')}</label>
+        <label className="form-check-label" htmlFor="flexSwitchCheckDefault">
+          {t('in_app_notification.only_unread')}
+        </label>
         <input
           id="flexSwitchCheckDefault"
           className="form-check-input"
           type="checkbox"
           role="switch"
+          aria-checked={isUnopendNotificationsVisible}
+          checked={isUnopendNotificationsVisible}
           onChange={onChangeUnopendNotificationsVisible}
         />
       </div>
@@ -30,35 +36,39 @@ export const InAppNotificationForms = (props: InAppNotificationFormsProps): JSX.
   );
 };
 
-
 type InAppNotificationContentProps = {
-  isUnopendNotificationsVisible: boolean
-}
-export const InAppNotificationContent = (props: InAppNotificationContentProps): JSX.Element => {
+  isUnopendNotificationsVisible: boolean;
+};
+export const InAppNotificationContent = (
+  props: InAppNotificationContentProps,
+): JSX.Element => {
   const { isUnopendNotificationsVisible } = props;
   const { t } = useTranslation('commons');
 
   // TODO: Infinite scroll implemented (https://redmine.weseek.co.jp/issues/138057)
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
-    6,
-    undefined,
-    isUnopendNotificationsVisible ? InAppNotificationStatuses.STATUS_UNOPENED : undefined,
-    { keepPreviousData: true },
-  );
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
+    useSWRxInAppNotifications(
+      6,
+      undefined,
+      isUnopendNotificationsVisible
+        ? InAppNotificationStatuses.STATUS_UNOPENED
+        : undefined,
+      { keepPreviousData: true },
+    );
 
   return (
     <>
-      {inAppNotificationData != null && inAppNotificationData.docs.length === 0
-      // no items
-        ? t('in_app_notification.no_notification')
-      // render list-group
-        : (
-          <InAppNotificationList
-            inAppNotificationData={inAppNotificationData}
-            onUnopenedNotificationOpend={mutateInAppNotificationData}
-          />
-        )
-      }
+      {inAppNotificationData != null &&
+      inAppNotificationData.docs.length === 0 ? (
+        // no items
+        t('in_app_notification.no_notification')
+      ) : (
+        // render list-group
+        <InAppNotificationList
+          inAppNotificationData={inAppNotificationData}
+          onUnopenedNotificationOpend={mutateInAppNotificationData}
+        />
+      )}
     </>
   );
 };

+ 51 - 39
apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx

@@ -6,42 +6,54 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import { PrimaryItem, type PrimaryItemProps } from '../SidebarNav/PrimaryItem';
 
-type PrimaryItemForNotificationProps = Omit<PrimaryItemProps, 'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents' >
-
-export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificationProps) => {
-  const { sidebarMode, onHover } = props;
-
-  const socket = useGlobalSocket();
-
-  const { data: notificationCount, mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
-
-  const badgeContents = notificationCount != null && notificationCount > 0 ? notificationCount : undefined;
-
-  const itemHoverHandler = useCallback((contents: SidebarContentsType) => {
-    onHover?.(contents);
-  }, [onHover]);
-
-  useEffect(() => {
-    if (socket != null) {
-      socket.on('notificationUpdated', () => {
-        mutateNotificationCount();
-      });
-
-      // clean up
-      return () => {
-        socket.off('notificationUpdated');
-      };
-    }
-  }, [mutateNotificationCount, socket]);
-
-  return (
-    <PrimaryItem
-      sidebarMode={sidebarMode}
-      contents={SidebarContentsType.NOTIFICATION}
-      label="In-App Notification"
-      iconName="notifications"
-      badgeContents={badgeContents}
-      onHover={itemHoverHandler}
-    />
-  );
-});
+type PrimaryItemForNotificationProps = Omit<
+  PrimaryItemProps,
+  'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents'
+>;
+
+export const PrimaryItemForNotification = memo(
+  (props: PrimaryItemForNotificationProps) => {
+    const { sidebarMode, onHover } = props;
+
+    const socket = useGlobalSocket();
+
+    const { data: notificationCount, mutate: mutateNotificationCount } =
+      useSWRxInAppNotificationStatus();
+
+    const badgeContents =
+      notificationCount != null && notificationCount > 0
+        ? notificationCount
+        : undefined;
+
+    const itemHoverHandler = useCallback(
+      (contents: SidebarContentsType) => {
+        onHover?.(contents);
+      },
+      [onHover],
+    );
+
+    useEffect(() => {
+      if (socket != null) {
+        socket.on('notificationUpdated', () => {
+          mutateNotificationCount();
+        });
+
+        // clean up
+        return () => {
+          socket.off('notificationUpdated');
+        };
+      }
+    }, [mutateNotificationCount, socket]);
+
+    return (
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.NOTIFICATION}
+        label="In-App Notification"
+        iconName="notifications"
+        badgeContents={badgeContents}
+        onHover={itemHoverHandler}
+      />
+    );
+  },
+);

+ 7 - 3
apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.tsx

@@ -6,8 +6,10 @@ import styles from './CreateButton.module.scss';
 
 const moduleClass = styles['btn-create'];
 
-
-type Props = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
+type Props = DetailedHTMLProps<
+  ButtonHTMLAttributes<HTMLButtonElement>,
+  HTMLButtonElement
+>;
 
 export const CreateButton = (props: Props): JSX.Element => {
   return (
@@ -17,7 +19,9 @@ export const CreateButton = (props: Props): JSX.Element => {
       className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
     >
       <Hexagon />
-      <span className="icon material-symbols-outlined position-absolute" aria-label="Create">edit</span>
+      <span className="icon material-symbols-outlined position-absolute">
+        edit
+      </span>
     </button>
   );
 };

+ 63 - 64
apps/app/src/client/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -1,78 +1,77 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'react-i18next';
-import { DropdownMenu, DropdownItem } from 'reactstrap';
+import { DropdownItem, DropdownMenu } from 'reactstrap';
 
 import type { LabelType } from '~/interfaces/template';
 
-
 type DropendMenuProps = {
-  onClickCreateNewPage: () => Promise<void>
-  onClickOpenPageCreateModal: () => void
-  onClickCreateTodaysMemo: () => Promise<void>
-  onClickCreateTemplate?: (label: LabelType) => Promise<void>
-  todaysPath: string | null,
-}
+  onClickCreateNewPage: () => Promise<void>;
+  onClickOpenPageCreateModal: () => void;
+  onClickCreateTodaysMemo: () => Promise<void>;
+  onClickCreateTemplate?: (label: LabelType) => Promise<void>;
+  todaysPath: string | null;
+};
 
-export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element => {
-  const {
-    onClickCreateNewPage,
-    onClickOpenPageCreateModal,
-    onClickCreateTodaysMemo,
-    onClickCreateTemplate,
-    todaysPath,
-  } = props;
+export const DropendMenu = React.memo(
+  (props: DropendMenuProps): JSX.Element => {
+    const {
+      onClickCreateNewPage,
+      onClickOpenPageCreateModal,
+      onClickCreateTodaysMemo,
+      onClickCreateTemplate,
+      todaysPath,
+    } = props;
 
-  const { t } = useTranslation('commons');
-
-  return (
-    <DropdownMenu
-      container="body"
-      data-testid="grw-page-create-button-dropend-menu"
-    >
-      <DropdownItem
-        onClick={onClickCreateNewPage}
-      >
-        {t('create_page_dropdown.new_page')}
-      </DropdownItem>
+    const { t } = useTranslation('commons');
 
-      <DropdownItem
-        onClick={onClickOpenPageCreateModal}
+    return (
+      <DropdownMenu
+        container="body"
+        data-testid="grw-page-create-button-dropend-menu"
       >
-        {t('create_page_dropdown.open_page_create_modal')}
-      </DropdownItem>
+        <DropdownItem onClick={onClickCreateNewPage}>
+          {t('create_page_dropdown.new_page')}
+        </DropdownItem>
 
+        <DropdownItem onClick={onClickOpenPageCreateModal}>
+          {t('create_page_dropdown.open_page_create_modal')}
+        </DropdownItem>
 
-      { todaysPath != null && (
-        <>
-          <DropdownItem divider />
-          <li><span className="text-muted px-3">{t('create_page_dropdown.todays.desc')}</span></li>
-          <DropdownItem
-            aria-label="Create today page"
-            onClick={onClickCreateTodaysMemo}
-          >
-            {todaysPath}
-          </DropdownItem>
-        </>
-      )}
+        {todaysPath != null && (
+          <>
+            <DropdownItem divider />
+            <li>
+              <span className="text-muted px-3">
+                {t('create_page_dropdown.todays.desc')}
+              </span>
+            </li>
+            <DropdownItem
+              aria-label="Create today page"
+              onClick={onClickCreateTodaysMemo}
+            >
+              {todaysPath}
+            </DropdownItem>
+          </>
+        )}
 
-      { onClickCreateTemplate != null && (
-        <>
-          <DropdownItem divider />
-          <li><span className="text-muted text-nowrap px-3">{t('create_page_dropdown.template.desc')}</span></li>
-          <DropdownItem
-            onClick={() => onClickCreateTemplate('_template')}
-          >
-            {t('create_page_dropdown.template.children')}
-          </DropdownItem>
-          <DropdownItem
-            onClick={() => onClickCreateTemplate('__template')}
-          >
-            {t('create_page_dropdown.template.descendants')}
-          </DropdownItem>
-        </>
-      ) }
-    </DropdownMenu>
-  );
-});
+        {onClickCreateTemplate != null && (
+          <>
+            <DropdownItem divider />
+            <li>
+              <span className="text-muted text-nowrap px-3">
+                {t('create_page_dropdown.template.desc')}
+              </span>
+            </li>
+            <DropdownItem onClick={() => onClickCreateTemplate('_template')}>
+              {t('create_page_dropdown.template.children')}
+            </DropdownItem>
+            <DropdownItem onClick={() => onClickCreateTemplate('__template')}>
+              {t('create_page_dropdown.template.descendants')}
+            </DropdownItem>
+          </>
+        )}
+      </DropdownMenu>
+    );
+  },
+);
 DropendMenu.displayName = 'DropendMenu';

+ 3 - 4
apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -1,15 +1,12 @@
 import type { JSX } from 'react';
-
 import { DropdownToggle } from 'reactstrap';
 
 import { Hexagon } from './Hexagon';
 
 import styles from './DropendToggle.module.scss';
 
-
 const moduleClass = styles['btn-toggle'];
 
-
 export const DropendToggle = (): JSX.Element => {
   return (
     <DropdownToggle
@@ -21,7 +18,9 @@ export const DropendToggle = (): JSX.Element => {
     >
       <Hexagon className="pe-none" />
       <div className="hitarea position-absolute" />
-      <span className="icon material-symbols-outlined position-absolute">chevron_right</span>
+      <span className="icon material-symbols-outlined position-absolute">
+        chevron_right
+      </span>
     </DropdownToggle>
   );
 };

+ 20 - 14
apps/app/src/client/components/Sidebar/PageCreateButton/Hexagon.tsx

@@ -1,18 +1,24 @@
 import React, { type JSX } from 'react';
 
 type Props = {
-  className?: string,
-}
+  className?: string;
+};
 
-export const Hexagon = React.memo((props: Props): JSX.Element => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    viewBox="0 0 27.691 23.999"
-    height="36px"
-    className={props.className}
-  >
-    <g className="background" transform="translate(0 0)">
-      <path d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z" transform="translate(0)"></path>
-    </g>
-  </svg>
-));
+export const Hexagon = React.memo(
+  (props: Props): JSX.Element => (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 27.691 23.999"
+      height="36px"
+      className={props.className}
+    >
+      <title>Create</title>
+      <g className="background" transform="translate(0 0)">
+        <path
+          d="M20.768,0l6.923,12L20.768,24H6.923L0,12,6.923,0Z"
+          transform="translate(0)"
+        ></path>
+      </g>
+    </svg>
+  ),
+);

+ 22 - 12
apps/app/src/client/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -1,5 +1,4 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { Dropdown } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
@@ -12,7 +11,6 @@ import { DropendMenu } from './DropendMenu';
 import { DropendToggle } from './DropendToggle';
 import { useCreateNewPage, useCreateTodaysMemo } from './hooks';
 
-
 export const PageCreateButton = React.memo((): JSX.Element => {
   const [isHovered, setIsHovered] = useState(false);
 
@@ -23,11 +21,16 @@ export const PageCreateButton = React.memo((): JSX.Element => {
 
   const { createNewPage, isCreating: isNewPageCreating } = useCreateNewPage();
   // TODO: https://redmine.weseek.co.jp/issues/138806
-  const { createTodaysMemo, isCreating: isTodaysPageCreating, todaysPath } = useCreateTodaysMemo();
+  const {
+    createTodaysMemo,
+    isCreating: isTodaysPageCreating,
+    todaysPath,
+  } = useCreateTodaysMemo();
   // TODO: https://redmine.weseek.co.jp/issues/138805
   const {
     createTemplate,
-    isCreating: isTemplatePageCreating, isCreatable: isTemplatePageCreatable,
+    isCreating: isTemplatePageCreating,
+    isCreatable: isTemplatePageCreatable,
   } = useCreateTemplatePage();
 
   const createNewPageWithToastr = useToastrOnError(createNewPage);
@@ -46,20 +49,23 @@ export const PageCreateButton = React.memo((): JSX.Element => {
   const toggle = () => setDropdownOpen(!dropdownOpen);
 
   return (
-    <div
-      className="d-flex flex-row mt-2"
+    <fieldset
+      className="d-flex flex-row mt-2 border-0 p-0 m-0"
       onMouseEnter={onMouseEnterHandler}
       onMouseLeave={onMouseLeaveHandler}
       data-testid="grw-page-create-button"
+      aria-label="Page create actions"
     >
       <div className="btn-group flex-grow-1">
         <CreateButton
           className="z-2"
           onClick={createNewPageWithToastr}
-          disabled={isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating}
+          disabled={
+            isNewPageCreating || isTodaysPageCreating || isTemplatePageCreating
+          }
         />
       </div>
-      { isHovered && (
+      {isHovered && (
         <Dropdown
           isOpen={dropdownOpen}
           toggle={toggle}
@@ -69,13 +75,17 @@ export const PageCreateButton = React.memo((): JSX.Element => {
           <DropendToggle />
           <DropendMenu
             onClickCreateNewPage={createNewPageWithToastr}
-            onClickOpenPageCreateModal={() => openPageCreateModal(currentPagePath)}
+            onClickOpenPageCreateModal={() =>
+              openPageCreateModal(currentPagePath)
+            }
             onClickCreateTodaysMemo={createTodaysMemoWithToastr}
-            onClickCreateTemplate={isTemplatePageCreatable ? createTemplateWithToastr : undefined}
+            onClickCreateTemplate={
+              isTemplatePageCreatable ? createTemplateWithToastr : undefined
+            }
             todaysPath={todaysPath}
           />
         </Dropdown>
       )}
-    </div>
+    </fieldset>
   );
 });

+ 4 - 6
apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts

@@ -1,22 +1,20 @@
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 
 import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentPagePath } from '~/states/page';
 
-
 type UseCreateNewPage = () => {
-  isCreating: boolean,
-  createNewPage: () => Promise<void>,
-}
+  isCreating: boolean;
+  createNewPage: () => Promise<void>;
+};
 
 export const useCreateNewPage: UseCreateNewPage = () => {
   const currentPagePath = useCurrentPagePath();
 
   const { isCreating, create } = useCreatePage();
 
-  const createNewPage = useCallback(async() => {
+  const createNewPage = useCallback(async () => {
     if (currentPagePath == null) return;
 
     return create(

+ 12 - 15
apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx

@@ -1,5 +1,4 @@
 import { useCallback } from 'react';
-
 import { Origin } from '@growi/core';
 import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
 import { format } from 'date-fns/format';
@@ -8,12 +7,11 @@ import { useTranslation } from 'react-i18next';
 import { useCreatePage } from '~/client/services/create-page';
 import { useCurrentUser } from '~/states/global';
 
-
 type UseCreateTodaysMemo = () => {
-  isCreating: boolean,
-  todaysPath: string | null,
-  createTodaysMemo: () => Promise<void>,
-}
+  isCreating: boolean;
+  todaysPath: string | null;
+  createTodaysMemo: () => Promise<void>;
+};
 
 export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const { t } = useTranslation('commons');
@@ -26,18 +24,17 @@ export const useCreateTodaysMemo: UseCreateTodaysMemo = () => {
   const parentDirName = t('create_page_dropdown.todays.memo');
   const now = format(new Date(), 'yyyy/MM/dd');
   const parentPath = `${userHomepagePath(currentUser)}/${parentDirName}`;
-  const todaysPath = isCreatable
-    ? `${parentPath}/${now}`
-    : null;
+  const todaysPath = isCreatable ? `${parentPath}/${now}` : null;
 
-  const createTodaysMemo = useCallback(async() => {
+  const createTodaysMemo = useCallback(async () => {
     if (!isCreatable || todaysPath == null) return;
 
-    return create(
-      {
-        path: todaysPath, parentPath, wip: true, origin: Origin.View,
-      },
-    );
+    return create({
+      path: todaysPath,
+      parentPath,
+      wip: true,
+      origin: Origin.View,
+    });
   }, [create, isCreatable, todaysPath, parentPath]);
 
   return {

+ 5 - 6
apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx

@@ -1,20 +1,17 @@
-import { Suspense, useState, type JSX } from 'react';
-
+import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 import { useTranslation } from 'react-i18next';
 
 import ItemsTreeContentSkeleton from '../../ItemsTree/ItemsTreeContentSkeleton';
-
 import { PageTreeHeader } from './PageTreeSubstance';
 
 const PageTreeContent = dynamic(
-  () => import('./PageTreeSubstance').then(mod => mod.PageTreeContent),
+  () => import('./PageTreeSubstance').then((mod) => mod.PageTreeContent),
   { ssr: false, loading: ItemsTreeContentSkeleton },
 );
 
-
 export const PageTree = (): JSX.Element => {
   const { t } = useTranslation();
 
@@ -27,7 +24,9 @@ export const PageTree = (): JSX.Element => {
         <Suspense>
           <PageTreeHeader
             isWipPageShown={isWipPageShown}
-            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+            onWipPageShownChange={() => {
+              setIsWipPageShown(!isWipPageShown);
+            }}
           />
         </Suspense>
       </div>

+ 133 - 115
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,7 +1,4 @@
-import React, {
-  memo, useCallback,
-} from 'react';
-
+import React, { memo, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { ItemsTree } from '~/features/page-tree/components';
@@ -10,141 +7,162 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageId, useCurrentPagePath } from '~/states/page';
 import { useSidebarScrollerElem } from '~/states/ui/sidebar';
 import {
-  mutatePageTree, mutateRecentlyUpdated, useSWRxRootPage, useSWRxV5MigrationStatus,
+  mutatePageTree,
+  mutateRecentlyUpdated,
+  useSWRxRootPage,
+  useSWRxV5MigrationStatus,
 } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 import { PageTreeItem, pageTreeItemSize } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
-
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
 
 const logger = loggerFactory('growi:cli:PageTreeSubstance');
 
 type HeaderProps = {
-  isWipPageShown: boolean,
-  onWipPageShownChange?: () => void
-}
-
-export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
-  const { t } = useTranslation();
-
-  const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
-  useSWRxV5MigrationStatus({ suspense: true });
-  const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
-
-  const mutate = useCallback(() => {
-    mutateRootPage();
-    mutatePageTree();
-    mutateRecentlyUpdated();
-    // Notify headless-tree to rebuild with fresh data
-    notifyUpdateAllTrees();
-  }, [mutateRootPage, notifyUpdateAllTrees]);
+  isWipPageShown: boolean;
+  onWipPageShownChange?: () => void;
+};
 
-  return (
-    <>
-      <SidebarHeaderReloadButton onClick={() => mutate()} />
-
-      <div className="me-1">
-        <button
-          color="transparent"
-          className="btn p-0 border-0"
-          type="button"
-          data-bs-toggle="dropdown"
-          data-bs-auto-close="outside"
-          aria-expanded="false"
-        >
-          <span className="material-symbols-outlined">more_horiz</span>
-        </button>
-
-        <ul className="dropdown-menu">
-          <li className="dropdown-item" onClick={onWipPageShownChange}>
-            <div className="form-check form-switch">
-              <input
-                className="form-check-input pe-none"
-                type="checkbox"
-                checked={isWipPageShown}
-                onChange={() => { }}
-              />
-              <label className="form-check-label pe-none">
-                {t('sidebar_header.show_wip_page')}
-              </label>
-            </div>
-          </li>
-        </ul>
-      </div>
-    </>
-  );
-});
+export const PageTreeHeader = memo(
+  ({ isWipPageShown, onWipPageShownChange }: HeaderProps) => {
+    const { t } = useTranslation();
+
+    const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
+    useSWRxV5MigrationStatus({ suspense: true });
+    const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
+
+    const mutate = useCallback(() => {
+      mutateRootPage();
+      mutatePageTree();
+      mutateRecentlyUpdated();
+      // Notify headless-tree to rebuild with fresh data
+      notifyUpdateAllTrees();
+    }, [mutateRootPage, notifyUpdateAllTrees]);
+
+    return (
+      <>
+        <SidebarHeaderReloadButton onClick={() => mutate()} />
+
+        <div className="me-1">
+          <button
+            color="transparent"
+            className="btn p-0 border-0"
+            type="button"
+            data-bs-toggle="dropdown"
+            data-bs-auto-close="outside"
+            aria-expanded="false"
+          >
+            <span className="material-symbols-outlined">more_horiz</span>
+          </button>
+
+          <ul className="dropdown-menu">
+            <li>
+              <button
+                type="button"
+                className="dropdown-item"
+                onClick={onWipPageShownChange}
+              >
+                <div className="form-check form-switch">
+                  <input
+                    id="page-tree-wip-toggle"
+                    className="form-check-input pe-none"
+                    type="checkbox"
+                    checked={isWipPageShown}
+                    onChange={() => {}}
+                  />
+                  <label
+                    className="form-check-label pe-none"
+                    htmlFor="page-tree-wip-toggle"
+                  >
+                    {t('sidebar_header.show_wip_page')}
+                  </label>
+                </div>
+              </button>
+            </li>
+          </ul>
+        </div>
+      </>
+    );
+  },
+);
 PageTreeHeader.displayName = 'PageTreeHeader';
 
-
 const PageTreeUnavailable = () => {
   const { t } = useTranslation();
 
   return (
     <div className="mt-5 mx-2 text-center">
-      <h3 className="text-gray">{t('v5_page_migration.page_tree_not_avaliable')}</h3>
+      <h3 className="text-gray">
+        {t('v5_page_migration.page_tree_not_avaliable')}
+      </h3>
       <a href="/admin">{t('v5_page_migration.go_to_settings')}</a>
     </div>
   );
 };
 
 type PageTreeContentProps = {
-  isWipPageShown: boolean,
-}
-
-export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) => {
-
-  const isGuestUser = useIsGuestUser();
-  const isReadOnlyUser = useIsReadOnlyUser();
-  const currentPath = useCurrentPagePath();
-  const targetId = useCurrentPageId();
-
-  const { data: migrationStatus } = useSWRxV5MigrationStatus({ suspense: true });
-
-  const targetPathOrId = targetId || currentPath;
-  const path = currentPath || '/';
-
-  const sidebarScrollerElem = useSidebarScrollerElem();
-
-  const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
-
-  if (!migrationStatus?.isV5Compatible) {
-    return <PageTreeUnavailable />;
-  }
-
-  /*
-   * dependencies
-   */
-  if (isGuestUser == null) {
-    return null;
-  }
+  isWipPageShown: boolean;
+};
 
-  return (
-    <div className="pt-4">
-      <ItemsTree
-        enableRenaming
-        enableDragAndDrop
-        isEnableActions={!isGuestUser}
-        isReadOnlyUser={!!isReadOnlyUser}
-        isWipPageShown={isWipPageShown}
-        targetPath={path}
-        targetPathOrId={targetPathOrId}
-        CustomTreeItem={PageTreeItem}
-        estimateTreeItemSize={estimateTreeItemSize}
-        scrollerElem={sidebarScrollerElem}
-      />
-
-      {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (
-        <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
-          <div className="private-legacy-pages-link px-3 py-2">
-            <PrivateLegacyPagesLink />
-          </div>
-        </div>
-      )}
-    </div>
-  );
-});
+export const PageTreeContent = memo(
+  ({ isWipPageShown }: PageTreeContentProps) => {
+    const isGuestUser = useIsGuestUser();
+    const isReadOnlyUser = useIsReadOnlyUser();
+    const currentPath = useCurrentPagePath();
+    const targetId = useCurrentPageId();
+
+    const { data: migrationStatus } = useSWRxV5MigrationStatus({
+      suspense: true,
+    });
+
+    const targetPathOrId = targetId || currentPath;
+    const path = currentPath || '/';
+
+    const sidebarScrollerElem = useSidebarScrollerElem();
+
+    const estimateTreeItemSize = useCallback(() => pageTreeItemSize, []);
+
+    if (!migrationStatus?.isV5Compatible) {
+      return <PageTreeUnavailable />;
+    }
+
+    /*
+     * dependencies
+     */
+    if (isGuestUser == null) {
+      return null;
+    }
+
+    return (
+      <div className="pt-4">
+        <ItemsTree
+          enableRenaming
+          enableDragAndDrop
+          isEnableActions={!isGuestUser}
+          isReadOnlyUser={!!isReadOnlyUser}
+          isWipPageShown={isWipPageShown}
+          targetPath={path}
+          targetPathOrId={targetPathOrId}
+          CustomTreeItem={PageTreeItem}
+          estimateTreeItemSize={estimateTreeItemSize}
+          scrollerElem={sidebarScrollerElem}
+        />
+
+        {!isGuestUser &&
+          !isReadOnlyUser &&
+          migrationStatus?.migratablePagesCount != null &&
+          migrationStatus.migratablePagesCount !== 0 && (
+            <div className="grw-pagetree-footer border-top mt-4 py-2 w-100">
+              <div className="private-legacy-pages-link px-3 py-2">
+                <PrivateLegacyPagesLink />
+              </div>
+            </div>
+          )}
+      </div>
+    );
+  },
+);
 
 PageTreeContent.displayName = 'PageTreeContent';

+ 3 - 4
apps/app/src/client/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,9 +1,7 @@
 import type { FC } from 'react';
 import React, { memo } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
-
+import { useTranslation } from 'next-i18next';
 
 export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
@@ -14,7 +12,8 @@ export const PrivateLegacyPagesLink: FC = memo(() => {
       className="h5 grw-private-legacy-pages-anchor text-decoration-none"
       prefetch={false}
     >
-      <span className="material-symbols-outlined me-2">bottom_drawer</span> {t('private_legacy_pages.title')}
+      <span className="material-symbols-outlined me-2">bottom_drawer</span>{' '}
+      {t('private_legacy_pages.title')}
     </Link>
   );
 });

+ 4 - 9
apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

@@ -4,8 +4,9 @@ import CountBadge from '~/client/components/Common/CountBadge';
 import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { usePageTreeDescCountMap } from '~/features/page-tree/states';
 
-
-export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element => {
+export const CountBadgeForPageTreeItem = (
+  props: TreeItemToolProps,
+): JSX.Element => {
   const { getDescCount } = usePageTreeDescCountMap();
 
   const { item } = props;
@@ -13,11 +14,5 @@ export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element
 
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 
-  return (
-    <>
-      {descendantCount > 0 && (
-        <CountBadge count={descendantCount} />
-      )}
-    </>
-  );
+  return <>{descendantCount > 0 && <CountBadge count={descendantCount} />}</>;
 };

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