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

Merge branch 'master' into imprv/173835-new-help-button

Yuki Takei 3 месяцев назад
Родитель
Сommit
b132f7cc52
100 измененных файлов с 2771 добавлено и 1820 удалено
  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. 105 0
      .serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md
  7. 50 1
      CHANGELOG.md
  8. 49 0
      apps/app/.eslintrc.js
  9. 3 0
      apps/app/.gitignore
  10. BIN
      apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c
  11. 1 0
      apps/app/config/logger/config.dev.js
  12. 1 1
      apps/app/docker/README.md
  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. 4 3
      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. 45 36
      apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx
  35. 224 147
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  36. 27 19
      apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx
  37. 85 60
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  38. 11 12
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx
  39. 39 19
      apps/app/src/client/components/Bookmarks/BookmarkFolderNameInput.tsx
  40. 66 33
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx
  41. 173 114
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  42. 39 19
      apps/app/src/client/components/Bookmarks/BookmarkItemRenameInput.tsx
  43. 5 4
      apps/app/src/client/components/Bookmarks/BookmarkMoveToRootBtn.tsx
  44. 33 21
      apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx
  45. 27 21
      apps/app/src/client/components/Comments.tsx
  46. 60 27
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx
  47. 6 7
      apps/app/src/client/components/Common/CountBadge.tsx
  48. 7 6
      apps/app/src/client/components/Common/CustomCopyToClipBoard.tsx
  49. 4 7
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx
  50. 17 11
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  51. 373 283
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  52. 138 87
      apps/app/src/client/components/Common/ImageCropModal.tsx
  53. 6 8
      apps/app/src/client/components/Common/LazyRenderer.tsx
  54. 4 3
      apps/app/src/client/components/Common/RendererErrorMessage.tsx
  55. 19 12
      apps/app/src/client/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  56. 5 8
      apps/app/src/client/components/Common/SubmittableInput/SubmittableInput.tsx
  57. 1 1
      apps/app/src/client/components/Common/SubmittableInput/index.ts
  58. 8 7
      apps/app/src/client/components/Common/SubmittableInput/types.d.ts
  59. 73 55
      apps/app/src/client/components/Common/SubmittableInput/use-submittable.ts
  60. 1 5
      apps/app/src/client/components/Common/UserPictureList.jsx
  61. 2 2
      apps/app/src/client/components/CompleteUserRegistration.tsx
  62. 74 58
      apps/app/src/client/components/CompleteUserRegistrationForm.tsx
  63. 31 7
      apps/app/src/client/components/ContentLinkButtons.tsx
  64. 46 32
      apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx
  65. 12 3
      apps/app/src/client/components/CreateTemplateModal/dynamic.tsx
  66. 105 69
      apps/app/src/client/components/CustomNavigation/CustomNav.tsx
  67. 27 15
      apps/app/src/client/components/CustomNavigation/CustomNavAndContents.tsx
  68. 11 13
      apps/app/src/client/components/CustomNavigation/CustomTabContent.tsx
  69. 20 7
      apps/app/src/client/components/DataTransferForm.tsx
  70. 24 14
      apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx
  71. 9 5
      apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx
  72. 77 51
      apps/app/src/client/components/DescendantsPageList.tsx
  73. 4 4
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx
  74. 72 36
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  75. 4 1
      apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx
  76. 16 19
      apps/app/src/client/components/DuplicatedPathsTable.tsx
  77. 3 5
      apps/app/src/client/components/EmptyTrashButton.tsx
  78. 25 16
      apps/app/src/client/components/EmptyTrashModal/EmptyTrashModal.tsx
  79. 4 1
      apps/app/src/client/components/EmptyTrashModal/dynamic.tsx
  80. 4 5
      apps/app/src/client/components/ErrorBoudary.jsx
  81. 6 6
      apps/app/src/client/components/ExpandOrContractButton.tsx
  82. 11 6
      apps/app/src/client/components/ForbiddenPage.tsx
  83. 10 6
      apps/app/src/client/components/FormattedDistanceDate.jsx
  84. 48 22
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx
  85. 18 9
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/dynamic.tsx
  86. 22 27
      apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx
  87. 5 4
      apps/app/src/client/components/Hotkeys/HotkeysManager.jsx
  88. 0 2
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx
  89. 13 12
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  90. 4 3
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  91. 7 5
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx
  92. 14 3
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  93. 0 2
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  94. 3 6
      apps/app/src/client/components/Icons/FolderIcon.tsx
  95. 1 2
      apps/app/src/client/components/Icons/RecentlyCreatedIcon.tsx
  96. 25 16
      apps/app/src/client/components/IdenticalPathPage.tsx
  97. 51 31
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  98. 14 12
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  99. 8 9
      apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx
  100. 105 80
      apps/app/src/client/components/InAppNotification/InAppNotificationPage.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

+ 105 - 0
.serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md

@@ -0,0 +1,105 @@
+# PagePathNav と SubNavigation の z-index レイヤリング
+
+## 概要
+
+PagePathNav(ページパス表示)と GrowiContextualSubNavigation(PageControls等を含むサブナビゲーション)の
+Sticky 状態における z-index の重なり順を修正した際の知見。
+
+## 修正したバグ
+
+### 症状
+スクロールしていって PagePathNav がウィンドウ上端に近づいたときに、PageControls のボタンが
+PagePathNav の要素の裏側に回ってしまい、クリックできなくなる。
+
+### 原因
+z-index 的に以下のように重なっていたため:
+
+**[Before]** 下層から順に:
+1. PageView の children - z-0
+2. ( GroundGlassBar = PageControls ) ← 同じ層 z-1
+3. PagePathNav
+
+PageControls が PagePathNav より下層にいたため、sticky 境界付近でクリック不能になっていた。
+
+## 修正後の構成
+
+**[After]** 下層から順に:
+1. PageView の children - z-0
+2. GroundGlassBar(磨りガラス背景)- z-1
+3. PagePathNav - z-2(通常時)/ z-3(sticky時)
+4. PageControls(nav要素)- z-3
+
+### ファイル構成
+
+- `GrowiContextualSubNavigation.tsx` - GroundGlassBar を分離してレンダリング
+  - 1つ目: GroundGlassBar のみ(`position-fixed`, `z-1`)
+  - 2つ目: nav 要素(`z-3`)
+- `PagePathNavSticky.tsx` - z-index を動的に切り替え
+  - 通常時: `z-2`
+  - sticky時: `z-3`
+
+## 実装のポイント
+
+### GroundGlassBar を分離した理由
+GroundGlassBar を `position-fixed` で常に固定表示にすることで、
+PageControls と切り離して独立した z-index 層として扱えるようにした。
+
+これにより、GroundGlassBar → PagePathNav → PageControls という
+理想的なレイヤー構造を実現できた。
+
+## CopyDropdown が z-2 で動作しない理由(解決済み)
+
+### 問題
+
+`PagePathNavSticky.tsx` の sticky 時の z-index について:
+
+```tsx
+// これだと CopyDropdown(マウスオーバーで表示されるドロップダウン)が出ない
+innerActiveClass="active z-2 mt-1"
+
+// これだと正常に動作する
+innerActiveClass="active z-3 mt-1"
+```
+
+### 原因
+
+1. `GrowiContextualSubNavigation` の sticky-inner-wrapper は `z-3` かつ横幅いっぱい(Flex アイテム)
+2. この要素が PagePathNavSticky(`z-2`)の上に重なる
+3. CopyDropdown は `.grw-page-path-nav-layout:hover` で `visibility: visible` になる仕組み
+   (参照: `PagePathNavLayout.module.scss`)
+4. **z-3 の要素が上に被さっているため、hover イベントが PagePathNavSticky に届かない**
+5. 結果、CopyDropdown のアイコンが表示されない
+
+### なぜ z-3 で動作するか
+
+- 同じ z-index: 3 になるため、DOM 順序で前後が決まる
+- PagePathNavSticky は GrowiContextualSubNavigation より後にレンダリングされるため前面に来る
+- hover イベントが正常に届き、CopyDropdown が表示される
+
+### 結論
+
+PagePathNavSticky の sticky 時の z-index は `z-3` である必要がある。
+これは GrowiContextualSubNavigation と同じ層に置くことで、DOM 順序による前後関係を利用するため。
+
+## 関連ファイル
+
+- `apps/app/src/client/components/PageView/PageView.tsx`
+- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx`
+- `apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss`
+- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx`
+- `apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.module.scss`
+- `apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx`(CopyDropdown を含む)
+
+## ライブラリの注意事項
+
+### react-stickynode の deprecation
+`react-stickynode` は **2025-12-31 で deprecated** となる予定。
+https://github.com/yahoo/react-stickynode
+
+将来的には CSS `position: sticky` + `IntersectionObserver` への移行を検討する必要がある。
+
+## 注意事項
+
+- z-index の値を変更する際は、上記のレイヤー構造を壊さないよう注意
+- Sticky コンポーネントの `innerActiveClass` で z-index を指定する際、
+  他のコンポーネントとの相互作用を確認すること

+ 50 - 1
CHANGELOG.md

@@ -1,9 +1,58 @@
 # Changelog
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.9...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.1...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v7.4.1](https://github.com/growilabs/compare/v7.4.0...v7.4.1) - 2025-12-26
+
+### 🚀 Improvement
+
+* imprv: Show page name and link for affected pages in Activity Log (#10590) @arvid-e
+
+### 🧰 Maintenance
+
+* support: Update terraform settings and the policy for OIDC GitHub (#10653) @yuki-takei
+
+## [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

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

@@ -37,6 +37,55 @@ module.exports = {
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
+    'src/client/components/DescendantsPageListModal/**',
+    'src/client/components/ItemsTree/**',
+    'src/client/components/LoginForm/**',
+    'src/client/components/Page/**',
+    'src/client/components/PageAttachment/**',
+    'src/client/components/PageDeleteModal/**',
+    'src/client/components/PageDuplicateModal/**',
+    'src/client/components/PageList/**',
+    'src/client/components/PageManagement/**',
+    'src/client/components/PagePathNavSticky/**',
+    'src/client/components/PagePresentationModal/**',
+    'src/client/components/PageRenameModal/**',
+    'src/client/components/PageSelectModal/**',
+    'src/client/components/PageSideContents/**',
+    'src/client/components/*.tsx',
+    'src/client/components/*.jsx',
+    'src/client/components/*.ts',
+    'src/client/components/*.js',
+    'src/client/components/Me/**',
+    'src/client/components/Bookmarks/**',
+    'src/client/components/InAppNotification/**',
+    'src/client/components/PageTags/**',
+    'src/client/components/ReactMarkdownComponents/**',
+    'src/client/components/AuthorInfo/**',
+    'src/client/components/Common/**',
+    'src/client/components/CreateTemplateModal/**',
+    'src/client/components/CustomNavigation/**',
+    'src/client/components/DeleteBookmarkFolderModal/**',
+    'src/client/components/EmptyTrashModal/**',
+    'src/client/components/GrantedGroupsInheritanceSelectModal/**',
+    'src/client/components/Icons/**',
+    'src/client/components/Maintenance/**',
+    'src/client/components/PageControls/**',
+    'src/client/components/PageComment/**',
+    'src/client/components/PageAccessoriesModal/**',
+    'src/client/components/PageHistory/**',
+    'src/client/components/Presentation/**',
+    'src/client/components/PutbackPageModal/**',
+    'src/client/components/RecentActivity/**',
+    'src/client/components/RecentCreated/**',
+    'src/client/components/RevisionComparer/**',
+    'src/client/components/ShortcutsModal/**',
+    'src/client/components/StaffCredit/**',
+    'src/client/components/TemplateModal/**',
+    'src/client/components/PageEditor/**',
+    'src/client/components/Hotkeys/**',
+    'src/client/components/Navbar/**',
+    'src/client/components/PageHeader/**',
+    'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',
     'src/stores/**',

+ 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


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

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

+ 1 - 1
apps/app/docker/README.md

@@ -10,7 +10,7 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.4.0`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.0/apps/app/docker/Dockerfile)
+* [`7.4.1`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.1/apps/app/docker/Dockerfile)
 * [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
 * [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 

+ 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}:*"
+    ]
+  }
 }

+ 4 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.0-RC.0",
+  "version": "7.4.2-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -173,7 +173,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.32",
+    "next": "^14.2.35",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^1.0.7",
@@ -191,8 +191,9 @@
     "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",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",

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

+ 45 - 36
apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx

@@ -1,17 +1,19 @@
 import React, { type JSX } from 'react';
-
+import Link from 'next/link';
 import type { IUserHasId } from '@growi/core';
-import { isPopulated, type IUser, type Ref } from '@growi/core';
+import { type IUser, isPopulated, type Ref } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-
 
 import styles from './AuthorInfo.module.scss';
 
-const UserLabel = ({ user }: { user: IUserHasId | Ref<IUser> }): JSX.Element => {
+const UserLabel = ({
+  user,
+}: {
+  user: IUserHasId | Ref<IUser>;
+}): JSX.Element => {
   if (isPopulated(user)) {
     return (
       <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>
@@ -23,44 +25,47 @@ const UserLabel = ({ user }: { user: IUserHasId | Ref<IUser> }): JSX.Element =>
   return <i>(anyone)</i>;
 };
 
-
 type AuthorInfoProps = {
-  date: Date,
-  user?: IUserHasId | Ref<IUser>,
-  mode: 'create' | 'update',
-  locate: 'pageSide' | 'footer',
-}
+  date: Date;
+  user?: IUserHasId | Ref<IUser>;
+  mode: 'create' | 'update';
+  locate: 'pageSide' | 'footer';
+};
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const { t } = useTranslation();
-  const {
-    date, user, mode = 'create', locate = 'pageSide',
-  } = props;
+  const { date, user, mode = 'create', locate = 'pageSide' } = props;
 
   const formatType = 'yyyy/MM/dd HH:mm';
 
-  const infoLabelForPageSide = mode === 'create'
-    ? t('author_info.created_by')
-    : t('author_info.updated_by');
-  const nullinfoLabelForFooter = mode === 'create'
-    ? 'Created by'
-    : 'Updated by';
-  const infoLabelForFooter = mode === 'create'
-    ? t('author_info.created_at')
-    : t('author_info.last_revision_posted_at');
-  const userLabel = user != null
-    ? (
-      <UserLabel user={user} />
-    )
-    : <i>Unknown</i>;
+  const infoLabelForPageSide =
+    mode === 'create'
+      ? t('author_info.created_by')
+      : t('author_info.updated_by');
+  const nullinfoLabelForFooter =
+    mode === 'create' ? 'Created by' : 'Updated by';
+  const infoLabelForFooter =
+    mode === 'create'
+      ? t('author_info.created_at')
+      : t('author_info.last_revision_posted_at');
+  const userLabel = user != null ? <UserLabel user={user} /> : <i>Unknown</i>;
 
   if (locate === 'footer') {
     try {
-      return <p>{infoLabelForFooter} {format(new Date(date), formatType)} by <UserPicture user={user} size="sm" /> {userLabel}</p>;
-    }
-    catch (err) {
+      return (
+        <p>
+          {infoLabelForFooter} {format(new Date(date), formatType)} by{' '}
+          <UserPicture user={user} size="sm" /> {userLabel}
+        </p>
+      );
+    } catch (err) {
       if (err instanceof RangeError) {
-        return <p>{nullinfoLabelForFooter} <UserPicture user={user} size="sm" /> {userLabel}</p>;
+        return (
+          <p>
+            {nullinfoLabelForFooter} <UserPicture user={user} size="sm" />{' '}
+            {userLabel}
+          </p>
+        );
       }
       return <></>;
     }
@@ -69,19 +74,23 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const renderParsedDate = () => {
     try {
       return format(new Date(date), formatType);
-    }
-    catch (err) {
+    } catch (err) {
       return '';
     }
   };
 
   return (
-    <div className={`grw-author-info ${styles['grw-author-info']} d-flex align-items-center mb-2`}>
+    <div
+      className={`grw-author-info ${styles['grw-author-info']} d-flex align-items-center mb-2`}
+    >
       <div className="me-2 d-none d-lg-block">
         <UserPicture user={user} size="sm" />
       </div>
       <div>
-        <div className="text-secondary mb-1">{infoLabelForPageSide} <br className="d-lg-none" />{userLabel}</div>
+        <div className="text-secondary mb-1">
+          {infoLabelForPageSide} <br className="d-lg-none" />
+          {userLabel}
+        </div>
         <div className="text-secondary text-date" data-vrt-blackout-datetime>
           {renderParsedDate()}
         </div>

+ 224 - 147
apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx

@@ -1,15 +1,21 @@
 import type { FC } from 'react';
 import { useCallback, useState } from 'react';
-
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
-  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+  addBookmarkToFolder,
+  addNewFolder,
+  hasChildren,
+  updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  DragItemDataType,
+  DragItemType,
+} from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
@@ -20,28 +26,43 @@ import { BookmarkItem } from './BookmarkItem';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type BookmarkFolderItemProps = {
-  isReadOnlyUser: boolean
-  bookmarkFolder: BookmarkFolderItems
-  isOpen?: boolean
-  isOperable: boolean,
-  level: number
-  root: string
-  isUserHomepage?: boolean
-  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
-  bookmarkFolderTreeMutation: () => void
-}
-
-export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkFolderItemProps) => {
+  isReadOnlyUser: boolean;
+  bookmarkFolder: BookmarkFolderItems;
+  isOpen?: boolean;
+  isOperable: boolean;
+  level: number;
+  root: string;
+  isUserHomepage?: boolean;
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void;
+  bookmarkFolderTreeMutation: () => void;
+};
 
+export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (
+  props: BookmarkFolderItemProps,
+) => {
   const BASE_FOLDER_PADDING = 15;
-  const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
+  const acceptedTypes: DragItemType[] = [
+    DRAG_ITEM_TYPE.FOLDER,
+    DRAG_ITEM_TYPE.BOOKMARK,
+  ];
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
-    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    bookmarkFolder,
+    isOpen: _isOpen = false,
+    isOperable,
+    level,
+    root,
+    isUserHomepage,
+    onClickDeleteMenuItemHandler,
+    bookmarkFolderTreeMutation,
   } = props;
 
   const {
-    name, _id: folderId, childFolder, parent, bookmarks,
+    name,
+    _id: folderId,
+    childFolder,
+    parent,
+    bookmarks,
   } = bookmarkFolder;
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -49,13 +70,14 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
-  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
+  const { open: openDeleteBookmarkFolderModal } =
+    useDeleteBookmarkFolderModalActions();
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
-  const loadChildFolder = useCallback(async() => {
+  const loadChildFolder = useCallback(async () => {
     setIsOpen(!isOpen);
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
@@ -66,95 +88,127 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
 
   // Rename for bookmark folder handler
-  const rename = useCallback(async(folderName: string) => {
-    if (folderName.trim() === '') {
-      return cancel();
-    }
+  const rename = useCallback(
+    async (folderName: string) => {
+      if (folderName.trim() === '') {
+        return cancel();
+      }
 
-    try {
-      // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName.trim(), parent as any, childFolder);
-      bookmarkFolderTreeMutation();
-      setIsRenameAction(false);
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent]);
+      try {
+        // TODO: do not use any type
+        await updateBookmarkFolder(
+          folderId,
+          folderName.trim(),
+          parent as any,
+          childFolder,
+        );
+        bookmarkFolderTreeMutation();
+        setIsRenameAction(false);
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [bookmarkFolderTreeMutation, cancel, childFolder, folderId, parent],
+  );
 
   // Create new folder / subfolder handler
-  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(), targetFolder);
-      setIsOpen(true);
-      setIsCreateAction(false);
-      bookmarkFolderTreeMutation();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolderTreeMutation, cancel, targetFolder]);
+      try {
+        await addNewFolder(folderName.trim(), targetFolder);
+        setIsOpen(true);
+        setIsCreateAction(false);
+        bookmarkFolderTreeMutation();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [bookmarkFolderTreeMutation, cancel, targetFolder],
+  );
 
-  const onClickPlusButton = useCallback(async(e) => {
-    e.stopPropagation();
-    if (!isOpen && childrenExists) {
-      setIsOpen(true);
-    }
-    setIsCreateAction(true);
-  }, [childrenExists, isOpen]);
+  const onClickPlusButton = useCallback(
+    async (e) => {
+      e.stopPropagation();
+      if (!isOpen && childrenExists) {
+        setIsOpen(true);
+      }
+      setIsCreateAction(true);
+    },
+    [childrenExists, isOpen],
+  );
 
-  const itemDropHandler = async(item: DragItemDataType, dragItemType: string | symbol | null) => {
+  const itemDropHandler = async (
+    item: DragItemDataType,
+    dragItemType: string | symbol | null,
+  ) => {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
+          await updateBookmarkFolder(
+            item.bookmarkFolder._id,
+            item.bookmarkFolder.name,
+            bookmarkFolder._id,
+            item.bookmarkFolder.childFolder,
+          );
           bookmarkFolderTreeMutation();
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
-    }
-    else {
+    } else {
       try {
         if (item != null) {
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           bookmarkFolderTreeMutation();
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     }
   };
 
-  const isDropable = (item: DragItemDataType, type: string | null | symbol): boolean => {
+  const isDropable = (
+    item: DragItemDataType,
+    type: string | null | symbol,
+  ): boolean => {
     if (type === DRAG_ITEM_TYPE.FOLDER) {
-      if (item.bookmarkFolder.parent === bookmarkFolder._id || item.bookmarkFolder._id === bookmarkFolder._id) {
+      if (
+        item.bookmarkFolder.parent === bookmarkFolder._id ||
+        item.bookmarkFolder._id === bookmarkFolder._id
+      ) {
         return false;
       }
 
       // Maximum folder hierarchy of 2 levels
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
-      if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
+      if (
+        item.bookmarkFolder.childFolder.length !== 0 ||
+        bookmarkFolder.parent != null
+      ) {
         return false;
       }
 
       return item.root !== root || item.level >= level;
     }
 
-    if (item.parentFolder != null && item.parentFolder._id === bookmarkFolder._id) {
+    if (
+      item.parentFolder != null &&
+      item.parentFolder._id === bookmarkFolder._id
+    ) {
       return false;
     }
     return true;
   };
 
-  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+  const triangleBtnClassName = (
+    isOpen: boolean,
+    childrenExists: boolean,
+  ): string => {
     if (!childrenExists) {
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
     }
@@ -162,41 +216,47 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   };
 
   const renderChildFolder = () => {
-    return isOpen && childFolder?.map((childFolder) => {
-      return (
-        <div key={childFolder._id} className="grw-foldertree-item-children">
-          <BookmarkFolderItem
-            key={childFolder._id}
+    return (
+      isOpen &&
+      childFolder?.map((childFolder) => {
+        return (
+          <div key={childFolder._id} className="grw-foldertree-item-children">
+            <BookmarkFolderItem
+              key={childFolder._id}
+              isReadOnlyUser={isReadOnlyUser}
+              isOperable={props.isOperable}
+              bookmarkFolder={childFolder}
+              level={level + 1}
+              root={root}
+              isUserHomepage={isUserHomepage}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
+              bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
+            />
+          </div>
+        );
+      })
+    );
+  };
+
+  const renderBookmarkItem = () => {
+    return (
+      isOpen &&
+      bookmarks?.map((bookmark) => {
+        return (
+          <BookmarkItem
+            key={bookmark._id}
             isReadOnlyUser={isReadOnlyUser}
             isOperable={props.isOperable}
-            bookmarkFolder={childFolder}
+            bookmarkedPage={bookmark.page}
             level={level + 1}
-            root={root}
-            isUserHomepage={isUserHomepage}
+            parentFolder={bookmarkFolder}
+            canMoveToRoot
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
-        </div>
-      );
-    });
-  };
-
-  const renderBookmarkItem = () => {
-    return isOpen && bookmarks?.map((bookmark) => {
-      return (
-        <BookmarkItem
-          key={bookmark._id}
-          isReadOnlyUser={isReadOnlyUser}
-          isOperable={props.isOperable}
-          bookmarkedPage={bookmark.page}
-          level={level + 1}
-          parentFolder={bookmarkFolder}
-          canMoveToRoot
-          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
-          bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
-        />
-      );
-    });
+        );
+      })
+    );
   };
 
   const onClickRenameHandler = useCallback(() => {
@@ -204,7 +264,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
 
   const onClickDeleteHandler = useCallback(() => {
-    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (
+      folderId,
+    ) => {
       if (typeof folderId !== 'string') {
         return;
       }
@@ -214,21 +276,39 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (bookmarkFolder == null) {
       return;
     }
-    openDeleteBookmarkFolderModal(bookmarkFolder, { onDeleted: bookmarkFolderDeleteHandler });
-  }, [bookmarkFolder, bookmarkFolderTreeMutation, openDeleteBookmarkFolderModal]);
-
-  const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
-    try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
-      bookmarkFolderTreeMutation();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+    openDeleteBookmarkFolderModal(bookmarkFolder, {
+      onDeleted: bookmarkFolderDeleteHandler,
+    });
+  }, [
+    bookmarkFolder,
+    bookmarkFolderTreeMutation,
+    openDeleteBookmarkFolderModal,
+  ]);
 
+  const onClickMoveToRootHandlerForBookmarkFolderItemControl =
+    useCallback(async () => {
+      try {
+        await updateBookmarkFolder(
+          bookmarkFolder._id,
+          bookmarkFolder.name,
+          null,
+          bookmarkFolder.childFolder,
+        );
+        bookmarkFolderTreeMutation();
+      } catch (err) {
+        toastError(err);
+      }
+    }, [
+      bookmarkFolder._id,
+      bookmarkFolder.childFolder,
+      bookmarkFolder.name,
+      bookmarkFolderTreeMutation,
+    ]);
   return (
-    <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
+    <div
+      id={`grw-bookmark-folder-item-${folderId}`}
+      className="grw-foldertree-item-container"
+    >
       <DragAndDropWrapper
         key={folderId}
         type={acceptedTypes}
@@ -240,23 +320,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       >
         <li
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
-          onClick={loadChildFolder}
           style={{ paddingLeft }}
         >
-          <div className="grw-triangle-container d-flex justify-content-center">
-            <button
-              type="button"
-              className={triangleBtnClassName(isOpen, childrenExists)}
-              onClick={loadChildFolder}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined fs-5">arrow_right</span>
-              </div>
-            </button>
-          </div>
-          <div>
-            <FolderIcon isOpen={isOpen} />
-          </div>
           {isRenameAction ? (
             <div className="flex-fill">
               <BookmarkFolderNameInput
@@ -266,27 +331,46 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               />
             </div>
           ) : (
-            <>
+            <button
+              type="button"
+              className="d-flex align-items-center flex-fill border-0 bg-transparent p-0 text-start"
+              onClick={loadChildFolder}
+            >
+              <div className="grw-triangle-container d-flex justify-content-center">
+                <span className={triangleBtnClassName(isOpen, childrenExists)}>
+                  <span className="material-symbols-outlined fs-5">
+                    arrow_right
+                  </span>
+                </span>
+              </div>
+              <div>
+                <FolderIcon isOpen={isOpen} />
+              </div>
               <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto">{name}</p>
               </div>
-            </>
+            </button>
           )}
           {isOperable && (
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}
                 onClickDelete={onClickDeleteHandler}
-                onClickMoveToRoot={bookmarkFolder.parent != null
-                  ? onClickMoveToRootHandlerForBookmarkFolderItemControl
-                  : undefined
+                onClickMoveToRoot={
+                  bookmarkFolder.parent != null
+                    ? onClickMoveToRootHandlerForBookmarkFolderItemControl
+                    : undefined
                 }
               >
-                <div onClick={e => e.stopPropagation()}>
-                  <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
-                    <span className="material-symbols-outlined">more_vert</span>
-                  </DropdownToggle>
-                </div>
+                <DropdownToggle
+                  color="transparent"
+                  className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1"
+                  onClick={(event) => {
+                    event.stopPropagation();
+                  }}
+                >
+                  <span className="material-symbols-outlined">more_vert</span>
+                </DropdownToggle>
               </BookmarkFolderItemControl>
               {/* Maximum folder hierarchy of 2 levels */}
               {!(bookmarkFolder.parent != null) && (
@@ -304,17 +388,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
       </DragAndDropWrapper>
       {isCreateAction && (
-        <BookmarkFolderNameInput
-          onSubmit={create}
-          onCancel={cancel}
-        />
+        <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
       )}
-      {
-        renderChildFolder()
-      }
-      {
-        renderBookmarkItem()
-      }
+      {renderChildFolder()}
+      {renderBookmarkItem()}
     </div>
   );
 };

+ 27 - 19
apps/app/src/client/components/Bookmarks/BookmarkFolderItemControl.tsx

@@ -1,15 +1,17 @@
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import {
-  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 export const BookmarkFolderItemControl: React.FC<{
-  children?: React.ReactNode
-  onClickMoveToRoot?: () => Promise<void>
-  onClickRename: () => void
-  onClickDelete: () => void
+  children?: React.ReactNode;
+  onClickMoveToRoot?: () => Promise<void>;
+  onClickRename: () => void;
+  onClickDelete: () => void;
 }> = ({
   children,
   onClickMoveToRoot,
@@ -21,23 +23,25 @@ export const BookmarkFolderItemControl: React.FC<{
 
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
-      { children ?? (
-        <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+      {children ?? (
+        <DropdownToggle
+          color="transparent"
+          className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center"
+        >
           <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
-      ) }
+      )}
 
-      { isOpen && (
-        <DropdownMenu
-          container="body"
-          style={{ zIndex: 1055 }}
-        >
+      {isOpen && (
+        <DropdownMenu container="body" style={{ zIndex: 1055 }}>
           {onClickMoveToRoot && (
             <DropdownItem
               onClick={onClickMoveToRoot}
               className="grw-page-control-dropdown-item"
             >
-              <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+              <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+                bookmark
+              </span>
               {t('bookmark_folder.move_to_root')}
             </DropdownItem>
           )}
@@ -45,7 +49,9 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickRename}
             className="grw-page-control-dropdown-item"
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              redo
+            </span>
             {t('Rename')}
           </DropdownItem>
 
@@ -55,11 +61,13 @@ export const BookmarkFolderItemControl: React.FC<{
             className="pt-2 grw-page-control-dropdown-item text-danger"
             onClick={onClickDelete}
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              delete
+            </span>
             {t('Delete')}
           </DropdownItem>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
   );
 };

+ 85 - 60
apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -1,11 +1,11 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
-import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
+import {
+  addBookmarkToFolder,
+  toggleBookmark,
+} from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -17,43 +17,45 @@ import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 import styles from './BookmarkFolderMenu.module.scss';
 
 type BookmarkFolderMenuProps = {
-  isOpen: boolean,
-  pageId: string,
-  isBookmarked: boolean,
-  onToggle?: () => void,
-  onUnbookmark?: () => void,
-  children?: React.ReactNode,
-}
-
-export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element => {
-  const {
-    isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children,
-  } = props;
+  isOpen: boolean;
+  pageId: string;
+  isBookmarked: boolean;
+  onToggle?: () => void;
+  onUnbookmark?: () => void;
+  children?: React.ReactNode;
+};
+
+export const BookmarkFolderMenu = (
+  props: BookmarkFolderMenuProps,
+): JSX.Element => {
+  const { isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children } =
+    props;
 
   const { t } = useTranslation();
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
 
   const currentUser = useCurrentUser();
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } =
+    useSWRxBookmarkFolderAndChild(currentUser?._id);
 
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
   }, [bookmarkFolders]);
 
-  const toggleBookmarkHandler = useCallback(async() => {
+  const toggleBookmarkHandler = useCallback(async () => {
     try {
       await toggleBookmark(pageId, isBookmarked);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [isBookmarked, pageId]);
 
-  const onUnbookmarkHandler = useCallback(async() => {
+  const onUnbookmarkHandler = useCallback(async () => {
     if (onUnbookmark != null) {
       onUnbookmark();
     }
@@ -62,9 +64,15 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
     mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutatePageInfo();
-  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
-
-  const toggleHandler = useCallback(async() => {
+  }, [
+    onUnbookmark,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutateBookmarkFolders,
+    mutatePageInfo,
+  ]);
+
+  const toggleHandler = useCallback(async () => {
     // on close
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
@@ -89,29 +97,48 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
         await toggleBookmarkHandler();
         mutateCurrentUserBookmarks();
         mutatePageInfo();
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     }
-  },
-  [isOpen, bookmarkFolders, onToggle, selectedItem, isBookmarked, pageId, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutatePageInfo]);
-
-  const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
-    e.stopPropagation();
-
-    setSelectedItem(itemId);
+  }, [
+    isOpen,
+    bookmarkFolders,
+    onToggle,
+    selectedItem,
+    isBookmarked,
+    pageId,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutatePageInfo,
+  ]);
+
+  const onMenuItemClickHandler = useCallback(
+    async (e, itemId: string) => {
+      e.stopPropagation();
+
+      setSelectedItem(itemId);
 
-    try {
-      await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
-      mutateCurrentUserBookmarks();
-      mutateBookmarkFolders();
-      mutatePageInfo();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
+      try {
+        await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
+        mutateCurrentUserBookmarks();
+        mutateBookmarkFolders();
+        mutatePageInfo();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo],
+  );
+  const onMenuItemKeyDownHandler = useCallback(
+    (itemId: string) => (event: React.KeyboardEvent<HTMLDivElement>) => {
+      if (event.key === 'Enter' || event.key === ' ') {
+        event.preventDefault();
+        onMenuItemClickHandler(event, itemId);
+      }
+    },
+    [onMenuItemClickHandler],
+  );
 
   const renderBookmarkMenuItem = () => {
     return (
@@ -122,9 +149,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
         >
           <span className="material-symbols-outlined">bookmark</span>{' '}
-          <span className="mx-2">
-            {t('bookmark_folder.cancel_bookmark')}
-          </span>
+          <span className="mx-2">{t('bookmark_folder.cancel_bookmark')}</span>
         </DropdownItem>
 
         {isBookmarkFolderExists && (
@@ -135,7 +160,8 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action px-4"
                 tabIndex={0}
                 role="menuitem"
-                onClick={e => onMenuItemClickHandler(e, 'root')}
+                onClick={(e) => onMenuItemClickHandler(e, 'root')}
+                onKeyDown={onMenuItemKeyDownHandler('root')}
               >
                 <BookmarkFolderMenuItem
                   itemId="root"
@@ -144,13 +170,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 />
               </div>
             </div>
-            {bookmarkFolders?.map(folder => (
+            {bookmarkFolders?.map((folder) => (
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   tabIndex={0}
                   role="menuitem"
-                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  onClick={(e) => onMenuItemClickHandler(e, folder._id)}
+                  onKeyDown={onMenuItemKeyDownHandler(folder._id)}
                 >
                   <BookmarkFolderMenuItem
                     itemId={folder._id}
@@ -158,13 +185,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                   />
                 </div>
-                {folder.childFolder?.map(child => (
+                {folder.childFolder?.map((child) => (
                   <div key={child._id}>
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       tabIndex={0}
                       role="menuitem"
-                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                      onClick={(e) => onMenuItemClickHandler(e, child._id)}
+                      onKeyDown={onMenuItemKeyDownHandler(child._id)}
                     >
                       <BookmarkFolderMenuItem
                         itemId={child._id}
@@ -183,13 +211,10 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
   };
 
   return (
-    <UncontrolledDropdown
-      isOpen={isOpen}
-      onToggle={toggleHandler}
-    >
+    <UncontrolledDropdown isOpen={isOpen} onToggle={toggleHandler}>
       {children}
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu
           end
           persist
@@ -197,9 +222,9 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           container="body"
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
         >
-          { renderBookmarkMenuItem() }
+          {renderBookmarkMenuItem()}
         </DropdownMenu>
-      ) }
+      )}
     </UncontrolledDropdown>
   );
 };

+ 11 - 12
apps/app/src/client/components/Bookmarks/BookmarkFolderMenuItem.tsx

@@ -1,14 +1,10 @@
-import React from 'react';
+import type React from 'react';
 
 export const BookmarkFolderMenuItem: React.FC<{
-  itemId: string
-  itemName: string
-  isSelected: boolean
-}> = ({
-  itemId,
-  itemName,
-  isSelected,
-}) => {
+  itemId: string;
+  itemName: string;
+  isSelected: boolean;
+}> = ({ itemId, itemName, isSelected }) => {
   return (
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
       <input
@@ -16,10 +12,13 @@ export const BookmarkFolderMenuItem: React.FC<{
         checked={isSelected}
         name="bookmark-folder-menu-item"
         id={`bookmark-folder-menu-item-${itemId}`}
-        onChange={e => e.stopPropagation()}
-        onClick={e => e.stopPropagation()}
+        onChange={(e) => e.stopPropagation()}
+        onClick={(e) => e.stopPropagation()}
       />
-      <label htmlFor={`bookmark-folder-menu-item-${itemId}`} className="p-2 m-0 form-label text-truncate">
+      <label
+        htmlFor={`bookmark-folder-menu-item-${itemId}`}
+        className="p-2 m-0 form-label text-truncate"
+      >
         {itemName}
       </label>
     </div>

+ 39 - 19
apps/app/src/client/components/Bookmarks/BookmarkFolderNameInput.tsx

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   const inputValidator = useInputValidator(ValidationTarget.FOLDER);
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
 
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
-  const maxWidth = parentRect != null
-    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
-    : undefined;
+  const maxWidth =
+    parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          parentRect.width,
+          'md',
+          validationResult != null ? false : undefined,
+        )
+      : undefined;
 
   return (
     <div ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         placeholder={t('bookmark_folder.input_placeholder')}
-        aria-describedby={isInvalid ? 'bookmark-folder-name-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-folder-name-input-feedback' : undefined
+        }
         autoFocus
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
       />
-      { isInvalid && (
-        <div id="bookmark-folder-name-input-feedback" className="invalid-feedback d-block my-1">
+      {isInvalid && (
+        <div
+          id="bookmark-folder-name-input-feedback"
+          className="invalid-feedback d-block my-1"
+        >
           {validationResult.message}
         </div>
-      ) }
+      )}
     </div>
   );
 };

+ 66 - 33
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx

@@ -1,9 +1,8 @@
-
-import React, { useCallback } from 'react';
-
+import type React from 'react';
+import { useCallback } from 'react';
+import { useRouter } from 'next/router';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
@@ -13,7 +12,8 @@ import { useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
-  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+  useSWRMUTxCurrentUserBookmarks,
+  useSWRxUserBookmarks,
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
@@ -30,10 +30,10 @@ import styles from './BookmarkFolderTree.module.scss';
 //  } & IPageHasId
 
 type Props = {
-  isUserHomepage?: boolean,
-  userId?: string,
-  isOperable: boolean,
-}
+  isUserHomepage?: boolean;
+  userId?: string;
+  isOperable: boolean;
+};
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { isUserHomepage, userId } = props;
@@ -44,10 +44,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   const isReadOnlyUser = useIsReadOnlyUser();
   const currentPage = useCurrentPageData();
-  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
-  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
-  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { data: bookmarkFolders, mutate: mutateBookmarkFolders } =
+    useSWRxBookmarkFolderAndChild(userId);
+  const { data: userBookmarks, mutate: mutateUserBookmarks } =
+    useSWRxUserBookmarks(userId ?? null);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(
+    currentPage?._id ?? null,
+  );
+  const { trigger: mutateCurrentUserBookmarks } =
+    useSWRMUTxCurrentUserBookmarks();
   const { open: openDeleteModal } = usePageDeleteModalActions();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
@@ -55,20 +60,43 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
     mutateCurrentUserBookmarks();
     mutatePageInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
-
-  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
-    const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') return;
-      toastSuccess(isCompletely ? t('deleted_pages_completely', { path: pathOrPathsToDelete }) : t('deleted_pages', { path: pathOrPathsToDelete }));
-      bookmarkFolderTreeMutation();
-      mutateAllPageInfo();
-      if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
-        router.push(`/trash${currentPage.path}`);
-      }
-    };
-    openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
-  }, [openDeleteModal, t, bookmarkFolderTreeMutation, currentPage?._id, currentPage?.path, router]);
+  }, [
+    mutateBookmarkFolders,
+    mutatePageInfo,
+    mutateCurrentUserBookmarks,
+    mutateUserBookmarks,
+  ]);
+
+  const onClickDeleteMenuItemHandler = useCallback(
+    (pageToDelete: IPageToDeleteWithMeta) => {
+      const pageDeletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        _isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') return;
+        toastSuccess(
+          isCompletely
+            ? t('deleted_pages_completely', { path: pathOrPathsToDelete })
+            : t('deleted_pages', { path: pathOrPathsToDelete }),
+        );
+        bookmarkFolderTreeMutation();
+        mutateAllPageInfo();
+        if (pageToDelete.data._id === currentPage?._id && _isRecursively) {
+          router.push(`/trash${currentPage.path}`);
+        }
+      };
+      openDeleteModal([pageToDelete], { onDeleted: pageDeletedHandler });
+    },
+    [
+      openDeleteModal,
+      t,
+      bookmarkFolderTreeMutation,
+      currentPage?._id,
+      currentPage?.path,
+      router,
+    ],
+  );
 
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -106,9 +134,12 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   return (
     <DndProvider backend={HTML5Backend}>
-
-      <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}>
-        <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}>
+      <div
+        className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}
+      >
+        <ul
+          className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}
+        >
           {bookmarkFolders?.map((bookmarkFolder) => {
             return (
               <BookmarkFolderItem
@@ -125,8 +156,11 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               />
             );
           })}
-          {userBookmarks?.map(userBookmark => (
-            <div key={userBookmark?._id} className="grw-foldertree-item-container grw-root-bookmarks">
+          {userBookmarks?.map((userBookmark) => (
+            <div
+              key={userBookmark?._id}
+              className="grw-foldertree-item-container grw-root-bookmarks"
+            >
               <BookmarkItem
                 isReadOnlyUser={!!isReadOnlyUser}
                 isOperable={props.isOperable}
@@ -156,7 +190,6 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
           </DragAndDropWrapper>
         )} */}
       </div>
-
     </DndProvider>
   );
 };

+ 173 - 114
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,44 +1,48 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
-import nodePath from 'path';
-
-import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/router';
+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';
+import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
-import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
-
+import { DropdownToggle, UncontrolledTooltip } from 'reactstrap';
 
 import { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
+import type {
+  BookmarkFolderItems,
+  DragItemDataType,
+} from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { useFetchCurrentPage } from '~/states/page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/page';
 
-import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
+import {
+  MenuItemType,
+  PageItemControl,
+} from '../Common/Dropdown/PageItemControl';
 import { PageListItemS } from '../PageList/PageListItemS';
-
 import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 type Props = {
-  isReadOnlyUser: boolean
-  isOperable: boolean,
-  bookmarkedPage: IPageHasId | null,
-  level: number,
-  parentFolder: BookmarkFolderItems | null,
-  canMoveToRoot: boolean,
-  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
-  bookmarkFolderTreeMutation: () => void,
-}
+  isReadOnlyUser: boolean;
+  isOperable: boolean;
+  bookmarkedPage: IPageHasId | null;
+  level: number;
+  parentFolder: BookmarkFolderItems | null;
+  canMoveToRoot: boolean;
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void;
+  bookmarkFolderTreeMutation: () => void;
+};
 
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
@@ -48,46 +52,56 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const router = useRouter();
 
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
-    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    isOperable,
+    bookmarkedPage,
+    onClickDeleteMenuItemHandler,
+    parentFolder,
+    level,
+    canMoveToRoot,
+    bookmarkFolderTreeMutation,
   } = props;
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
-  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage?._id);
+  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(
+    bookmarkedPage?._id,
+  );
   const { fetchCurrentPage } = useFetchCurrentPage();
 
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
+  const paddingLeft = BASE_BOOKMARK_PADDING + BASE_FOLDER_PADDING * level;
   const dragItem: Partial<DragItemDataType> = {
-    ...bookmarkedPage, parentFolder,
+    ...bookmarkedPage,
+    parentFolder,
   };
 
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
 
-  const onClickMoveToRootHandler = useCallback(async() => {
+  const onClickMoveToRootHandler = useCallback(async () => {
     if (bookmarkedPageId == null) return;
 
     try {
       await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
-  const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
-    if (shouldBookmark) {
-      await bookmark(pageId);
-    }
-    else {
-      await unbookmark(pageId);
-    }
-    bookmarkFolderTreeMutation();
-    mutatePageInfo();
-  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
+  const bookmarkMenuItemClickHandler = useCallback(
+    async (pageId: string, shouldBookmark: boolean) => {
+      if (shouldBookmark) {
+        await bookmark(pageId);
+      } else {
+        await unbookmark(pageId);
+      }
+      bookmarkFolderTreeMutation();
+      mutatePageInfo();
+    },
+    [bookmarkFolderTreeMutation, mutatePageInfo],
+  );
 
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
@@ -97,57 +111,81 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(false);
   }, []);
 
-  const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPageId == null) return;
-
+  const rename = useCallback(
+    async (inputText: string) => {
+      if (bookmarkedPageId == null) return;
 
-    if (inputText.trim() === '') {
-      return cancel();
-    }
+      if (inputText.trim() === '') {
+        return cancel();
+      }
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
-    const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPagePath) {
-      setRenameInputShown(false);
-      return;
-    }
+      const parentPath = pathUtils.addTrailingSlash(
+        nodePath.dirname(bookmarkedPagePath ?? ''),
+      );
+      const newPagePath = nodePath.resolve(parentPath, inputText.trim());
+      if (newPagePath === bookmarkedPagePath) {
+        setRenameInputShown(false);
+        return;
+      }
 
-    try {
-      setRenameInputShown(false);
-      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
-      bookmarkFolderTreeMutation();
-      mutatePageInfo();
-    }
-    catch (err) {
-      setRenameInputShown(true);
-      toastError(err);
-    }
-  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+      try {
+        setRenameInputShown(false);
+        await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
+        bookmarkFolderTreeMutation();
+        mutatePageInfo();
+      } catch (err) {
+        setRenameInputShown(true);
+        toastError(err);
+      }
+    },
+    [
+      bookmarkedPageId,
+      bookmarkedPagePath,
+      bookmarkedPageRevision,
+      cancel,
+      bookmarkFolderTreeMutation,
+      mutatePageInfo,
+    ],
+  );
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPageId == null) return;
+  const deleteMenuItemClickHandler = useCallback(
+    async (
+      _pageId: string,
+      pageInfo: IPageInfoExt | undefined,
+    ): Promise<void> => {
+      if (bookmarkedPageId == null) return;
 
-    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
-      throw Error('_id and path must not be null.');
-    }
+      if (bookmarkedPageId == null || bookmarkedPagePath == null) {
+        throw Error('_id and path must not be null.');
+      }
 
-    const pageToDelete: IPageToDeleteWithMeta = {
-      data: {
-        _id: bookmarkedPageId,
-        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
-        path: bookmarkedPagePath,
-      },
-      meta: pageInfo,
-    };
+      const pageToDelete: IPageToDeleteWithMeta = {
+        data: {
+          _id: bookmarkedPageId,
+          revision:
+            bookmarkedPageRevision == null
+              ? null
+              : getIdStringForRef(bookmarkedPageRevision),
+          path: bookmarkedPagePath,
+        },
+        meta: pageInfo,
+      };
 
-    onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
+      onClickDeleteMenuItemHandler(pageToDelete);
+    },
+    [
+      bookmarkedPageId,
+      bookmarkedPagePath,
+      bookmarkedPageRevision,
+      onClickDeleteMenuItemHandler,
+    ],
+  );
 
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
 
     const { _id: pageId, path } = bookmarkedPage;
-    const putBackedHandler = async() => {
+    const putBackedHandler = async () => {
       try {
         await unlink(path);
         mutateAllPageInfo();
@@ -155,36 +193,41 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         router.push(`/${pageId}`);
         fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
       }
     };
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
-  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
-
-  const {
-    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
-  } = useMemo(() => {
-    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+  }, [
+    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,
+        };
+      }
 
-    if (bookmarkedPagePath == null) {
+      const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
       return {
-        pageTitle: '',
-        formerPagePath: '',
-        isFormerRoot: false,
+        pageTitle: dPagePath.latter,
+        formerPagePath: dPagePath.former,
+        isFormerRoot: dPagePath.isFormerRoot,
         bookmarkItemId,
       };
-    }
-
-    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
-    return {
-      pageTitle: dPagePath.latter,
-      formerPagePath: dPagePath.former,
-      isFormerRoot: dPagePath.isFormerRoot,
-      bookmarkItemId,
-    };
-  }, [bookmarkedPagePath, bookmarkedPageId]);
+    }, [bookmarkedPagePath, bookmarkedPageId]);
 
   if (bookmarkedPage == null) {
     return <></>;
@@ -202,15 +245,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         style={{ paddingLeft }}
       >
-        { isRenameInputShown
-          ? (
-            <BookmarkItemRenameInput
-              value={nodePath.basename(bookmarkedPage.path ?? '')}
-              onSubmit={rename}
-              onCancel={() => { setRenameInputShown(false) }}
-            />
-          )
-          : <PageListItemS page={bookmarkedPage} pageTitle={pageTitle} isNarrowView />}
+        {isRenameInputShown ? (
+          <BookmarkItemRenameInput
+            value={nodePath.basename(bookmarkedPage.path ?? '')}
+            onSubmit={rename}
+            onCancel={() => {
+              setRenameInputShown(false);
+            }}
+          />
+        ) : (
+          <PageListItemS
+            page={bookmarkedPage}
+            pageTitle={pageTitle}
+            isNarrowView
+          />
+        )}
 
         <div className="grw-foldertree-control">
           <PageItemControl
@@ -224,11 +273,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRevertMenuItem={putBackClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
-              ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler} />
-              : undefined}
+            additionalMenuItemOnTopRenderer={
+              canMoveToRoot
+                ? () => (
+                    <BookmarkMoveToRootBtn
+                      pageId={bookmarkedPage._id}
+                      onClickMoveToRootHandler={onClickMoveToRootHandler}
+                    />
+                  )
+                : undefined
+            }
           >
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1">
+            <DropdownToggle
+              color="transparent"
+              className="border-0 rounded btn-page-item-control p-0 grw-visible-on-hover me-1"
+            >
               <span className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
           </PageItemControl>

+ 39 - 19
apps/app/src/client/components/Bookmarks/BookmarkItemRenameInput.tsx

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '../Common/SubmittableInput';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import {
+  AutosizeSubmittableInput,
+  getAdjustedMaxWidthForAutosizeInput,
+} from '../Common/SubmittableInput';
 import type { SubmittableInputProps } from '../Common/SubmittableInput/types';
 
-
-type Props = Pick<SubmittableInputProps<AutosizeInputProps>, 'value' | 'onSubmit' | 'onCancel'>;
+type Props = Pick<
+  SubmittableInputProps<AutosizeInputProps>,
+  'value' | 'onSubmit' | 'onCancel'
+>;
 
 export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
   const inputValidator = useInputValidator(ValidationTarget.PAGE);
 
-  const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-    const validationResult = inputValidator(e.target.value);
-    setValidationResult(validationResult ?? undefined);
-  }, [inputValidator]);
+  const changeHandler = useCallback(
+    async (e: ChangeEvent<HTMLInputElement>) => {
+      const validationResult = inputValidator(e.target.value);
+      setValidationResult(validationResult ?? undefined);
+    },
+    [inputValidator],
+  );
   const changeHandlerDebounced = debounce(300, changeHandler);
 
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
-  const maxWidth = parentRect != null
-    ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'md', validationResult != null ? false : undefined)
-    : undefined;
+  const maxWidth =
+    parentRect != null
+      ? getAdjustedMaxWidthForAutosizeInput(
+          parentRect.width,
+          'md',
+          validationResult != null ? false : undefined,
+        )
+      : undefined;
 
   return (
     <div className="flex-fill" ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkItemRenameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         placeholder={t('Input page name')}
-        aria-describedby={isInvalid ? 'bookmark-item-rename-input-feedback' : undefined}
+        aria-describedby={
+          isInvalid ? 'bookmark-item-rename-input-feedback' : undefined
+        }
         autoFocus
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
       />
-      { isInvalid && (
-        <div id="bookmark-item-rename-input-feedback" className="invalid-feedback d-block my-1">
+      {isInvalid && (
+        <div
+          id="bookmark-item-rename-input-feedback"
+          className="invalid-feedback d-block my-1"
+        >
           {validationResult.message}
         </div>
-      ) }
+      )}
     </div>
   );
 };

+ 5 - 4
apps/app/src/client/components/Bookmarks/BookmarkMoveToRootBtn.tsx

@@ -1,11 +1,10 @@
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 
 export const BookmarkMoveToRootBtn: React.FC<{
-  pageId: string
-  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+  pageId: string;
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>;
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
   const { t } = useTranslation();
 
@@ -14,7 +13,9 @@ export const BookmarkMoveToRootBtn: React.FC<{
       onClick={() => onClickMoveToRootHandler(pageId)}
       className="grw-page-control-dropdown-item"
     >
-      <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
+      <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+        bookmark
+      </span>
       {t('bookmark_folder.move_to_root')}
     </DropdownItem>
   );

+ 33 - 21
apps/app/src/client/components/Bookmarks/DragAndDropWrapper.tsx

@@ -1,33 +1,44 @@
-import type { ReactNode, JSX } from 'react';
-
+import type { JSX, ReactNode } from 'react';
 import { useDrag, useDrop } from 'react-dnd';
 
 import type { DragItemDataType } from '~/interfaces/bookmark-info';
 
 type DragAndDropWrapperProps = {
-  item?: Partial<DragItemDataType>
-  type: string[]
-  children: ReactNode
-  useDragMode?: boolean
-  useDropMode?: boolean
-  onDropItem?:(item: DragItemDataType, type: string | null | symbol) => Promise<void>
-  isDropable?:(item: Partial<DragItemDataType>, type: string | null | symbol) => boolean
-}
+  item?: Partial<DragItemDataType>;
+  type: string[];
+  children: ReactNode;
+  useDragMode?: boolean;
+  useDropMode?: boolean;
+  onDropItem?: (
+    item: DragItemDataType,
+    type: string | null | symbol,
+  ) => Promise<void>;
+  isDropable?: (
+    item: Partial<DragItemDataType>,
+    type: string | null | symbol,
+  ) => boolean;
+};
 
-export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element => {
+export const DragAndDropWrapper = (
+  props: DragAndDropWrapperProps,
+): JSX.Element => {
   const {
-    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+    item,
+    children,
+    useDragMode,
+    useDropMode,
+    type,
+    onDropItem,
+    isDropable,
   } = props;
 
-
   const acceptedTypes = type;
   const sourcetype: string | symbol = type[0];
 
-
   const [, dragRef] = useDrag({
     type: sourcetype,
     item,
-    collect: monitor => ({
+    collect: (monitor) => ({
       isDragging: monitor.isDragging(),
       canDrag: monitor.canDrag(),
     }),
@@ -48,7 +59,7 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
       }
       return false;
     },
-    collect: monitor => ({
+    collect: (monitor) => ({
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
     }),
   }));
@@ -57,17 +68,18 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
     if (useDragMode && useDropMode) {
       dragRef(c);
       dropRef(c);
-    }
-    else if (useDragMode) {
+    } else if (useDragMode) {
       dragRef(c);
-    }
-    else if (useDropMode) {
+    } else if (useDropMode) {
       dropRef(c);
     }
   };
 
   return (
-    <div ref={getCallback} className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}>
+    <div
+      ref={getCallback}
+      className={`grw-drag-drop-container ${isOver ? 'grw-accept-drop-item' : ''}`}
+    >
       {children}
     </div>
   );

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

+ 60 - 27
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx

@@ -1,12 +1,18 @@
 import React, {
-  useState, useMemo, useCallback, type ReactNode, type CSSProperties,
+  type CSSProperties,
+  type ReactNode,
+  useCallback,
+  useMemo,
+  useState,
 } from 'react';
-
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
   Tooltip,
 } from 'reactstrap';
 
@@ -33,20 +39,28 @@ interface CopyDropdownProps {
 
 /* eslint-disable react/prop-types */
 const DropdownItemContents: React.FC<DropdownItemContentsProps> = ({
-  title, contents, className = '', style,
+  title,
+  contents,
+  className = '',
+  style,
 }) => (
   <>
-    <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className={`card mb-1 p-2 ${className}`} style={style}>{contents}</div>
+    <div className="h6 mt-1 mb-2">
+      <strong>{title}</strong>
+    </div>
+    <div className={`card mb-1 p-2 ${className}`} style={style}>
+      {contents}
+    </div>
   </>
 );
 /* eslint-enable react/prop-types */
 
-
 export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
   const [dropdownOpen, setDropdownOpen] = useState(false);
   const [tooltipOpen, setTooltipOpen] = useState(false);
-  const [isParamsAppended, setParamsAppended] = useState(!props.isShareLinkMode);
+  const [isParamsAppended, setParamsAppended] = useState(
+    !props.isShareLinkMode,
+  );
 
   /*
    * functions to construct labels and URLs
@@ -56,9 +70,7 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
       return '';
     }
 
-    const {
-      search, hash,
-    } = window.location;
+    const { search, hash } = window.location;
 
     return `${search}${hash}`;
   }, [isParamsAppended, dropdownOpen]);
@@ -96,7 +108,6 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
     return `[${label}](${permalink})`;
   }, [props, getUriParams, permalink]);
 
-
   /**
    * control
    */
@@ -115,16 +126,17 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
     }, 1000);
   }, []);
 
-
   /*
    * render
    */
   const { t } = useTranslation('commons');
   const {
-    dropdownToggleId, pageId,
+    dropdownToggleId,
+    pageId,
     dropdownToggleClassName,
     dropdownMenuContainer,
-    children, isShareLinkMode,
+    children,
+    isShareLinkMode,
   } = props;
 
   const customSwitchForParamsId = `customSwitchForParams_${dropdownToggleId}`;
@@ -151,9 +163,9 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
         >
           <div className="d-flex align-items-center justify-content-between">
             <DropdownItem header className="px-3">
-              { t('copy_to_clipboard.Copy to clipboard') }
+              {t('copy_to_clipboard.Copy to clipboard')}
             </DropdownItem>
-            { !isShareLinkMode && (
+            {!isShareLinkMode && (
               <div className="px-3 form-check form-switch form-switch-sm">
                 <input
                   type="checkbox"
@@ -162,9 +174,14 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
                   checked={isParamsAppended}
                   onChange={toggleAppendParams}
                 />
-                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>{ t('copy_to_clipboard.Append params') }</label>
+                <label
+                  className="form-label form-check-label small"
+                  htmlFor={customSwitchForParamsId}
+                >
+                  {t('copy_to_clipboard.Append params')}
+                </label>
               </div>
-            ) }
+            )}
           </div>
 
           <DropdownItem divider className="my-0"></DropdownItem>
@@ -195,7 +212,7 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
           <DropdownItem divider className="my-0"></DropdownItem>
 
           {/* Permanent Link */}
-          { pageId && (
+          {pageId && (
             <CopyToClipboard text={permalink} onCopy={showToolTip}>
               <DropdownItem className="px-3">
                 <DropdownItemContents
@@ -210,12 +227,21 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
           <DropdownItem divider className="my-0"></DropdownItem>
 
           {/* Page path + Permanent Link */}
-          { pageId && (
-            <CopyToClipboard text={`${pagePathWithParams}\n${permalink}`} onCopy={showToolTip}>
+          {pageId && (
+            <CopyToClipboard
+              text={`${pagePathWithParams}\n${permalink}`}
+              onCopy={showToolTip}
+            >
               <DropdownItem className="px-3">
                 <DropdownItemContents
                   title={t('copy_to_clipboard.Page path and permanent link')}
-                  contents={<>{pagePathWithParams}<br />{permalink}</>}
+                  contents={
+                    <>
+                      {pagePathWithParams}
+                      <br />
+                      {permalink}
+                    </>
+                  }
                   className="text-truncate d-block"
                 />
               </DropdownItem>
@@ -225,18 +251,25 @@ export const CopyDropdown: React.FC<CopyDropdownProps> = (props) => {
           <DropdownItem divider className="my-0"></DropdownItem>
 
           {/* Markdown Link */}
-          { pageId && (
+          {pageId && (
             <CopyToClipboard text={markdownLink} onCopy={showToolTip}>
               <DropdownItem className="px-3 text-wrap">
-                <DropdownItemContents title={t('copy_to_clipboard.Markdown link')} contents={markdownLink} />
+                <DropdownItemContents
+                  title={t('copy_to_clipboard.Markdown link')}
+                  contents={markdownLink}
+                />
               </DropdownItem>
             </CopyToClipboard>
           )}
         </DropdownMenu>
-
       </Dropdown>
 
-      <Tooltip placement="bottom" isOpen={tooltipOpen} target={dropdownToggleId} fade={false}>
+      <Tooltip
+        placement="bottom"
+        isOpen={tooltipOpen}
+        target={dropdownToggleId}
+        fade={false}
+      >
         copied!
       </Tooltip>
     </>

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

@@ -2,18 +2,17 @@ import type { FC } from 'react';
 import React from 'react';
 
 type CountProps = {
-  count?: number,
-  offset?: number,
-}
+  count?: number;
+  offset?: number;
+};
 
-const CountBadge: FC<CountProps> = (props:CountProps) => {
+const CountBadge: FC<CountProps> = (props: CountProps) => {
   const { count, offset = 0 } = props;
 
-
   return (
     <span className="grw-count-badge px-2 badge bg-body-tertiary text-body-tertiary">
-      { count == null && <span className="text-muted">―</span> }
-      { count != null && count + offset }
+      {count == null && <span className="text-muted">―</span>}
+      {count != null && count + offset}
     </span>
   );
 };

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

@@ -1,14 +1,13 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
 import { Tooltip } from 'reactstrap';
 
 type Props = {
-  message: string
-  textToBeCopied?: string
-}
+  message: string;
+  textToBeCopied?: string;
+};
 
 // To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
 const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
@@ -26,7 +25,9 @@ const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
     <>
       <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
         <div className="btn input-group-text" id="tooltipTarget">
-          <span className="material-symbols-outlined mx-1" aria-hidden="true">content_paste</span>
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">
+            content_paste
+          </span>
         </div>
       </CopyToClipboard>
       <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>

+ 4 - 7
apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx

@@ -1,4 +1,4 @@
-import { type ReactNode, type JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 
 import { useDrawerOpened } from '~/states/ui/sidebar';
 
@@ -6,14 +6,12 @@ import styles from './DrawerToggler.module.scss';
 
 const moduleClass = styles['grw-drawer-toggler'];
 
-
 type Props = {
-  className?: string,
-  children?: ReactNode,
-}
+  className?: string;
+  children?: ReactNode;
+};
 
 export const DrawerToggler = (props: Props): JSX.Element => {
-
   const { className, children } = props;
 
   const [isOpened, setIsOpened] = useDrawerOpened();
@@ -31,5 +29,4 @@ export const DrawerToggler = (props: Props): JSX.Element => {
       </button>
     </div>
   );
-
 };

+ 17 - 11
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,13 +1,13 @@
-import { type IPageInfoForOperation, type IPageInfoForEmpty } from '@growi/core/dist/interfaces';
-import {
-  fireEvent, screen, within,
-} from '@testing-library/dom';
+import type {
+  IPageInfoForEmpty,
+  IPageInfoForOperation,
+} from '@growi/core/dist/interfaces';
+import { fireEvent, screen, within } from '@testing-library/dom';
 import { render } from '@testing-library/react';
 import { mock } from 'vitest-mock-extended';
 
 import { PageItemControl } from './PageItemControl';
 
-
 // mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 const mocks = vi.hoisted(() => ({
@@ -20,10 +20,9 @@ vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 
-
 describe('PageItemControl.tsx', () => {
   describe('Should trigger onClickRenameMenuItem() when clicking the rename button', () => {
-    it('without fetching PageInfo by useSWRxPageInfo', async() => {
+    it('without fetching PageInfo by useSWRxPageInfo', async () => {
       // setup
       const pageInfo = mock<IPageInfoForOperation>();
 
@@ -47,7 +46,9 @@ describe('PageItemControl.tsx', () => {
       render(<PageItemControl {...props} />);
 
       // when
-      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      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);
@@ -56,7 +57,7 @@ describe('PageItemControl.tsx', () => {
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
 
-    it('with empty page (IPageInfoForEmpty)', async() => {
+    it('with empty page (IPageInfoForEmpty)', async () => {
       // setup - Create an empty page mock with required properties
       const pageInfo: IPageInfoForEmpty = {
         emptyPageId: 'empty-page-id',
@@ -94,14 +95,19 @@ describe('PageItemControl.tsx', () => {
       render(<PageItemControl {...props} />);
 
       // when
-      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      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);
+      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith(
+        'dummy-page-id',
+        pageInfo,
+      );
     });
   });
 });

+ 373 - 283
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -1,14 +1,16 @@
-import React, {
-  useState, useCallback, useEffect, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useEffect, useState } from 'react';
 import {
-  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
+  type IPageInfoExt,
+  isIPageInfoForEmpty,
+  isIPageInfoForOperation,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import {
-  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
@@ -19,7 +21,6 @@ import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 
-
 export const MenuItemType = {
   BOOKMARK: 'bookmark',
   RENAME: 'rename',
@@ -29,276 +30,355 @@ export const MenuItemType = {
   PATH_RECOVERY: 'pathRecovery',
   SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
-export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+export type MenuItemType = (typeof MenuItemType)[keyof typeof MenuItemType];
 
 export type ForceHideMenuItems = MenuItemType[];
 
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 
 type CommonProps = {
-  pageInfo?: IPageInfoExt,
-  isEnableActions?: boolean,
-  isReadOnlyUser?: boolean,
-  forceHideMenuItems?: ForceHideMenuItems,
-
-  onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
-  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
-  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
-
-  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  isInstantRename?: boolean,
-  alignEnd?: boolean,
-}
-
+  pageInfo?: IPageInfoExt;
+  isEnableActions?: boolean;
+  isReadOnlyUser?: boolean;
+  forceHideMenuItems?: ForceHideMenuItems;
+
+  onClickBookmarkMenuItem?: (
+    pageId: string,
+    newValue?: boolean,
+  ) => Promise<void>;
+  onClickRenameMenuItem?: (
+    pageId: string,
+    pageInfo: IPageInfoExt | undefined,
+  ) => Promise<void> | void;
+  onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void;
+  onClickDeleteMenuItem?: (
+    pageId: string,
+    pageInfo: IPageInfoExt | undefined,
+  ) => Promise<void> | void;
+  onClickRevertMenuItem?: (pageId: string) => Promise<void> | void;
+  onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void;
+
+  additionalMenuItemOnTopRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
+  isInstantRename?: boolean;
+  alignEnd?: boolean;
+};
 
 type DropdownMenuProps = CommonProps & {
-  pageId: string,
-  isLoading?: boolean,
-  isDataUnavailable?: boolean,
-  operationProcessData?: IPageOperationProcessData,
-}
-
-const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.Element => {
-  const { t } = useTranslation('');
-
-  const {
-    pageId, isLoading, isDataUnavailable, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
-    onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
-    onClickRevertMenuItem, onClickPathRecoveryMenuItem,
-    additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
-    additionalMenuItemRenderer: AdditionalMenuItems,
-    isInstantRename, alignEnd,
-  } = props;
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const bookmarkItemClickedHandler = useCallback(async() => {
-    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 (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
-      logger.warn('This page could not be renamed.');
-      return;
-    }
-
-    await onClickRenameMenuItem(pageId, pageInfo);
-  }, [onClickRenameMenuItem, pageId, pageInfo]);
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const duplicateItemClickedHandler = useCallback(async() => {
-    if (onClickDuplicateMenuItem == null) {
-      return;
-    }
-    await onClickDuplicateMenuItem(pageId);
-  }, [onClickDuplicateMenuItem, pageId]);
-
-  const revertItemClickedHandler = useCallback(async() => {
-    if (onClickRevertMenuItem == null) {
-      return;
-    }
-    await onClickRevertMenuItem(pageId);
-  }, [onClickRevertMenuItem, pageId]);
-
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const deleteItemClickedHandler = useCallback(async() => {
-    if (onClickDeleteMenuItem == null) return;
-
-    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
-      logger.warn('This page could not be deleted.');
-      return;
-    }
-    await onClickDeleteMenuItem(pageId, pageInfo);
-  }, [onClickDeleteMenuItem, pageId, pageInfo]);
+  pageId: string;
+  isLoading?: boolean;
+  isDataUnavailable?: boolean;
+  operationProcessData?: IPageOperationProcessData;
+};
 
-  // eslint-disable-next-line react-hooks/rules-of-hooks
-  const pathRecoveryItemClickedHandler = useCallback(async() => {
-    if (onClickPathRecoveryMenuItem == null) {
-      return;
+const PageItemControlDropdownMenu = React.memo(
+  (props: DropdownMenuProps): JSX.Element => {
+    const { t } = useTranslation('');
+
+    const {
+      pageId,
+      isLoading,
+      isDataUnavailable,
+      pageInfo,
+      isEnableActions,
+      isReadOnlyUser,
+      forceHideMenuItems,
+      operationProcessData,
+      onClickBookmarkMenuItem,
+      onClickRenameMenuItem,
+      onClickDuplicateMenuItem,
+      onClickDeleteMenuItem,
+      onClickRevertMenuItem,
+      onClickPathRecoveryMenuItem,
+      additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
+      additionalMenuItemRenderer: AdditionalMenuItems,
+      isInstantRename,
+      alignEnd,
+    } = props;
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const bookmarkItemClickedHandler = useCallback(async () => {
+      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 (
+        !(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) ||
+        !pageInfo?.isMovable
+      ) {
+        logger.warn('This page could not be renamed.');
+        return;
+      }
+
+      await onClickRenameMenuItem(pageId, pageInfo);
+    }, [onClickRenameMenuItem, pageId, pageInfo]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const duplicateItemClickedHandler = useCallback(async () => {
+      if (onClickDuplicateMenuItem == null) {
+        return;
+      }
+      await onClickDuplicateMenuItem(pageId);
+    }, [onClickDuplicateMenuItem, pageId]);
+
+    const revertItemClickedHandler = useCallback(async () => {
+      if (onClickRevertMenuItem == null) {
+        return;
+      }
+      await onClickRevertMenuItem(pageId);
+    }, [onClickRevertMenuItem, pageId]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const deleteItemClickedHandler = useCallback(async () => {
+      if (onClickDeleteMenuItem == null) return;
+
+      if (
+        !(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) ||
+        !pageInfo?.isDeletable
+      ) {
+        logger.warn('This page could not be deleted.');
+        return;
+      }
+      await onClickDeleteMenuItem(pageId, pageInfo);
+    }, [onClickDeleteMenuItem, pageId, pageInfo]);
+
+    // eslint-disable-next-line react-hooks/rules-of-hooks
+    const pathRecoveryItemClickedHandler = useCallback(async () => {
+      if (onClickPathRecoveryMenuItem == null) {
+        return;
+      }
+      await onClickPathRecoveryMenuItem(pageId);
+    }, [onClickPathRecoveryMenuItem, pageId]);
+
+    let contents = <></>;
+
+    if (isDataUnavailable) {
+      // Show message when data is not available (e.g., fetch error)
+      contents = (
+        <div className="text-warning text-center px-3">
+          <span className="material-symbols-outlined">error_outline</span> No
+          data available
+        </div>
+      );
+    } else if (isLoading) {
+      contents = (
+        <div className="text-muted text-center my-2">
+          <LoadingSpinner />
+        </div>
+      );
+    } else if (pageId != null && pageInfo != null) {
+      const showDeviderBeforeAdditionalMenuItems =
+        (forceHideMenuItems?.length ?? 0) < 3;
+      const showDeviderBeforeDelete =
+        AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
+
+      // PathRecovery
+      // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
+      const shouldShowPathRecoveryButton =
+        operationProcessData != null
+          ? shouldRecoverPagePaths(operationProcessData)
+          : false;
+
+      contents = (
+        <>
+          {!isEnableActions && (
+            <DropdownItem>
+              <p>{t('search_result.currently_not_implemented')}</p>
+            </DropdownItem>
+          )}
+
+          {AdditionalMenuItemsOnTop && (
+            <>
+              <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
+              <DropdownItem divider />
+            </>
+          )}
+
+          {/* Bookmark */}
+          {!forceHideMenuItems?.includes(MenuItemType.BOOKMARK) &&
+            isEnableActions &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) && (
+              <DropdownItem
+                onClick={bookmarkItemClickedHandler}
+                className="grw-page-control-dropdown-item"
+                data-testid={
+                  pageInfo.isBookmarked
+                    ? 'remove-bookmark-btn'
+                    : 'add-bookmark-btn'
+                }
+              >
+                <span className="material-symbols-outlined grw-page-control-dropdown-icon">
+                  bookmark
+                </span>
+                {pageInfo.isBookmarked
+                  ? t('remove_bookmark')
+                  : t('add_bookmark')}
+              </DropdownItem>
+            )}
+
+          {/* Move/Rename */}
+          {!forceHideMenuItems?.includes(MenuItemType.RENAME) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) &&
+            pageInfo.isMovable && (
+              <DropdownItem
+                onClick={renameItemClickedHandler}
+                data-testid="rename-page-btn"
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  redo
+                </span>
+                {t(isInstantRename ? 'Rename' : 'Move/Rename')}
+              </DropdownItem>
+            )}
+
+          {/* Duplicate */}
+          {!forceHideMenuItems?.includes(MenuItemType.DUPLICATE) &&
+            isEnableActions &&
+            !isReadOnlyUser && (
+              <DropdownItem
+                onClick={duplicateItemClickedHandler}
+                data-testid="open-page-duplicate-modal-btn"
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  file_copy
+                </span>
+                {t('Duplicate')}
+              </DropdownItem>
+            )}
+
+          {/* Revert */}
+          {!forceHideMenuItems?.includes(MenuItemType.REVERT) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) &&
+            pageInfo.isRevertible && (
+              <DropdownItem
+                onClick={revertItemClickedHandler}
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  undo
+                </span>
+                {t('modal_putback.label.Put Back Page')}
+              </DropdownItem>
+            )}
+
+          {AdditionalMenuItems && (
+            <>
+              {showDeviderBeforeAdditionalMenuItems && <DropdownItem divider />}
+              <AdditionalMenuItems pageInfo={pageInfo} />
+            </>
+          )}
+
+          {/* PathRecovery */}
+          {!forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            shouldShowPathRecoveryButton && (
+              <DropdownItem
+                onClick={pathRecoveryItemClickedHandler}
+                className="grw-page-control-dropdown-item"
+              >
+                <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                  build
+                </span>
+                {t('PathRecovery')}
+              </DropdownItem>
+            )}
+
+          {/* divider */}
+          {/* Delete */}
+          {!forceHideMenuItems?.includes(MenuItemType.DELETE) &&
+            isEnableActions &&
+            !isReadOnlyUser &&
+            (isIPageInfoForEmpty(pageInfo) ||
+              isIPageInfoForOperation(pageInfo)) &&
+            pageInfo.isDeletable && (
+              <>
+                {showDeviderBeforeDelete && <DropdownItem divider />}
+                <DropdownItem
+                  className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
+                  disabled={!pageInfo.isDeletable}
+                  onClick={deleteItemClickedHandler}
+                  data-testid="open-page-delete-modal-btn"
+                >
+                  <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                    delete
+                  </span>
+                  {t('Delete')}
+                </DropdownItem>
+              </>
+            )}
+        </>
+      );
     }
-    await onClickPathRecoveryMenuItem(pageId);
-  }, [onClickPathRecoveryMenuItem, pageId]);
 
-  let contents = <></>;
-
-  if (isDataUnavailable) {
-    // Show message when data is not available (e.g., fetch error)
-    contents = (
-      <div className="text-warning text-center px-3">
-        <span className="material-symbols-outlined">error_outline</span> No data available
-      </div>
-    );
-  }
-  else if (isLoading) {
-    contents = (
-      <div className="text-muted text-center my-2">
-        <LoadingSpinner />
-      </div>
-    );
-  }
-  else if (pageId != null && pageInfo != null) {
-
-    const showDeviderBeforeAdditionalMenuItems = (forceHideMenuItems?.length ?? 0) < 3;
-    const showDeviderBeforeDelete = AdditionalMenuItems != null || showDeviderBeforeAdditionalMenuItems;
-
-    // PathRecovery
-    // Todo: It is wanted to find a better way to pass operationProcessData to PageItemControl
-    const shouldShowPathRecoveryButton = operationProcessData != null ? shouldRecoverPagePaths(operationProcessData) : false;
-
-    contents = (
-      <>
-        { !isEnableActions && (
-          <DropdownItem>
-            <p>
-              {t('search_result.currently_not_implemented')}
-            </p>
-          </DropdownItem>
-        ) }
-
-        { AdditionalMenuItemsOnTop && (
-          <>
-            <AdditionalMenuItemsOnTop pageInfo={pageInfo} />
-            <DropdownItem divider />
-          </>
-        ) }
-
-        {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
-          <DropdownItem
-            onClick={bookmarkItemClickedHandler}
-            className="grw-page-control-dropdown-item"
-            data-testid={pageInfo.isBookmarked ? 'remove-bookmark-btn' : 'add-bookmark-btn'}
-          >
-            <span className="material-symbols-outlined grw-page-control-dropdown-icon">bookmark</span>
-            { pageInfo.isBookmarked ? t('remove_bookmark') : t('add_bookmark') }
-          </DropdownItem>
-        ) }
-
-        {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
-          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
-          && pageInfo.isMovable && (
-          <DropdownItem
-            onClick={renameItemClickedHandler}
-            data-testid="rename-page-btn"
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">redo</span>
-            {t(isInstantRename ? 'Rename' : 'Move/Rename')}
-          </DropdownItem>
-        ) }
-
-        {/* Duplicate */}
-        { !forceHideMenuItems?.includes(MenuItemType.DUPLICATE) && isEnableActions && !isReadOnlyUser && (
-          <DropdownItem
-            onClick={duplicateItemClickedHandler}
-            data-testid="open-page-duplicate-modal-btn"
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">file_copy</span>
-            {t('Duplicate')}
-          </DropdownItem>
-        ) }
-
-        {/* Revert */}
-        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
-          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
-          && pageInfo.isRevertible && (
-          <DropdownItem
-            onClick={revertItemClickedHandler}
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">undo</span>
-            {t('modal_putback.label.Put Back Page')}
-          </DropdownItem>
-        ) }
-
-        { AdditionalMenuItems && (
-          <>
-            { showDeviderBeforeAdditionalMenuItems && <DropdownItem divider /> }
-            <AdditionalMenuItems pageInfo={pageInfo} />
-          </>
-        ) }
-
-        {/* PathRecovery */}
-        { !forceHideMenuItems?.includes(MenuItemType.PATH_RECOVERY) && isEnableActions && !isReadOnlyUser && shouldShowPathRecoveryButton && (
-          <DropdownItem
-            onClick={pathRecoveryItemClickedHandler}
-            className="grw-page-control-dropdown-item"
-          >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">build</span>
-            {t('PathRecovery')}
-          </DropdownItem>
-        ) }
-
-        {/* divider */}
-        {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
-          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
-          && pageInfo.isDeletable && (
-          <>
-            { showDeviderBeforeDelete && <DropdownItem divider /> }
-            <DropdownItem
-              className={`pt-2 grw-page-control-dropdown-item ${pageInfo.isDeletable ? 'text-danger' : ''}`}
-              disabled={!pageInfo.isDeletable}
-              onClick={deleteItemClickedHandler}
-              data-testid="open-page-delete-modal-btn"
-            >
-              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">delete</span>
-              {t('Delete')}
-            </DropdownItem>
-          </>
-        )}
-      </>
+    return (
+      <DropdownMenu
+        className="d-print-none"
+        data-testid="page-item-control-menu"
+        end={alignEnd}
+        container="body"
+        persist={!!alignEnd}
+        style={{
+          zIndex: 1055,
+        }} /* make it larger than $zindex-modal of bootstrap */
+      >
+        {contents}
+      </DropdownMenu>
     );
-  }
-
-  return (
-    <DropdownMenu
-      className="d-print-none"
-      data-testid="page-item-control-menu"
-      end={alignEnd}
-      container="body"
-      persist={!!alignEnd}
-      style={{ zIndex: 1055 }} /* make it larger than $zindex-modal of bootstrap */
-    >
-      {contents}
-    </DropdownMenu>
-  );
-});
+  },
+);
 
 PageItemControlDropdownMenu.displayName = 'PageItemControl';
 
-
 type PageItemControlSubstanceProps = CommonProps & {
-  pageId: string,
-  children?: React.ReactNode,
-  operationProcessData?: IPageOperationProcessData,
-}
-
-export const PageItemControlSubstance = (props: PageItemControlSubstanceProps): JSX.Element => {
+  pageId: string;
+  children?: React.ReactNode;
+  operationProcessData?: IPageOperationProcessData;
+};
 
+export const PageItemControlSubstance = (
+  props: PageItemControlSubstanceProps,
+): JSX.Element => {
   const {
-    pageId, pageInfo: presetPageInfo, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
-    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId,
+    pageInfo: presetPageInfo,
+    children,
+    onClickBookmarkMenuItem,
+    onClickRenameMenuItem,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    onClickPathRecoveryMenuItem,
   } = props;
 
   const [isOpen, setIsOpen] = useState(false);
   const [shouldFetch, setShouldFetch] = useState(false);
 
-  const { data: fetchedPageInfo, error: fetchError, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const {
+    data: fetchedPageInfo,
+    error: fetchError,
+    mutate: mutatePageInfo,
+  } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
   // update shouldFetch (and will never be false)
   useEffect(() => {
@@ -311,42 +391,47 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   }, [isOpen, presetPageInfo, shouldFetch]);
 
   // mutate after handle event
-  const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean) => {
-    if (onClickBookmarkMenuItem != null) {
-      await onClickBookmarkMenuItem(_pageId, _newValue);
-    }
-
-    if (shouldFetch) {
-      mutatePageInfo();
-    }
-  }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
+  const bookmarkMenuItemClickHandler = useCallback(
+    async (_pageId: string, _newValue: boolean) => {
+      if (onClickBookmarkMenuItem != null) {
+        await onClickBookmarkMenuItem(_pageId, _newValue);
+      }
+
+      if (shouldFetch) {
+        mutatePageInfo();
+      }
+    },
+    [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch],
+  );
 
   // isLoading should be true only when fetching is in progress (data and error are both undefined)
-  const isLoading = shouldFetch && fetchedPageInfo == null && fetchError == null;
-  const isDataUnavailable = !isLoading && fetchedPageInfo == null && presetPageInfo == null;
+  const isLoading =
+    shouldFetch && fetchedPageInfo == null && fetchError == null;
+  const isDataUnavailable =
+    !isLoading && fetchedPageInfo == null && presetPageInfo == null;
 
-  const renameMenuItemClickHandler = useCallback(async() => {
+  const renameMenuItemClickHandler = useCallback(async () => {
     if (onClickRenameMenuItem == null) {
       return;
     }
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
-  const duplicateMenuItemClickHandler = useCallback(async() => {
+  const duplicateMenuItemClickHandler = useCallback(async () => {
     if (onClickDuplicateMenuItem == null) {
       return;
     }
     await onClickDuplicateMenuItem(pageId);
   }, [onClickDuplicateMenuItem, pageId]);
 
-  const deleteMenuItemClickHandler = useCallback(async() => {
+  const deleteMenuItemClickHandler = useCallback(async () => {
     if (onClickDeleteMenuItem == null) {
       return;
     }
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
-  const pathRecoveryMenuItemClickHandler = useCallback(async() => {
+  const pathRecoveryMenuItemClickHandler = useCallback(async () => {
     if (onClickPathRecoveryMenuItem == null) {
       return;
     }
@@ -355,14 +440,23 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
   return (
     <NotAvailableForGuest>
-      <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)} className="grw-page-item-control" data-testid="open-page-item-control-btn">
-        { children ?? (
-          <DropdownToggle role="button" color="transparent" className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center">
+      <Dropdown
+        isOpen={isOpen}
+        toggle={() => setIsOpen(!isOpen)}
+        className="grw-page-item-control"
+        data-testid="open-page-item-control-btn"
+      >
+        {children ?? (
+          <DropdownToggle
+            role="button"
+            color="transparent"
+            className="border-0 rounded btn-page-item-control d-flex align-items-center justify-content-center"
+          >
             <span className="material-symbols-outlined">more_vert</span>
           </DropdownToggle>
-        ) }
+        )}
 
-        { isOpen && (
+        {isOpen && (
           <PageItemControlDropdownMenu
             {...props}
             isLoading={isLoading}
@@ -374,21 +468,17 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
           />
-        ) }
+        )}
       </Dropdown>
-
     </NotAvailableForGuest>
-
   );
-
 };
 
-
 export type PageItemControlProps = CommonProps & {
-  pageId?: string,
-  children?: React.ReactNode,
-  operationProcessData?: IPageOperationProcessData,
-}
+  pageId?: string;
+  children?: React.ReactNode;
+  operationProcessData?: IPageOperationProcessData;
+};
 
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
   const { pageId } = props;

+ 138 - 87
apps/app/src/client/components/Common/ImageCropModal.tsx

@@ -1,16 +1,9 @@
 import type { FC } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
 import canvasToBlob from 'async-canvas-to-blob';
 import { useTranslation } from 'react-i18next';
 import ReactCrop from 'react-image-crop';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { toastError } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
@@ -19,28 +12,32 @@ import 'react-image-crop/dist/ReactCrop.css';
 const logger = loggerFactory('growi:ImageCropModal');
 
 interface ICropOptions {
-  aspect: number
-  unit: string,
-  x: number
-  y: number
-  width: number,
-  height: number,
+  aspect: number;
+  unit: string;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
 }
 
-type CropOptions = ICropOptions | null
+type CropOptions = ICropOptions | null;
 
 type Props = {
-  isShow: boolean,
-  src: string | ArrayBuffer | null,
-  onModalClose: () => void,
-  onImageProcessCompleted: (res: any) => void,
-  isCircular: boolean,
-  showCropOption: boolean
-}
+  isShow: boolean;
+  src: string | ArrayBuffer | null;
+  onModalClose: () => void;
+  onImageProcessCompleted: (res: any) => void;
+  isCircular: boolean;
+  showCropOption: boolean;
+};
 const ImageCropModal: FC<Props> = (props: Props) => {
-
   const {
-    isShow, src, onModalClose, onImageProcessCompleted, isCircular, showCropOption,
+    isShow,
+    src,
+    onModalClose,
+    onImageProcessCompleted,
+    isCircular,
+    showCropOption,
   } = props;
 
   const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
@@ -77,91 +74,133 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   }, [reset]);
 
   // Memoize image processing functions
-  const onImageLoaded = useCallback((image) => {
-    setImageRef(image);
-    reset();
-    return false;
-  }, [reset]);
+  const onImageLoaded = useCallback(
+    (image) => {
+      setImageRef(image);
+      reset();
+      return false;
+    },
+    [reset],
+  );
 
-  const getCroppedImg = useCallback(async(image: HTMLImageElement, crop: ICropOptions) => {
-    const {
-      naturalWidth: imageNaturalWidth, naturalHeight: imageNaturalHeight, width: imageWidth, height: imageHeight,
-    } = image;
-
-    const {
-      width: cropWidth, height: cropHeight, x, y,
-    } = crop;
-
-    const canvas = document.createElement('canvas');
-    const scaleX = imageNaturalWidth / imageWidth;
-    const scaleY = imageNaturalHeight / imageHeight;
-    canvas.width = cropWidth;
-    canvas.height = cropHeight;
-    const ctx = canvas.getContext('2d');
-    ctx?.drawImage(image, x * scaleX, y * scaleY, cropWidth * scaleX, cropHeight * scaleY, 0, 0, cropWidth, cropHeight);
-    try {
-      const blob = await canvasToBlob(canvas);
-      return blob;
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to draw image'));
-    }
-  }, []);
+  const getCroppedImg = useCallback(
+    async (image: HTMLImageElement, crop: ICropOptions) => {
+      const {
+        naturalWidth: imageNaturalWidth,
+        naturalHeight: imageNaturalHeight,
+        width: imageWidth,
+        height: imageHeight,
+      } = image;
+
+      const { width: cropWidth, height: cropHeight, x, y } = crop;
+
+      const canvas = document.createElement('canvas');
+      const scaleX = imageNaturalWidth / imageWidth;
+      const scaleY = imageNaturalHeight / imageHeight;
+      canvas.width = cropWidth;
+      canvas.height = cropHeight;
+      const ctx = canvas.getContext('2d');
+      ctx?.drawImage(
+        image,
+        x * scaleX,
+        y * scaleY,
+        cropWidth * scaleX,
+        cropHeight * scaleY,
+        0,
+        0,
+        cropWidth,
+        cropHeight,
+      );
+      try {
+        const blob = await canvasToBlob(canvas);
+        return blob;
+      } catch (err) {
+        logger.error(err);
+        toastError(new Error('Failed to draw image'));
+      }
+    },
+    [],
+  );
 
   // Convert base64 Image to blob
-  const convertBase64ToBlob = useCallback(async(base64Image: string) => {
+  const convertBase64ToBlob = useCallback(async (base64Image: string) => {
     const base64Response = await fetch(base64Image);
     return base64Response.blob();
   }, []);
 
-
   // Memoize event handlers
-  const onModalCloseHandler = useCallback(async() => {
+  const onModalCloseHandler = useCallback(async () => {
     setImageRef(null);
     onModalClose();
   }, [onModalClose]);
 
-  const processAndSaveImage = useCallback(async() => {
+  const processAndSaveImage = useCallback(async () => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
-      const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
+      const processedImage = isCropImage
+        ? await getCroppedImg(imageRef, cropOptions)
+        : await convertBase64ToBlob(imageRef.src);
       // Save image to database
       onImageProcessCompleted(processedImage);
     }
     onModalCloseHandler();
-  }, [imageRef, cropOptions, isCropImage, getCroppedImg, convertBase64ToBlob, onImageProcessCompleted, onModalCloseHandler]);
-
-  const toggleCropMode = useCallback(() => setIsCropImage(!isCropImage), [isCropImage]);
-  const handleCropChange = useCallback((crop: CropOptions) => setCropOtions(crop), []);
+  }, [
+    imageRef,
+    cropOptions,
+    isCropImage,
+    getCroppedImg,
+    convertBase64ToBlob,
+    onImageProcessCompleted,
+    onModalCloseHandler,
+  ]);
+
+  const toggleCropMode = useCallback(
+    () => setIsCropImage(!isCropImage),
+    [isCropImage],
+  );
+  const handleCropChange = useCallback(
+    (crop: CropOptions) => setCropOtions(crop),
+    [],
+  );
 
   return (
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
       {isShow && (
         <>
-          <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
+          <ModalHeader
+            tag="h4"
+            toggle={onModalCloseHandler}
+            className="text-info"
+          >
             {t('crop_image_modal.image_crop')}
           </ModalHeader>
           <ModalBody className="my-4">
-            {
-              isCropImage
-                ? (
-                  <ReactCrop
-                    style={{ backgroundColor: 'transparent' }}
-                    src={src}
-                    crop={cropOptions}
-                    onImageLoaded={onImageLoaded}
-                    onChange={handleCropChange}
-                    circularCrop={isCircular}
-                  />
-                )
-                : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
-            }
+            {isCropImage ? (
+              <ReactCrop
+                style={{ backgroundColor: 'transparent' }}
+                src={src}
+                crop={cropOptions}
+                onImageLoaded={onImageLoaded}
+                onChange={handleCropChange}
+                circularCrop={isCircular}
+              />
+            ) : (
+              <img
+                style={{ maxWidth: imageRef?.width }}
+                src={imageRef?.src}
+                alt="Cropped preview"
+              />
+            )}
           </ModalBody>
           <ModalFooter>
-            <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
+            <button
+              type="button"
+              className="btn btn-outline-danger rounded-pill me-auto"
+              disabled={!isCropImage}
+              onClick={reset}
+            >
               {t('commons:Reset')}
             </button>
-            { !showCropOption && (
+            {!showCropOption && (
               <div className="me-auto">
                 <div className="form-check form-switch">
                   <input
@@ -171,18 +210,30 @@ const ImageCropModal: FC<Props> = (props: Props) => {
                     checked={isCropImage}
                     onChange={toggleCropMode}
                   />
-                  <label className="form-label form-check-label" htmlFor="cropImageOption">
-                    { t('crop_image_modal.image_crop') }
+                  <label
+                    className="form-label form-check-label"
+                    htmlFor="cropImageOption"
+                  >
+                    {t('crop_image_modal.image_crop')}
                   </label>
                 </div>
               </div>
-            )
-            }
-            <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
+            )}
+            <button
+              type="button"
+              className="btn btn-outline-secondary rounded-pill me-2"
+              onClick={onModalCloseHandler}
+            >
               {t('crop_image_modal.cancel')}
             </button>
-            <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
-              { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
+            <button
+              type="button"
+              className="btn btn-outline-primary rounded-pill"
+              onClick={processAndSaveImage}
+            >
+              {isCropImage
+                ? t('crop_image_modal.crop')
+                : t('crop_image_modal.save')}
             </button>
           </ModalFooter>
         </>

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

@@ -1,18 +1,17 @@
-import React, { useEffect, useState, type JSX } from 'react';
+import React, { type JSX, useEffect, useState } from 'react';
 
 type Props = {
-  shouldRender: boolean | (() => boolean),
-  children: JSX.Element,
-}
+  shouldRender: boolean | (() => boolean);
+  children: JSX.Element;
+};
 
 export const LazyRenderer = (props: Props): JSX.Element => {
   const { shouldRender: _shouldRender, children } = props;
 
   const [isActivated, setActivated] = useState(false);
 
-  const shouldRender = typeof _shouldRender === 'function'
-    ? _shouldRender()
-    : _shouldRender;
+  const shouldRender =
+    typeof _shouldRender === 'function' ? _shouldRender() : _shouldRender;
 
   useEffect(() => {
     if (isActivated) {
@@ -28,5 +27,4 @@ export const LazyRenderer = (props: Props): JSX.Element => {
   const child = React.Children.only(children);
 
   return React.cloneElement(child, { visibility: shouldRender });
-
 };

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

@@ -1,10 +1,11 @@
-import React from 'react';
+import type React from 'react';
 
 export const RendererErrorMessage: React.FC = () => {
   return (
     <p className="alert alert-warning">
-      ⚠️ <strong>Developer Warning:</strong>{' '}
-      Required renderer configuration is missing. Ensure <code>useRendererConfig()</code> is properly called in the component.
+      ⚠️ <strong>Developer Warning:</strong> Required renderer configuration is
+      missing. Ensure <code>useRendererConfig()</code> is properly called in the
+      component.
     </p>
   );
 };

+ 19 - 12
apps/app/src/client/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx

@@ -1,30 +1,37 @@
-import type {
-  ReactElement,
-} from 'react';
-
+import type { ReactElement } from 'react';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import AutosizeInput from 'react-input-autosize';
 
 import type { SubmittableInputProps } from './types';
 import { useSubmittable } from './use-submittable';
 
-
-export const getAdjustedMaxWidthForAutosizeInput = (parentMaxWidth: number, size: 'sm' | 'md' | 'lg' = 'md', isValid?: boolean): number => {
+export const getAdjustedMaxWidthForAutosizeInput = (
+  parentMaxWidth: number,
+  size: 'sm' | 'md' | 'lg' = 'md',
+  isValid?: boolean,
+): number => {
   // eslint-disable-next-line no-nested-ternary
   const bsFormPaddingSize = size === 'sm' ? 8 : size === 'md' ? 12 : 16; // by bootstrap form
   // eslint-disable-next-line no-nested-ternary
   const bsValidationIconSize = size === 'sm' ? 25 : size === 'md' ? 24 : 26; // by bootstrap form validation
 
-  return parentMaxWidth
-      - bsFormPaddingSize * 2 // minus the padding (12px * 2) because AutosizeInput has "box-sizing: content-box;"
-      - (isValid === false ? bsValidationIconSize : 0); // minus the width for the exclamation icon
+  return (
+    parentMaxWidth -
+    bsFormPaddingSize * 2 - // minus the padding (12px * 2) because AutosizeInput has "box-sizing: content-box;"
+    (isValid === false ? bsValidationIconSize : 0)
+  ); // minus the width for the exclamation icon
 };
 
-export const AutosizeSubmittableInput = (props: SubmittableInputProps<AutosizeInputProps>): ReactElement<AutosizeInput> => {
-
+export const AutosizeSubmittableInput = (
+  props: SubmittableInputProps<AutosizeInputProps>,
+): ReactElement<AutosizeInput> => {
   const submittableProps = useSubmittable(props);
 
   return (
-    <AutosizeInput {...submittableProps} type="text" data-testid="autosize-submittable-input" />
+    <AutosizeInput
+      {...submittableProps}
+      type="text"
+      data-testid="autosize-submittable-input"
+    />
   );
 };

+ 5 - 8
apps/app/src/client/components/Common/SubmittableInput/SubmittableInput.tsx

@@ -1,12 +1,11 @@
-import type {
-  ReactElement,
-} from 'react';
+import type { ReactElement } from 'react';
 
 import type { SubmittableInputProps } from './types';
 import { useSubmittable } from './use-submittable';
 
-
-export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTMLInputElement> => {
+export const SubmittableInput = (
+  props: SubmittableInputProps,
+): ReactElement<HTMLInputElement> => {
   // // autoFocus
   // useEffect(() => {
   //   if (inputRef?.current == null) {
@@ -17,7 +16,5 @@ export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTM
 
   const submittableProps = useSubmittable(props);
 
-  return (
-    <input {...submittableProps} />
-  );
+  return <input {...submittableProps} />;
 };

+ 1 - 1
apps/app/src/client/components/Common/SubmittableInput/index.ts

@@ -1,2 +1,2 @@
-export * from './SubmittableInput';
 export * from './AutosizeSubmittableInput';
+export * from './SubmittableInput';

+ 8 - 7
apps/app/src/client/components/Common/SubmittableInput/types.d.ts

@@ -1,7 +1,8 @@
-export type SubmittableInputProps<T extends InputHTMLAttributes<HTMLInputElement> = InputHTMLAttributes<HTMLInputElement>> =
-  Omit<InputHTMLAttributes<T>, 'value' | 'onKeyDown' | 'onSubmit'>
-  & {
-    value?: string,
-    onSubmit?: (inputText: string) => void,
-    onCancel?: () => void,
-  }
+export type SubmittableInputProps<
+  T extends
+    InputHTMLAttributes<HTMLInputElement> = InputHTMLAttributes<HTMLInputElement>,
+> = Omit<InputHTMLAttributes<T>, 'value' | 'onKeyDown' | 'onSubmit'> & {
+  value?: string;
+  onSubmit?: (inputText: string) => void;
+  onCancel?: () => void;
+};

+ 73 - 55
apps/app/src/client/components/Common/SubmittableInput/use-submittable.ts

@@ -1,77 +1,96 @@
-import type {
-  CompositionEvent,
-} from 'react';
 import type React from 'react';
-import {
-  useCallback, useState,
-} from 'react';
+import type { CompositionEvent } from 'react';
+import { useCallback, useState } from 'react';
 
 import type { SubmittableInputProps } from './types';
 
-export const useSubmittable = (props: SubmittableInputProps): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
-
+export const useSubmittable = (
+  props: SubmittableInputProps,
+): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
   const {
     value,
-    onChange, onBlur,
-    onCompositionStart, onCompositionEnd,
-    onSubmit, onCancel,
+    onChange,
+    onBlur,
+    onCompositionStart,
+    onCompositionEnd,
+    onSubmit,
+    onCancel,
   } = props;
 
   const [inputText, setInputText] = useState(value ?? '');
-  const [lastSubmittedInputText, setLastSubmittedInputText] = useState<string|undefined>(value ?? '');
+  const [lastSubmittedInputText, setLastSubmittedInputText] = useState<
+    string | undefined
+  >(value ?? '');
   const [isComposing, setComposing] = useState(false);
 
-  const changeHandler = useCallback(async(e: React.ChangeEvent<HTMLInputElement>) => {
-    const inputText = e.target.value;
-    setInputText(inputText);
+  const changeHandler = useCallback(
+    async (e: React.ChangeEvent<HTMLInputElement>) => {
+      const inputText = e.target.value;
+      setInputText(inputText);
 
-    onChange?.(e);
-  }, [onChange]);
+      onChange?.(e);
+    },
+    [onChange],
+  );
 
-  const keyDownHandler = useCallback((e) => {
-    switch (e.key) {
-      case 'Enter':
-        // Do nothing when composing
-        if (isComposing) {
-          return;
-        }
-        setLastSubmittedInputText(inputText);
-        onSubmit?.(inputText.trim());
-        break;
-      case 'Escape':
-        if (isComposing) {
-          return;
-        }
-        onCancel?.();
-        break;
-    }
-  }, [inputText, isComposing, onCancel, onSubmit]);
+  const keyDownHandler = useCallback(
+    (e) => {
+      switch (e.key) {
+        case 'Enter':
+          // Do nothing when composing
+          if (isComposing) {
+            return;
+          }
+          setLastSubmittedInputText(inputText);
+          onSubmit?.(inputText.trim());
+          break;
+        case 'Escape':
+          if (isComposing) {
+            return;
+          }
+          onCancel?.();
+          break;
+      }
+    },
+    [inputText, isComposing, onCancel, onSubmit],
+  );
 
-  const blurHandler = useCallback((e) => {
-    // suppress continuous calls to submit by blur event
-    if (lastSubmittedInputText === inputText) {
-      return;
-    }
+  const blurHandler = useCallback(
+    (e) => {
+      // suppress continuous calls to submit by blur event
+      if (lastSubmittedInputText === inputText) {
+        return;
+      }
 
-    // submit on blur
-    setLastSubmittedInputText(inputText);
-    onSubmit?.(inputText.trim());
-    onBlur?.(e);
-  }, [inputText, lastSubmittedInputText, onSubmit, onBlur]);
+      // submit on blur
+      setLastSubmittedInputText(inputText);
+      onSubmit?.(inputText.trim());
+      onBlur?.(e);
+    },
+    [inputText, lastSubmittedInputText, onSubmit, onBlur],
+  );
 
-  const compositionStartHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
-    setComposing(true);
-    onCompositionStart?.(e);
-  }, [onCompositionStart]);
+  const compositionStartHandler = useCallback(
+    (e: CompositionEvent<HTMLInputElement>) => {
+      setComposing(true);
+      onCompositionStart?.(e);
+    },
+    [onCompositionStart],
+  );
 
-  const compositionEndHandler = useCallback((e: CompositionEvent<HTMLInputElement>) => {
-    setComposing(false);
-    onCompositionEnd?.(e);
-  }, [onCompositionEnd]);
+  const compositionEndHandler = useCallback(
+    (e: CompositionEvent<HTMLInputElement>) => {
+      setComposing(false);
+      onCompositionEnd?.(e);
+    },
+    [onCompositionEnd],
+  );
 
   const {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    value: _value, onSubmit: _onSubmit, onCancel: _onCancel,
+    value: _value,
+    onSubmit: _onSubmit,
+    onCancel: _onCancel,
     ...cleanedProps
   } = props;
 
@@ -84,5 +103,4 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
     onCompositionStart: compositionStartHandler,
     onCompositionEnd: compositionEndHandler,
   };
-
 };

+ 1 - 5
apps/app/src/client/components/Common/UserPictureList.jsx

@@ -1,19 +1,15 @@
 import React from 'react';
-
 import { UserPicture } from '@growi/ui/dist/components';
 import PropTypes from 'prop-types';
 
-
 export default class UserPictureList extends React.Component {
-
   render() {
-    return this.props.users.map(user => (
+    return this.props.users.map((user) => (
       <span key={user._id}>
         <UserPicture user={user} size="xs" />
       </span>
     ));
   }
-
 }
 
 UserPictureList.propTypes = {

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

+ 46 - 32
apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx

@@ -1,13 +1,12 @@
-import React, { useCallback, useMemo } from 'react';
-
+import type React from 'react';
+import { useCallback, useMemo } from 'react';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import { Modal, ModalHeader, ModalBody } from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
-import type { TargetType, LabelType } from '~/interfaces/template';
-
+import type { LabelType, TargetType } from '~/interfaces/template';
 
 type TemplateCardProps = {
   target: TargetType;
@@ -17,7 +16,10 @@ type TemplateCardProps = {
 };
 
 const TemplateCard: React.FC<TemplateCardProps> = ({
-  target, label, isPageCreating, onClickHandler,
+  target,
+  label,
+  isPageCreating,
+  onClickHandler,
 }) => {
   const { t } = useTranslation();
 
@@ -25,8 +27,12 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
     <div className="card card-select-template">
       <div className="card-header">{t(`template.${target}.label`)}</div>
       <div className="card-body">
-        <p className="text-center"><code>{label}</code></p>
-        <p className="form-text text-muted text-center"><small>{t(`template.${target}.desc`)}</small></p>
+        <p className="text-center">
+          <code>{label}</code>
+        </p>
+        <p className="form-text text-muted text-center">
+          <small>{t(`template.${target}.desc`)}</small>
+        </p>
       </div>
       <div className="card-footer text-center">
         <button
@@ -51,50 +57,58 @@ type CreateTemplateModalProps = {
 };
 
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
-  path, isOpen, onClose,
+  path,
+  isOpen,
+  onClose,
 }) => {
   const { t } = useTranslation(['translation', 'commons']);
 
   const { createTemplate, isCreating, isCreatable } = useCreateTemplatePage();
 
-  const onClickTemplateButtonHandler = useCallback(async(label: LabelType) => {
-    try {
-      await createTemplate?.(label);
-      onClose();
-    }
-    catch (err) {
-      toastError(t('toaster.create_failed', { target: path }));
-    }
-  }, [createTemplate, onClose, path, t]);
+  const onClickTemplateButtonHandler = useCallback(
+    async (label: LabelType) => {
+      try {
+        await createTemplate?.(label);
+        onClose();
+      } catch (err) {
+        toastError(t('toaster.create_failed', { target: path }));
+      }
+    },
+    [createTemplate, onClose, path, t],
+  );
 
   // Memoize computed path
   const parentPath = useMemo(() => pathUtils.addTrailingSlash(path), [path]);
 
   // Memoize template card rendering function
-  const renderTemplateCard = useCallback((target: TargetType, label: LabelType) => (
-    <div className="col">
-      <TemplateCard
-        target={target}
-        label={label}
-        isPageCreating={isCreating}
-        onClickHandler={() => onClickTemplateButtonHandler(label)}
-      />
-    </div>
-  ), [isCreating, onClickTemplateButtonHandler]);
+  const renderTemplateCard = useCallback(
+    (target: TargetType, label: LabelType) => (
+      <div className="col">
+        <TemplateCard
+          target={target}
+          label={label}
+          isPageCreating={isCreating}
+          onClickHandler={() => onClickTemplateButtonHandler(label)}
+        />
+      </div>
+    ),
+    [isCreating, onClickTemplateButtonHandler],
+  );
 
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
-      {(isCreatable && isOpen) && (
+      {isCreatable && isOpen && (
         <>
           <ModalHeader tag="h4" toggle={onClose}>
             {t('template.modal_label.Create/Edit Template Page')}
           </ModalHeader>
           <ModalBody>
             <div>
-              <label className="form-label mb-4">
-                <code>{parentPath}</code><br />
+              <div className="form-label mb-4">
+                <code>{parentPath}</code>
+                <br />
                 {t('template.modal_label.Create template under')}
-              </label>
+              </div>
               <div className="row row-cols-2">
                 {renderTemplateCard('children', '_template')}
                 {renderTemplateCard('descendants', '__template')}

+ 12 - 3
apps/app/src/client/components/CreateTemplateModal/dynamic.tsx

@@ -8,12 +8,21 @@ type CreateTemplateModalProps = {
   onClose: () => void;
 };
 
-export const CreateTemplateModalLazyLoaded = (props: CreateTemplateModalProps): JSX.Element => {
+export const CreateTemplateModalLazyLoaded = (
+  props: CreateTemplateModalProps,
+): JSX.Element => {
   const CreateTemplateModal = useLazyLoader<CreateTemplateModalProps>(
     'create-template-modal',
-    () => import('./CreateTemplateModal').then(mod => ({ default: mod.CreateTemplateModal })),
+    () =>
+      import('./CreateTemplateModal').then((mod) => ({
+        default: mod.CreateTemplateModal,
+      })),
     props.isOpen,
   );
 
-  return CreateTemplateModal != null ? <CreateTemplateModal {...props} /> : <></>;
+  return CreateTemplateModal != null ? (
+    <CreateTemplateModal {...props} />
+  ) : (
+    <></>
+  );
 };

+ 105 - 69
apps/app/src/client/components/CustomNavigation/CustomNav.tsx

@@ -1,18 +1,21 @@
 import React, {
-  useEffect, useState, useRef, useMemo, useCallback, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
-
 import type { Breakpoint } from '@growi/ui/dist/interfaces';
-import {
-  Nav, NavItem, NavLink,
-} from 'reactstrap';
+import { Nav, NavItem, NavLink } from 'reactstrap';
 
 import type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 import styles from './CustomNav.module.scss';
 
-
-function getBreakpointOneLevelLarger(breakpoint: Breakpoint): Omit<Breakpoint, 'xs' | 'sm'> {
+function getBreakpointOneLevelLarger(
+  breakpoint: Breakpoint,
+): Omit<Breakpoint, 'xs' | 'sm'> {
   switch (breakpoint) {
     case 'xs':
       return 'sm';
@@ -28,17 +31,16 @@ function getBreakpointOneLevelLarger(breakpoint: Breakpoint): Omit<Breakpoint, '
   }
 }
 
-
 type CustomNavDropdownProps = {
-  navTabMapping: ICustomNavTabMappings,
-  activeTab: string,
-  onNavSelected?: (selectedTabKey: string) => void,
+  navTabMapping: ICustomNavTabMappings;
+  activeTab: string;
+  onNavSelected?: (selectedTabKey: string) => void;
 };
 
-export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element => {
-  const {
-    activeTab, navTabMapping, onNavSelected,
-  } = props;
+export const CustomNavDropdown = (
+  props: CustomNavDropdownProps,
+): JSX.Element => {
+  const { activeTab, navTabMapping, onNavSelected } = props;
 
   const { Icon, i18n } = navTabMapping[activeTab];
 
@@ -47,19 +49,22 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   const dropdownButtonRef = useRef<HTMLButtonElement>(null);
 
   const toggleDropdown = () => {
-    setIsDropdownOpen(prev => !prev);
+    setIsDropdownOpen((prev) => !prev);
   };
 
-  const menuItemClickHandler = useCallback((key) => {
-    if (onNavSelected != null) {
-      onNavSelected(key);
-    }
-    // Manually close the dropdown
-    setIsDropdownOpen(false);
-    if (dropdownButtonRef.current) {
-      dropdownButtonRef.current.classList.remove('show');
-    }
-  }, [onNavSelected]);
+  const menuItemClickHandler = useCallback(
+    (key) => {
+      if (onNavSelected != null) {
+        onNavSelected(key);
+      }
+      // Manually close the dropdown
+      setIsDropdownOpen(false);
+      if (dropdownButtonRef.current) {
+        dropdownButtonRef.current.classList.remove('show');
+      }
+    },
+    [onNavSelected],
+  );
 
   return (
     <div className="btn-group">
@@ -74,15 +79,19 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
         data-testid="custom-nav-dropdown"
       >
         <span className="float-start">
-          { Icon != null && <Icon /> } {i18n}
+          {Icon != null && <Icon />} {i18n}
         </span>
       </button>
-      <div className={`dropdown-menu dropdown-menu-right w-100 ${isDropdownOpen ? 'show' : ''} ${styles['dropdown-menu']}`}>
+      <div
+        className={`dropdown-menu dropdown-menu-right w-100 ${isDropdownOpen ? 'show' : ''} ${styles['dropdown-menu']}`}
+      >
         {Object.entries(navTabMapping).map(([key, value]) => {
-
           const isActive = activeTab === key;
           const _isLinkEnabled = value.isLinkEnabled ?? true;
-          const isLinkEnabled = typeof _isLinkEnabled === 'boolean' ? _isLinkEnabled : _isLinkEnabled(value);
+          const isLinkEnabled =
+            typeof _isLinkEnabled === 'boolean'
+              ? _isLinkEnabled
+              : _isLinkEnabled(value);
           const { Icon, i18n } = value;
 
           return (
@@ -93,7 +102,7 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
               disabled={!isLinkEnabled}
               onClick={() => menuItemClickHandler(key)}
             >
-              { Icon != null && <Icon /> } {i18n}
+              {Icon != null && <Icon />} {i18n}
             </button>
           );
         })}
@@ -102,14 +111,13 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   );
 };
 
-
 type CustomNavTabProps = {
-  activeTab: string,
-  navTabMapping: ICustomNavTabMappings,
-  onNavSelected?: (selectedTabKey: string) => void,
-  hideBorderBottom?: boolean,
-  breakpointToHideInactiveTabsDown?: Breakpoint,
-  navRightElement?: JSX.Element,
+  activeTab: string;
+  navTabMapping: ICustomNavTabMappings;
+  onNavSelected?: (selectedTabKey: string) => void;
+  hideBorderBottom?: boolean;
+  breakpointToHideInactiveTabsDown?: Breakpoint;
+  navRightElement?: JSX.Element;
 };
 
 export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
@@ -117,9 +125,12 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
   const {
-    activeTab, navTabMapping, onNavSelected,
+    activeTab,
+    navTabMapping,
+    onNavSelected,
     hideBorderBottom,
-    breakpointToHideInactiveTabsDown, navRightElement,
+    breakpointToHideInactiveTabsDown,
+    navRightElement,
   } = props;
 
   const navContainerRef = useRef<HTMLDivElement>(null);
@@ -132,11 +143,14 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
     return obj;
   }, [navTabMapping]);
 
-  const navLinkClickHandler = useCallback((key) => {
-    if (onNavSelected != null) {
-      onNavSelected(key);
-    }
-  }, [onNavSelected]);
+  const navLinkClickHandler = useCallback(
+    (key) => {
+      if (onNavSelected != null) {
+        onNavSelected(key);
+      }
+    },
+    [onNavSelected],
+  );
 
   function registerNavLink(key: string, anchorElem: HTMLAnchorElement | null) {
     if (anchorElem != null) {
@@ -145,9 +159,9 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   }
 
   // Might make this dynamic for px, %, pt, em
-  function getPercentage(min, max) {
-    return min / max * 100;
-  }
+  const getPercentage = useCallback((min: number, max: number) => {
+    return (min / max) * 100;
+  }, []);
 
   useEffect(() => {
     if (activeTab == null || activeTab === '') {
@@ -162,7 +176,10 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
 
     let marginLeft = 0;
     for (const [key, anchorElem] of Object.entries(navTabRefs)) {
-      const width = getPercentage(anchorElem.offsetWidth, navContainer.offsetWidth);
+      const width = getPercentage(
+        anchorElem.offsetWidth,
+        navContainer.offsetWidth,
+      );
 
       if (key === activeTab) {
         setSliderWidth(width);
@@ -172,25 +189,32 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
 
       marginLeft += width;
     }
-  }, [activeTab, navTabRefs, navTabMapping]);
+  }, [activeTab, getPercentage, navTabRefs]);
 
   // determine inactive classes to hide NavItem
   const inactiveClassnames: string[] = [];
   if (breakpointToHideInactiveTabsDown != null) {
-    const breakpointOneLevelLarger = getBreakpointOneLevelLarger(breakpointToHideInactiveTabsDown);
+    const breakpointOneLevelLarger = getBreakpointOneLevelLarger(
+      breakpointToHideInactiveTabsDown,
+    );
     inactiveClassnames.push('d-none');
     inactiveClassnames.push(`d-${breakpointOneLevelLarger}-block`);
   }
 
   return (
-    <div data-testid="custom-nav-tab" className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}>
+    <div
+      data-testid="custom-nav-tab"
+      className={`grw-custom-nav-tab ${styles['grw-custom-nav-tab']}`}
+    >
       <div ref={navContainerRef} className="d-flex justify-content-between">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
-
             const isActive = activeTab === key;
             const _isLinkEnabled = value.isLinkEnabled ?? true;
-            const isLinkEnabled = typeof _isLinkEnabled === 'boolean' ? _isLinkEnabled : _isLinkEnabled(value);
+            const isLinkEnabled =
+              typeof _isLinkEnabled === 'boolean'
+                ? _isLinkEnabled
+                : _isLinkEnabled(value);
             const { Icon, i18n } = value;
 
             return (
@@ -198,8 +222,19 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
                 key={key}
                 className={`p-0 ${isActive ? 'active' : inactiveClassnames.join(' ')}`}
               >
-                <NavLink type="button" key={key} innerRef={elm => registerNavLink(key, elm)} disabled={!isLinkEnabled} onClick={() => navLinkClickHandler(key)}>
-                  { Icon != null && <span className="me-1"><Icon /></span> } {i18n}
+                <NavLink
+                  type="button"
+                  key={key}
+                  innerRef={(elm) => registerNavLink(key, elm)}
+                  disabled={!isLinkEnabled}
+                  onClick={() => navLinkClickHandler(key)}
+                >
+                  {Icon != null && (
+                    <span className="me-1">
+                      <Icon />
+                    </span>
+                  )}{' '}
+                  {i18n}
                 </NavLink>
               </NavItem>
             );
@@ -207,30 +242,32 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
         </Nav>
         {navRightElement}
       </div>
-      <hr className="my-0 grw-nav-slide-hr border-none" style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }} />
-      { !hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" /> }
+      <hr
+        className="my-0 grw-nav-slide-hr border-none"
+        style={{ width: `${sliderWidth}%`, marginLeft: `${sliderMarginLeft}%` }}
+      />
+      {!hideBorderBottom && <hr className="my-0 border-top-0 border-bottom" />}
     </div>
   );
-
 };
 
-
 type CustomNavProps = {
-  activeTab: string,
-  navTabMapping: ICustomNavTabMappings,
-  onNavSelected?: (selectedTabKey: string) => void,
-  hideBorderBottom?: boolean,
-  breakpointToHideInactiveTabsDown?: Breakpoint,
-  breakpointToSwitchDropdownDown?: Breakpoint,
+  activeTab: string;
+  navTabMapping: ICustomNavTabMappings;
+  onNavSelected?: (selectedTabKey: string) => void;
+  hideBorderBottom?: boolean;
+  breakpointToHideInactiveTabsDown?: Breakpoint;
+  breakpointToSwitchDropdownDown?: Breakpoint;
 };
 
 const CustomNav = (props: CustomNavProps): JSX.Element => {
-
   const tabClassnames = ['d-none'];
   const dropdownClassnames = ['d-block'];
 
   // determine classes to show/hide
-  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(props.breakpointToSwitchDropdownDown ?? 'sm');
+  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(
+    props.breakpointToSwitchDropdownDown ?? 'sm',
+  );
   tabClassnames.push(`d-${breakpointOneLevelLarger}-block`);
   dropdownClassnames.push(`d-${breakpointOneLevelLarger}-none`);
 
@@ -244,7 +281,6 @@ const CustomNav = (props: CustomNavProps): JSX.Element => {
       </div>
     </div>
   );
-
 };
 
 export default CustomNav;

+ 27 - 15
apps/app/src/client/components/CustomNavigation/CustomNavAndContents.tsx

@@ -1,26 +1,34 @@
 import type { ReactNode } from 'react';
-import React, { useState, type JSX } from 'react';
+import React, { type JSX, useState } from 'react';
 
-import CustomNav, { CustomNavTab, CustomNavDropdown } from './CustomNav';
+import CustomNav, { CustomNavDropdown, CustomNavTab } from './CustomNav';
 import CustomTabContent from './CustomTabContent';
 
 type CustomNavAndContentsProps = {
-  navTabMapping: any,
-  defaultTabIndex?: number,
-  navigationMode?: 'both' | 'tab' | 'dropdown',
-  tabContentClasses?: string[],
-  breakpointToHideInactiveTabsDown?: 'xs' | 'sm' | 'md' | 'lg' | 'xl',
-  navRightElement?: ReactNode
-}
-
+  navTabMapping: any;
+  defaultTabIndex?: number;
+  navigationMode?: 'both' | 'tab' | 'dropdown';
+  tabContentClasses?: string[];
+  breakpointToHideInactiveTabsDown?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
+  navRightElement?: ReactNode;
+};
 
-const CustomNavAndContents = (props: CustomNavAndContentsProps): JSX.Element => {
+const CustomNavAndContents = (
+  props: CustomNavAndContentsProps,
+): JSX.Element => {
   const {
-    navTabMapping, defaultTabIndex, navigationMode = 'tab', tabContentClasses = ['p-4'], breakpointToHideInactiveTabsDown, navRightElement,
+    navTabMapping,
+    defaultTabIndex,
+    navigationMode = 'tab',
+    tabContentClasses = ['p-4'],
+    breakpointToHideInactiveTabsDown,
+    navRightElement,
   } = props;
-  const [activeTab, setActiveTab] = useState(Object.keys(props.navTabMapping)[defaultTabIndex || 0]);
+  const [activeTab, setActiveTab] = useState(
+    Object.keys(props.navTabMapping)[defaultTabIndex || 0],
+  );
 
-  let SelectedNav;
+  let SelectedNav: (props) => JSX.Element;
   switch (navigationMode) {
     case 'tab':
       SelectedNav = CustomNavTab;
@@ -42,7 +50,11 @@ const CustomNavAndContents = (props: CustomNavAndContentsProps): JSX.Element =>
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
         navRightElement={navRightElement}
       />
-      <CustomTabContent activeTab={activeTab} navTabMapping={navTabMapping} additionalClassNames={tabContentClasses} />
+      <CustomTabContent
+        activeTab={activeTab}
+        navTabMapping={navTabMapping}
+        additionalClassNames={tabContentClasses}
+      />
     </>
   );
 };

+ 11 - 13
apps/app/src/client/components/CustomNavigation/CustomTabContent.tsx

@@ -1,28 +1,27 @@
 import React, { type JSX } from 'react';
-
-import {
-  TabContent, TabPane,
-} from 'reactstrap';
+import { TabContent, TabPane } from 'reactstrap';
 
 import type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 
-
 type Props = {
-  navTabMapping: ICustomNavTabMappings,
-  activeTab?: string,
-  additionalClassNames?: string[],
-}
+  navTabMapping: ICustomNavTabMappings;
+  activeTab?: string;
+  additionalClassNames?: string[];
+};
 
 const CustomTabContent = (props: Props): JSX.Element => {
-
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
   return (
-    <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
+    <TabContent
+      activeTab={activeTab}
+      className={
+        additionalClassNames != null ? additionalClassNames.join(' ') : ''
+      }
+    >
       {Object.entries(navTabMapping).map(([key, value]) => {
-
         const { Content } = value;
         const content = Content != null ? <Content /> : <></>;
 
@@ -36,7 +35,6 @@ const CustomTabContent = (props: Props): JSX.Element => {
       })}
     </TabContent>
   );
-
 };
 
 export default CustomTabContent;

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

+ 24 - 14
apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx

@@ -1,17 +1,16 @@
-
 import type { FC } from 'react';
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalBody, ModalFooter, ModalHeader,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
-import { useDeleteBookmarkFolderModalStatus, useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
+import {
+  useDeleteBookmarkFolderModalActions,
+  useDeleteBookmarkFolderModalStatus,
+} from '~/states/ui/modal/delete-bookmark-folder';
 
 /**
  * DeleteBookmarkFolderModalSubstance - Presentation component (all logic here)
@@ -29,20 +28,19 @@ const DeleteBookmarkFolderModalSubstance = ({
 }: DeleteBookmarkFolderModalSubstanceProps): React.JSX.Element => {
   const { t } = useTranslation();
 
-  const deleteBookmark = useCallback(async() => {
+  const deleteBookmark = useCallback(async () => {
     try {
       await deleteBookmarkFolder(bookmarkFolder._id);
       if (onDeleted != null) {
         onDeleted(bookmarkFolder._id);
       }
       closeModal();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [bookmarkFolder, onDeleted, closeModal]);
 
-  const onClickDeleteButton = useCallback(async() => {
+  const onClickDeleteButton = useCallback(async () => {
     await deleteBookmark();
   }, [deleteBookmark]);
 
@@ -54,7 +52,10 @@ const DeleteBookmarkFolderModalSubstance = ({
       </ModalHeader>
       <ModalBody>
         <div className="pb-1 text-break">
-          <label className="form-label">{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
+          <span className="form-label">
+            {t('bookmark_folder.delete_modal.modal_body_description')}:
+          </span>
+          <br />
           <FolderIcon isOpen={false} /> {bookmarkFolder?.name}
         </div>
         {t('bookmark_folder.delete_modal.modal_body_alert')}
@@ -65,7 +66,9 @@ const DeleteBookmarkFolderModalSubstance = ({
           className="btn btn-danger"
           onClick={onClickDeleteButton}
         >
-          <span className="material-symbols-outlined" aria-hidden="true">delete</span>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            delete
+          </span>
           {t('bookmark_folder.delete_modal.modal_footer_button')}
         </button>
       </ModalFooter>
@@ -77,11 +80,18 @@ const DeleteBookmarkFolderModalSubstance = ({
  * DeleteBookmarkFolderModal - Container component (lightweight, always rendered)
  */
 const DeleteBookmarkFolderModal: FC = () => {
-  const { isOpened, bookmarkFolder, opts } = useDeleteBookmarkFolderModalStatus();
+  const { isOpened, bookmarkFolder, opts } =
+    useDeleteBookmarkFolderModalStatus();
   const { close: closeModal } = useDeleteBookmarkFolderModalActions();
 
   return (
-    <Modal size="md" isOpen={isOpened} toggle={closeModal} data-testid="page-delete-modal" className="grw-create-page">
+    <Modal
+      size="md"
+      isOpen={isOpened}
+      toggle={closeModal}
+      data-testid="page-delete-modal"
+      className="grw-create-page"
+    >
       {isOpened && bookmarkFolder != null && (
         <DeleteBookmarkFolderModalSubstance
           bookmarkFolder={bookmarkFolder}

+ 9 - 5
apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx

@@ -8,11 +8,15 @@ type DeleteBookmarkFolderModalProps = Record<string, unknown>;
 export const DeleteBookmarkFolderModalLazyLoaded = (): JSX.Element => {
   const status = useDeleteBookmarkFolderModalStatus();
 
-  const DeleteBookmarkFolderModal = useLazyLoader<DeleteBookmarkFolderModalProps>(
-    'delete-bookmark-folder-modal',
-    () => import('./DeleteBookmarkFolderModal').then(mod => ({ default: mod.DeleteBookmarkFolderModal })),
-    status?.isOpened ?? false,
-  );
+  const DeleteBookmarkFolderModal =
+    useLazyLoader<DeleteBookmarkFolderModalProps>(
+      'delete-bookmark-folder-modal',
+      () =>
+        import('./DeleteBookmarkFolderModal').then((mod) => ({
+          default: mod.DeleteBookmarkFolderModal,
+        })),
+      status?.isOpened ?? false,
+    );
 
   return DeleteBookmarkFolderModal ? <DeleteBookmarkFolderModal /> : <></>;
 };

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

+ 4 - 4
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx

@@ -1,4 +1,4 @@
-import { render, screen, fireEvent } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
 
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
@@ -33,7 +33,9 @@ vi.mock('~/states/context', () => ({
 }));
 
 vi.mock('../DescendantsPageList', () => ({
-  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+  DescendantsPageList: () => (
+    <div data-testid="descendants-page-list">DescendantsPageList</div>
+  ),
 }));
 
 vi.mock('../PageTimeline', () => ({
@@ -41,7 +43,6 @@ vi.mock('../PageTimeline', () => ({
 }));
 
 describe('DescendantsPageListModal.tsx', () => {
-
   it('should render the modal when isOpened is true', () => {
     render(<DescendantsPageListModal />);
     expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
@@ -55,7 +56,6 @@ describe('DescendantsPageListModal.tsx', () => {
   });
 
   describe('when device is larger than lg', () => {
-
     it('should render CustomNavTab', () => {
       render(<DescendantsPageListModal />);
       expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();

+ 72 - 36
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -1,18 +1,16 @@
-
-import React, {
-  useState, useMemo, useEffect, useCallback,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
+import { useTranslation } from 'next-i18next';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
 import { useIsSharedUser } from '~/states/context';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
-import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+import {
+  useDescendantsPageListModalActions,
+  useDescendantsPageListModalStatus,
+} from '~/states/ui/modal/descendants-page-list';
 
 import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -21,9 +19,38 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import styles from './DescendantsPageListModal.module.scss';
 
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('../DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+const DescendantsPageList = dynamic<DescendantsPageListProps>(
+  () => import('../DescendantsPageList').then((mod) => mod.DescendantsPageList),
+  { ssr: false },
+);
+
+const PageTimeline = dynamic(
+  () => import('../PageTimeline').then((mod) => mod.PageTimeline),
+  { ssr: false },
+);
+
+const PageListTabIcon = (): React.JSX.Element => (
+  <span className="material-symbols-outlined">subject</span>
+);
+
+const PageListTabContent = (): React.JSX.Element => {
+  const status = useDescendantsPageListModalStatus();
+  const path = status?.path;
+
+  if (path == null) {
+    return <></>;
+  }
+
+  return <DescendantsPageList path={path} />;
+};
+
+const TimelineTabIcon = (): React.JSX.Element => (
+  <span data-testid="timeline-tab-button" className="material-symbols-outlined">
+    timeline
+  </span>
+);
 
-const PageTimeline = dynamic(() => import('../PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+const TimelineTabContent = (): React.JSX.Element => <PageTimeline />;
 
 /**
  * DescendantsPageListModalSubstance - Presentation component (all logic here)
@@ -58,26 +85,19 @@ const DescendantsPageListModalSubstance = ({
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => {
-          if (path == null) {
-            return <></>;
-          }
-          return <DescendantsPageList path={path} />;
-        },
+        Icon: PageListTabIcon,
+        Content: PageListTabContent,
         i18n: t('page_list'),
         isLinkEnabled: () => !isSharedUser,
       },
       timeline: {
-        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
-        Content: () => {
-          return <PageTimeline />;
-        },
+        Icon: TimelineTabIcon,
+        Content: TimelineTabContent,
         i18n: t('Timeline View'),
         isLinkEnabled: () => !isSharedUser,
       },
     };
-  }, [isSharedUser, path, t]);
+  }, [isSharedUser, t]);
 
   // Memoize event handlers
   const expandWindow = useCallback(() => {
@@ -90,20 +110,32 @@ const DescendantsPageListModalSubstance = ({
   }, [onExpandedChange]);
   const onNavSelected = useCallback((v: string) => setActiveTab(v), []);
 
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={closeModal} aria-label="Close"></button>
-    </span>
-  ), [closeModal, isWindowExpanded, expandWindow, contractWindow]);
+  const buttons = useMemo(
+    () => (
+      <span className="me-3">
+        <ExpandOrContractButton
+          isWindowExpanded={isWindowExpanded}
+          expandWindow={expandWindow}
+          contractWindow={contractWindow}
+        />
+        <button
+          type="button"
+          className="btn btn-close ms-2"
+          onClick={closeModal}
+          aria-label="Close"
+        ></button>
+      </span>
+    ),
+    [closeModal, isWindowExpanded, expandWindow, contractWindow],
+  );
 
   return (
     <div>
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
+      <ModalHeader
+        className={isDeviceLargerThanLg ? 'p-0' : ''}
+        toggle={closeModal}
+        close={buttons}
+      >
         {isDeviceLargerThanLg && (
           <CustomNavTab
             activeTab={activeTab}
@@ -125,7 +157,11 @@ const DescendantsPageListModalSubstance = ({
         <CustomTabContent
           activeTab={activeTab}
           navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+          additionalClassNames={
+            !isDeviceLargerThanLg
+              ? ['grw-tab-content-style-md-down']
+              : undefined
+          }
         />
       </ModalBody>
     </div>

+ 4 - 1
apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx

@@ -10,7 +10,10 @@ export const DescendantsPageListModalLazyLoaded = (): JSX.Element => {
 
   const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
     'descendants-page-list-modal',
-    () => import('./DescendantsPageListModal').then(mod => ({ default: mod.DescendantsPageListModal })),
+    () =>
+      import('./DescendantsPageListModal').then((mod) => ({
+        default: mod.DescendantsPageListModal,
+      })),
     status?.isOpened ?? false,
   );
 

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

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

@@ -1,14 +1,15 @@
+import type React from 'react';
 import type { FC } from 'react';
-import React, { useState, useCallback, useMemo } from 'react';
-
+import { useCallback, useMemo, useState } from 'react';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { apiv3Delete } from '~/client/util/apiv3-client';
-import { useEmptyTrashModalStatus, useEmptyTrashModalActions } from '~/states/ui/modal/empty-trash';
+import {
+  useEmptyTrashModalActions,
+  useEmptyTrashModalStatus,
+} from '~/states/ui/modal/empty-trash';
 
 import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
 
@@ -32,7 +33,7 @@ const EmptyTrashModalSubstance = ({
 
   const [errs, setErrs] = useState<Error[] | null>(null);
 
-  const emptyTrash = useCallback(async() => {
+  const emptyTrash = useCallback(async () => {
     if (pages == null) {
       return;
     }
@@ -43,22 +44,21 @@ const EmptyTrashModalSubstance = ({
         onEmptiedTrash();
       }
       closeModal();
-    }
-    catch (err) {
+    } catch (err) {
       setErrs([err]);
     }
   }, [pages, onEmptiedTrash, closeModal]);
 
-  const emptyTrashButtonHandler = useCallback(async() => {
+  const emptyTrashButtonHandler = useCallback(async () => {
     await emptyTrash();
   }, [emptyTrash]);
 
   // Memoize page paths rendering
   const renderPagePaths = useMemo(() => {
     if (pages != null) {
-      return pages.map(page => (
+      return pages.map((page) => (
         <p key={page.data._id} className="mb-1">
-          <code>{ page.data.path }</code>
+          <code>{page.data.path}</code>
         </p>
       ));
     }
@@ -73,11 +73,13 @@ const EmptyTrashModalSubstance = ({
       </ModalHeader>
       <ModalBody>
         <div className="grw-scrollable-modal-body pb-1">
-          <label className="form-label">{ t('modal_delete.deleting_page') }:</label><br />
+          <span className="form-label">{t('modal_delete.deleting_page')}:</span>
+          <br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {renderPagePaths}
         </div>
-        {!canDeleteAllPages && t('modal_empty.not_deletable_notice')}<br />
+        {!canDeleteAllPages && t('modal_empty.not_deletable_notice')}
+        <br />
         {t('modal_empty.notice')}
       </ModalBody>
       <ModalFooter>
@@ -87,7 +89,9 @@ const EmptyTrashModalSubstance = ({
           className="btn btn-danger"
           onClick={emptyTrashButtonHandler}
         >
-          <span className="material-symbols-outlined" aria-hidden="true">delete_forever</span>
+          <span className="material-symbols-outlined" aria-hidden="true">
+            delete_forever
+          </span>
           {t('modal_empty.empty_the_trash_button')}
         </button>
       </ModalFooter>
@@ -103,7 +107,12 @@ export const EmptyTrashModal: FC = () => {
   const { close: closeModal } = useEmptyTrashModalActions();
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeModal} data-testid="page-delete-modal">
+    <Modal
+      size="lg"
+      isOpen={isOpened}
+      toggle={closeModal}
+      data-testid="page-delete-modal"
+    >
       {isOpened && (
         <EmptyTrashModalSubstance
           pages={pages}

+ 4 - 1
apps/app/src/client/components/EmptyTrashModal/dynamic.tsx

@@ -11,7 +11,10 @@ export const EmptyTrashModalLazyLoaded = (): JSX.Element => {
 
   const EmptyTrashModal = useLazyLoader<EmptyTrashModalProps>(
     'empty-trash-modal',
-    () => import('./EmptyTrashModal').then(mod => ({ default: mod.EmptyTrashModal })),
+    () =>
+      import('./EmptyTrashModal').then((mod) => ({
+        default: mod.EmptyTrashModal,
+      })),
     status?.isOpened ?? false,
   );
 

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

+ 48 - 22
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx

@@ -1,35 +1,46 @@
-import { useState, useCallback } from 'react';
-
+import { useCallback, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import {
-  useGrantedGroupsInheritanceSelectModalActions, useGrantedGroupsInheritanceSelectModalStatus,
+  useGrantedGroupsInheritanceSelectModalActions,
+  useGrantedGroupsInheritanceSelectModalStatus,
 } from '~/states/ui/modal/granted-groups-inheritance-select';
 
 /**
  * GrantedGroupsInheritanceSelectModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
 type GrantedGroupsInheritanceSelectModalSubstanceProps = {
-  onCreateBtnClick: ((onlyInheritUserRelatedGrantedGroups: boolean) => Promise<void>) | undefined;
+  onCreateBtnClick:
+    | ((onlyInheritUserRelatedGrantedGroups: boolean) => Promise<void>)
+    | undefined;
   closeModal: () => void;
 };
 
-const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheritanceSelectModalSubstanceProps): React.JSX.Element => {
+const GrantedGroupsInheritanceSelectModalSubstance = (
+  props: GrantedGroupsInheritanceSelectModalSubstanceProps,
+): React.JSX.Element => {
   const { onCreateBtnClick: _onCreateBtnClick, closeModal } = props;
   const { t } = useTranslation();
 
-  const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
+  const [
+    onlyInheritUserRelatedGrantedGroups,
+    setOnlyInheritUserRelatedGrantedGroups,
+  ] = useState(false);
 
-  const onCreateBtnClick = useCallback(async() => {
+  const onCreateBtnClick = useCallback(async () => {
     await _onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
     setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
   }, [_onCreateBtnClick, onlyInheritUserRelatedGrantedGroups]);
 
-  const setInheritAll = useCallback(() => setOnlyInheritUserRelatedGrantedGroups(false), []);
-  const setInheritRelatedOnly = useCallback(() => setOnlyInheritUserRelatedGrantedGroups(true), []);
+  const setInheritAll = useCallback(
+    () => setOnlyInheritUserRelatedGrantedGroups(false),
+    [],
+  );
+  const setInheritRelatedOnly = useCallback(
+    () => setOnlyInheritUserRelatedGrantedGroups(true),
+    [],
+  );
 
   return (
     <>
@@ -48,7 +59,9 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
               onChange={setInheritAll}
             />
             <label className="form-check-label" htmlFor="inheritAllGroupsRadio">
-              {t('modal_granted_groups_inheritance_select.inherit_all_granted_groups_from_parent')}
+              {t(
+                'modal_granted_groups_inheritance_select.inherit_all_granted_groups_from_parent',
+              )}
             </label>
           </div>
           <div className="form-check radio-primary">
@@ -60,15 +73,30 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
               checked={onlyInheritUserRelatedGrantedGroups}
               onChange={setInheritRelatedOnly}
             />
-            <label className="form-check-label" htmlFor="onlyInheritRelatedGroupsRadio">
-              {t('modal_granted_groups_inheritance_select.only_inherit_related_groups')}
+            <label
+              className="form-check-label"
+              htmlFor="onlyInheritRelatedGroupsRadio"
+            >
+              {t(
+                'modal_granted_groups_inheritance_select.only_inherit_related_groups',
+              )}
             </label>
           </div>
         </div>
       </ModalBody>
       <ModalFooter className="grw-modal-footer">
-        <button type="button" className="me-2 btn btn-secondary" onClick={() => closeModal()}>{t('Cancel')}</button>
-        <button className="btn btn-primary" type="button" onClick={onCreateBtnClick}>
+        <button
+          type="button"
+          className="me-2 btn btn-secondary"
+          onClick={() => closeModal()}
+        >
+          {t('Cancel')}
+        </button>
+        <button
+          className="btn btn-primary"
+          type="button"
+          onClick={onCreateBtnClick}
+        >
           {t('modal_granted_groups_inheritance_select.create_page')}
         </button>
       </ModalFooter>
@@ -80,14 +108,12 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
  * GrantedGroupsInheritanceSelectModal - Container component (lightweight, always rendered)
  */
 export const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
-  const { isOpened, onCreateBtnClick } = useGrantedGroupsInheritanceSelectModalStatus();
+  const { isOpened, onCreateBtnClick } =
+    useGrantedGroupsInheritanceSelectModalStatus();
   const { close: closeModal } = useGrantedGroupsInheritanceSelectModalActions();
 
   return (
-    <Modal
-      isOpen={isOpened}
-      toggle={() => closeModal()}
-    >
+    <Modal isOpen={isOpened} toggle={() => closeModal()}>
       {isOpened && (
         <GrantedGroupsInheritanceSelectModalSubstance
           onCreateBtnClick={onCreateBtnClick}

+ 18 - 9
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/dynamic.tsx

@@ -5,14 +5,23 @@ import { useGrantedGroupsInheritanceSelectModalStatus } from '~/states/ui/modal/
 
 type GrantedGroupsInheritanceSelectModalProps = Record<string, unknown>;
 
-export const GrantedGroupsInheritanceSelectModalLazyLoaded = (): JSX.Element => {
-  const status = useGrantedGroupsInheritanceSelectModalStatus();
+export const GrantedGroupsInheritanceSelectModalLazyLoaded =
+  (): JSX.Element => {
+    const status = useGrantedGroupsInheritanceSelectModalStatus();
 
-  const GrantedGroupsInheritanceSelectModal = useLazyLoader<GrantedGroupsInheritanceSelectModalProps>(
-    'granted-groups-inheritance-select-modal',
-    () => import('./GrantedGroupsInheritanceSelectModal').then(mod => ({ default: mod.GrantedGroupsInheritanceSelectModal })),
-    status?.isOpened ?? false,
-  );
+    const GrantedGroupsInheritanceSelectModal =
+      useLazyLoader<GrantedGroupsInheritanceSelectModalProps>(
+        'granted-groups-inheritance-select-modal',
+        () =>
+          import('./GrantedGroupsInheritanceSelectModal').then((mod) => ({
+            default: mod.GrantedGroupsInheritanceSelectModal,
+          })),
+        status?.isOpened ?? false,
+      );
 
-  return GrantedGroupsInheritanceSelectModal ? <GrantedGroupsInheritanceSelectModal /> : <></>;
-};
+    return GrantedGroupsInheritanceSelectModal ? (
+      <GrantedGroupsInheritanceSelectModal />
+    ) : (
+      <></>
+    );
+  };

+ 22 - 27
apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx

@@ -1,22 +1,17 @@
-import React, { useMemo, useCallback } from 'react';
-
+import React, { useCallback, useMemo } from 'react';
 import PropTypes from 'prop-types';
 import { GlobalHotKeys } from 'react-hotkeys';
 
 import HotkeyStroke from '~/client/models/HotkeyStroke';
 
 const HotkeysDetector = (props) => {
-
   const { keySet, strokeSet, onDetected } = props;
 
   // memorize HotkeyStroke instances
-  const hotkeyStrokes = useMemo(
-    () => {
-      const strokes = Array.from(strokeSet);
-      return strokes.map(stroke => new HotkeyStroke(stroke));
-    },
-    [strokeSet],
-  );
+  const hotkeyStrokes = useMemo(() => {
+    const strokes = Array.from(strokeSet);
+    return strokes.map((stroke) => new HotkeyStroke(stroke));
+  }, [strokeSet]);
 
   /**
    * return key expression string includes modifier
@@ -43,19 +38,22 @@ const HotkeysDetector = (props) => {
   /**
    * evaluate the key user pressed and trigger onDetected
    */
-  const checkHandler = useCallback((event) => {
-    const eventKey = getKeyExpression(event);
-
-    hotkeyStrokes.forEach((hotkeyStroke) => {
-      // if any stroke is completed
-      if (hotkeyStroke.evaluate(eventKey)) {
-        // cancel the key event
-        event.preventDefault();
-        // invoke detected handler
-        onDetected(hotkeyStroke.stroke);
-      }
-    });
-  }, [hotkeyStrokes, getKeyExpression, onDetected]);
+  const checkHandler = useCallback(
+    (event) => {
+      const eventKey = getKeyExpression(event);
+
+      hotkeyStrokes.forEach((hotkeyStroke) => {
+        // if any stroke is completed
+        if (hotkeyStroke.evaluate(eventKey)) {
+          // cancel the key event
+          event.preventDefault();
+          // invoke detected handler
+          onDetected(hotkeyStroke.stroke);
+        }
+      });
+    },
+    [hotkeyStrokes, getKeyExpression, onDetected],
+  );
 
   // memorize keyMap for GlobalHotKeys
   const keyMap = useMemo(() => {
@@ -67,10 +65,7 @@ const HotkeysDetector = (props) => {
     return { check: checkHandler };
   }, [checkHandler]);
 
-  return (
-    <GlobalHotKeys keyMap={keyMap} handlers={handlers} />
-  );
-
+  return <GlobalHotKeys keyMap={keyMap} handlers={handlers} />;
 };
 
 HotkeysDetector.propTypes = {

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

@@ -27,7 +27,9 @@ SUPPORTED_COMPONENTS.forEach((comp) => {
 
   strokes.forEach((stroke) => {
     // register key
-    stroke.forEach(key => KEY_SET.add(key));
+    stroke.forEach((key) => {
+      KEY_SET.add(key);
+    });
     // register stroke
     STROKE_SET.add(stroke);
     // register component
@@ -58,7 +60,7 @@ const HotkeysManager = (props) => {
     const key = (Math.random() * 1000).toString();
     const components = STROKE_TO_COMPONENT_MAP[strokeDetermined.toString()];
 
-    const newViews = components.map(Component => (
+    const newViews = components.map((Component) => (
       <Component key={key} onDeleteRender={deleteRender} />
     ));
     setView(view.concat(newViews).flat());
@@ -67,14 +69,13 @@ const HotkeysManager = (props) => {
   return (
     <>
       <HotkeysDetector
-        onDetected={stroke => onDetected(stroke)}
+        onDetected={(stroke) => onDetected(stroke)}
         keySet={KEY_SET}
         strokeSet={STROKE_SET}
       />
       {view}
     </>
   );
-
 };
 
 export default HotkeysManager;

+ 0 - 2
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -1,12 +1,10 @@
 import React, { useEffect } from 'react';
-
 import PropTypes from 'prop-types';
 
 import { useCurrentPagePath } from '~/states/page';
 import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
 
 const CreatePage = React.memo((props) => {
-
   const { open: openCreateModal } = usePageCreateModalActions();
   const currentPath = useCurrentPagePath();
 

+ 13 - 12
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -1,22 +1,21 @@
 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';
+import { useCurrentPagePath, useIsEditable } from '~/states/page';
 
 type Props = {
-  onDeleteRender: () => void,
-}
+  onDeleteRender: () => void;
+};
 
 /**
  * Custom hook for edit page logic
  */
 const useEditPage = (
-    onCompleted: () => void,
-    onError?: (path: string) => void,
+  onCompleted: () => void,
+  onError?: (path: string) => void,
 ): void => {
   const isEditable = useIsEditable();
   const startEditing = useStartEditing();
@@ -26,7 +25,7 @@ const useEditPage = (
   const isExecutedRef = useRef(false);
 
   useEffect(() => {
-    (async() => {
+    (async () => {
       // Prevent multiple executions
       if (isExecutedRef.current) return;
       isExecutedRef.current = true;
@@ -42,8 +41,7 @@ const useEditPage = (
 
       try {
         await startEditing(path);
-      }
-      catch (err) {
+      } catch (err) {
         onError?.(path);
       }
 
@@ -58,9 +56,12 @@ const useEditPage = (
 const EditPage = (props: Props): null => {
   const { t } = useTranslation('commons');
 
-  const handleError = useCallback((path: string) => {
-    toastError(t('toaster.create_failed', { target: path }));
-  }, [t]);
+  const handleError = useCallback(
+    (path: string) => {
+      toastError(t('toaster.create_failed', { target: path }));
+    },
+    [t],
+  );
 
   useEditPage(props.onDeleteRender, handleError);
 

+ 4 - 3
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -1,9 +1,11 @@
 import { useEffect } from 'react';
 
-import { useSearchModalStatus, useSearchModalActions } from '~/features/search/client/states/modal/search';
+import {
+  useSearchModalActions,
+  useSearchModalStatus,
+} from '~/features/search/client/states/modal/search';
 import { useIsEditable } from '~/states/page';
 
-
 const FocusToGlobalSearch = (props) => {
   const isEditable = useIsEditable();
   const searchModalData = useSearchModalStatus();
@@ -20,7 +22,6 @@ const FocusToGlobalSearch = (props) => {
       // remove this
       props.onDeleteRender();
     }
-
   }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
 
   return null;

+ 7 - 5
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx

@@ -1,12 +1,14 @@
-import React, { useEffect, type JSX } from 'react';
+import React, { type JSX, useEffect } from 'react';
 
-import { useShortcutsModalStatus, useShortcutsModalActions } from '~/states/ui/modal/shortcuts';
+import {
+  useShortcutsModalActions,
+  useShortcutsModalStatus,
+} from '~/states/ui/modal/shortcuts';
 
 type Props = {
-  onDeleteRender: () => void,
-}
+  onDeleteRender: () => void;
+};
 const ShowShortcutsModal = (props: Props): JSX.Element => {
-
   const status = useShortcutsModalStatus();
   const { open } = useShortcutsModalActions();
 

+ 14 - 3
apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx

@@ -3,9 +3,7 @@ import PropTypes from 'prop-types';
 import StaffCredit from '../../StaffCredit/StaffCredit';
 
 const ShowStaffCredit = (props) => {
-
   return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
-
 };
 
 ShowStaffCredit.propTypes = {
@@ -13,7 +11,20 @@ ShowStaffCredit.propTypes = {
 };
 
 ShowStaffCredit.getHotkeyStrokes = () => {
-  return [['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a']];
+  return [
+    [
+      'ArrowUp',
+      'ArrowUp',
+      'ArrowDown',
+      'ArrowDown',
+      'ArrowLeft',
+      'ArrowRight',
+      'ArrowLeft',
+      'ArrowRight',
+      'b',
+      'a',
+    ],
+  ];
 };
 
 export default ShowStaffCredit;

+ 0 - 2
apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx

@@ -1,9 +1,7 @@
 import React, { useEffect } from 'react';
-
 import PropTypes from 'prop-types';
 
 const SwitchToMirrorMode = (props) => {
-
   // setup effect
   useEffect(() => {
     document.body.classList.add('mirror');

+ 3 - 6
apps/app/src/client/components/Icons/FolderIcon.tsx

@@ -1,8 +1,8 @@
 import React, { type JSX } from 'react';
 
 type Props = {
-  isOpen: boolean
-}
+  isOpen: boolean;
+};
 export const FolderIcon = (props: Props): JSX.Element => {
   const { isOpen } = props;
 
@@ -10,12 +10,9 @@ export const FolderIcon = (props: Props): JSX.Element => {
     <>
       {!isOpen ? (
         <span className="material-symbols-outlined">folder</span>
-
       ) : (
         <span className="material-symbols-outlined">folder_open</span>
-      )
-      }
+      )}
     </>
   );
-
 };

+ 1 - 2
apps/app/src/client/components/Icons/RecentlyCreatedIcon.tsx

@@ -7,9 +7,8 @@ export const RecentlyCreatedIcon = (): JSX.Element => (
     height="20"
     viewBox="0 0 20 20"
   >
-
+    <title>Recently created</title>
     <g transform="translate(-921.906 192.966)">
-
       <rect
         width="20"
         height="20"

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

+ 51 - 31
apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -1,15 +1,18 @@
-import React, {
-  useState, useEffect, useRef, type JSX,
-} from 'react';
-
+import React, { type JSX, useEffect, useRef, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import { useRipple } from 'react-use-ripple';
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 
 import { useGlobalSocket } from '~/states/socket-io';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import {
+  useSWRxInAppNotificationStatus,
+  useSWRxInAppNotifications,
+} from '~/stores/in-app-notification';
 
 import InAppNotificationList from './InAppNotificationList';
 
@@ -20,11 +23,14 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const limit = 6;
 
   const socket = useGlobalSocket();
-  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
-    limit, undefined, undefined,
-    { revalidateOnFocus: isOpen },
-  );
-  const { data: inAppNotificationUnreadStatusCount, mutate: mutateInAppNotificationUnreadStatusCount } = useSWRxInAppNotificationStatus();
+  const { data: inAppNotificationData, mutate: mutateInAppNotificationData } =
+    useSWRxInAppNotifications(limit, undefined, undefined, {
+      revalidateOnFocus: isOpen,
+    });
+  const {
+    data: inAppNotificationUnreadStatusCount,
+    mutate: mutateInAppNotificationUnreadStatusCount,
+  } = useSWRxInAppNotificationStatus();
 
   // ripple
   const buttonRef = useRef(null);
@@ -43,9 +49,12 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     }
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
 
-
-  const toggleDropdownHandler = async() => {
-    if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+  const toggleDropdownHandler = async () => {
+    if (
+      !isOpen &&
+      inAppNotificationUnreadStatusCount != null &&
+      inAppNotificationUnreadStatusCount > 0
+    ) {
       mutateInAppNotificationUnreadStatusCount();
     }
 
@@ -56,34 +65,45 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     setIsOpen(newIsOpenState);
   };
 
-  let badge;
-  if (inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
-    badge = <span className="badge rounded-pill bg-danger grw-notification-badge">{inAppNotificationUnreadStatusCount}</span>;
-  }
-  else {
-    badge = '';
-  }
+  const badge =
+    inAppNotificationUnreadStatusCount != null &&
+    inAppNotificationUnreadStatusCount > 0 ? (
+      <span className="badge rounded-pill bg-danger grw-notification-badge">
+        {inAppNotificationUnreadStatusCount}
+      </span>
+    ) : null;
 
   return (
-    <Dropdown className="notification-wrapper grw-notification-dropdown" isOpen={isOpen} toggle={toggleDropdownHandler} direction="end">
+    <Dropdown
+      className="notification-wrapper grw-notification-dropdown"
+      isOpen={isOpen}
+      toggle={toggleDropdownHandler}
+      direction="end"
+    >
       <DropdownToggle className="px-3" color="primary" innerRef={buttonRef}>
         <span className="material-symbols-outlined">notifications</span> {badge}
       </DropdownToggle>
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu end>
-          { inAppNotificationData != null && inAppNotificationData.docs.length === 0
-          // no items
-            ? <DropdownItem disabled>{t('in_app_notification.no_unread_messages')}</DropdownItem>
-          // render DropdownItem
-            : <InAppNotificationList inAppNotificationData={inAppNotificationData} />
-          }
+          {inAppNotificationData != null &&
+          inAppNotificationData.docs.length === 0 ? (
+            // no items
+            <DropdownItem disabled>
+              {t('in_app_notification.no_unread_messages')}
+            </DropdownItem>
+          ) : (
+            // render DropdownItem
+            <InAppNotificationList
+              inAppNotificationData={inAppNotificationData}
+            />
+          )}
           <DropdownItem divider />
           <DropdownItem tag="a" href="/me/all-in-app-notifications">
-            { t('in_app_notification.see_all') }
+            {t('in_app_notification.see_all')}
           </DropdownItem>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
   );
 };

+ 14 - 12
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,6 +1,5 @@
 import type { FC, JSX } from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 
@@ -12,12 +11,11 @@ import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useModelNotification } from './ModelNotification';
 
 interface Props {
-  notification: IInAppNotification & HasObjectId
-  onUnopenedNotificationOpend?: () => void,
+  notification: IInAppNotification & HasObjectId;
+  onUnopenedNotificationOpend?: () => void;
 }
 
 const InAppNotificationElm: FC<Props> = (props: Props) => {
-
   const { notification, onUnopenedNotificationOpend } = props;
 
   const modelNotificationUtils = useModelNotification(notification);
@@ -32,7 +30,9 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
     return <></>;
   }
 
-  const clickHandler = async(notification: IInAppNotification & HasObjectId): Promise<void> => {
+  const clickHandler = async (
+    notification: IInAppNotification & HasObjectId,
+  ): Promise<void> => {
     if (notification.status === InAppNotificationStatuses.STATUS_UNOPENED) {
       // set notification status "OPEND"
       await apiv3Post('/in-app-notification/open', { id: notification._id });
@@ -65,24 +65,26 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
   };
 
   return (
-    <div className="list-group-item list-group-item-action" style={{ cursor: 'pointer' }}>
+    <div
+      className="list-group-item list-group-item-action"
+      style={{ cursor: 'pointer' }}
+    >
       <a
         href={isDisabled ? undefined : clickLink}
         onClick={() => clickHandler(notification)}
       >
         <div className="d-flex align-items-center">
           <span
-            className={`${notification.status === InAppNotificationStatuses.STATUS_UNOPENED
-              ? 'grw-unopend-notification'
-              : 'ms-2'
+            className={`${
+              notification.status === InAppNotificationStatuses.STATUS_UNOPENED
+                ? 'grw-unopend-notification'
+                : 'ms-2'
             } rounded-circle me-3`}
-          >
-          </span>
+          ></span>
 
           {renderActionUserPictures()}
 
           <Notification />
-
         </div>
       </a>
     </div>

+ 8 - 9
apps/app/src/client/components/InAppNotification/InAppNotificationList.tsx

@@ -1,18 +1,18 @@
 import type { FC } from 'react';
 import React from 'react';
-
 import type { HasObjectId } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
-import type { IInAppNotification, PaginateResult } from '~/interfaces/in-app-notification';
-
+import type {
+  IInAppNotification,
+  PaginateResult,
+} from '~/interfaces/in-app-notification';
 
 import InAppNotificationElm from './InAppNotificationElm';
 
-
 type Props = {
-  inAppNotificationData?: PaginateResult<IInAppNotification>,
-  onUnopenedNotificationOpend?: () => void,
+  inAppNotificationData?: PaginateResult<IInAppNotification>;
+  onUnopenedNotificationOpend?: () => void;
 };
 
 const InAppNotificationList: FC<Props> = (props: Props) => {
@@ -32,7 +32,7 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
 
   return (
     <div className="list-group">
-      { notifications.map((notification: IInAppNotification & HasObjectId) => {
+      {notifications.map((notification: IInAppNotification & HasObjectId) => {
         return (
           <InAppNotificationElm
             key={notification._id}
@@ -40,10 +40,9 @@ const InAppNotificationList: FC<Props> = (props: Props) => {
             onUnopenedNotificationOpend={onUnopenedNotificationOpend}
           />
         );
-      }) }
+      })}
     </div>
   );
 };
 
-
 export default InAppNotificationList;

+ 105 - 80
apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
@@ -8,117 +7,143 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { showPageLimitationXLAtom } from '~/states/server-configurations';
-import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
+import {
+  useSWRxInAppNotificationStatus,
+  useSWRxInAppNotifications,
+} from '~/stores/in-app-notification';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import PaginationWrapper from '../PaginationWrapper';
-
 import InAppNotificationList from './InAppNotificationList';
 
-export const InAppNotificationPage: FC = () => {
+type InAppNotificationCategoryByStatusProps = {
+  status?: InAppNotificationStatuses;
+};
+
+const EmptyIcon: FC = () => {
+  return null;
+};
+
+const InAppNotificationCategoryByStatus: FC<
+  InAppNotificationCategoryByStatusProps
+> = ({ status }) => {
   const { t } = useTranslation('commons');
 
   const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
-
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
 
-  const InAppNotificationCategoryByStatus = (status?: InAppNotificationStatuses) => {
-    const [activePage, setActivePage] = useState(1);
-    const offset = (activePage - 1) * limit;
-
-    let categoryStatus;
+  const [activePage, setActivePage] = useState(1);
+  const offset = (activePage - 1) * limit;
 
-    switch (status) {
-      case InAppNotificationStatuses.STATUS_UNOPENED:
-        categoryStatus = InAppNotificationStatuses.STATUS_UNOPENED;
-        break;
-      default:
-    }
+  const categoryStatus =
+    status === InAppNotificationStatuses.STATUS_UNOPENED
+      ? InAppNotificationStatuses.STATUS_UNOPENED
+      : undefined;
 
-    const { data: notificationData, mutate: mutateNotificationData } = useSWRxInAppNotifications(limit, offset, categoryStatus);
-    const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(limit, offset, undefined);
-    const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
+  const { data: notificationData, mutate: mutateNotificationData } =
+    useSWRxInAppNotifications(limit, offset, categoryStatus);
+  const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(
+    limit,
+    offset,
+    undefined,
+  );
+  const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
-    const setAllNotificationPageNumber = (selectedPageNumber): void => {
-      setActivePage(selectedPageNumber);
-    };
+  const setAllNotificationPageNumber = (selectedPageNumber: number): void => {
+    setActivePage(selectedPageNumber);
+  };
 
+  if (notificationData == null) {
+    return (
+      <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
+        <div className="text-muted text-center">
+          <LoadingSpinner className="me-1 fs-3" />
+        </div>
+      </div>
+    );
+  }
+
+  const updateUnopendNotificationStatusesToOpened = async () => {
+    await apiv3Put('/in-app-notification/all-statuses-open');
+    // mutate notification statuses in 'UNREAD' Category
+    mutateNotificationData();
+    // mutate notification statuses in 'ALL' Category
+    mutateAllNotificationData();
+    mutateNotificationCount();
+  };
 
-    if (notificationData == null) {
-      return (
-        <div className="wiki" data-testid="grw-in-app-notification-page-spinner">
-          <div className="text-muted text-center">
-            <LoadingSpinner className="me-1 fs-3" />
+  return (
+    <>
+      {status === InAppNotificationStatuses.STATUS_UNOPENED &&
+        notificationData.totalDocs > 0 && (
+          <div className="mb-2 d-flex justify-content-end">
+            <button
+              type="button"
+              className="btn btn-outline-primary"
+              onClick={updateUnopendNotificationStatusesToOpened}
+            >
+              {t('in_app_notification.mark_all_as_read')}
+            </button>
           </div>
+        )}
+      {notificationData != null && notificationData.docs.length === 0 ? (
+        // no items
+        t('in_app_notification.no_unread_messages')
+      ) : (
+        // render list-group
+        <InAppNotificationList inAppNotificationData={notificationData} />
+      )}
+
+      {notificationData.totalDocs > 0 && (
+        <div className="mt-4">
+          <PaginationWrapper
+            activePage={activePage}
+            changePage={setAllNotificationPageNumber}
+            totalItemsCount={notificationData.totalDocs}
+            pagingLimit={notificationData.limit}
+            align="center"
+            size="sm"
+          />
         </div>
-      );
-    }
+      )}
+    </>
+  );
+};
 
-    const updateUnopendNotificationStatusesToOpened = async() => {
-      await apiv3Put('/in-app-notification/all-statuses-open');
-      // mutate notification statuses in 'UNREAD' Category
-      mutateNotificationData();
-      // mutate notification statuses in 'ALL' Category
-      mutateAllNotificationData();
-      mutateNotificationCount();
-    };
+const InAppNotificationAllTabContent: FC = () => {
+  return <InAppNotificationCategoryByStatus />;
+};
 
+const InAppNotificationUnreadTabContent: FC = () => {
+  return (
+    <InAppNotificationCategoryByStatus
+      status={InAppNotificationStatuses.STATUS_UNOPENED}
+    />
+  );
+};
 
-    return (
-      <>
-        {(status === InAppNotificationStatuses.STATUS_UNOPENED && notificationData.totalDocs > 0)
-      && (
-        <div className="mb-2 d-flex justify-content-end">
-          <button
-            type="button"
-            className="btn btn-outline-primary"
-            onClick={updateUnopendNotificationStatusesToOpened}
-          >
-            {t('in_app_notification.mark_all_as_read')}
-          </button>
-        </div>
-      )}
-        { notificationData != null && notificationData.docs.length === 0
-          // no items
-          ? t('in_app_notification.no_unread_messages')
-          // render list-group
-          : (
-            <InAppNotificationList inAppNotificationData={notificationData} />
-          )
-        }
-
-        {notificationData.totalDocs > 0 && (
-          <div className="mt-4">
-            <PaginationWrapper
-              activePage={activePage}
-              changePage={setAllNotificationPageNumber}
-              totalItemsCount={notificationData.totalDocs}
-              pagingLimit={notificationData.limit}
-              align="center"
-              size="sm"
-            />
-          </div>
-        ) }
-      </>
-    );
-  };
+export const InAppNotificationPage: FC = () => {
+  const { t } = useTranslation('commons');
 
   const navTabMapping = {
     user_infomation: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(),
+      Icon: EmptyIcon,
+      Content: InAppNotificationAllTabContent,
       i18n: t('in_app_notification.all'),
     },
     external_accounts: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
+      Icon: EmptyIcon,
+      Content: InAppNotificationUnreadTabContent,
       i18n: t('in_app_notification.unopend'),
     },
   };
 
   return (
     <div data-testid="grw-in-app-notification-page">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['mt-4']}
+      />
     </div>
   );
 };

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