Преглед изворни кода

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

Yuki Takei пре 3 месеци
родитељ
комит
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:
       with:
         aws-region: ap-northeast-1
         aws-region: ap-northeast-1
         role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
         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
     - name: Run CodeBuild
       uses: dark-mechanicum/aws-codebuild@v1
       uses: dark-mechanicum/aws-codebuild@v1

+ 1 - 0
.gitignore

@@ -33,6 +33,7 @@ yarn-error.log*
 # Terraform
 # Terraform
 **/.terraform/*
 **/.terraform/*
 *.tfstate.*
 *.tfstate.*
+/aws/
 
 
 # IDE, dev #
 # IDE, dev #
 .idea
 .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
 # 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.*
 *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
 ## [v7.3.9](https://github.com/growilabs/compare/v7.3.8...v7.3.9) - 2025-12-09
 
 
 ### 🐛 Bug Fixes
 ### 🐛 Bug Fixes

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

@@ -37,6 +37,55 @@ module.exports = {
     'src/interfaces/**',
     'src/interfaces/**',
     'src/utils/**',
     'src/utils/**',
     'src/components/**',
     '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/services/**',
     'src/states/**',
     'src/states/**',
     'src/stores/**',
     'src/stores/**',

+ 3 - 0
apps/app/.gitignore

@@ -14,3 +14,6 @@
 /public/uploads
 /public/uploads
 /src/styles/prebuilt
 /src/styles/prebuilt
 /tmp/
 /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:service:g2g-transfer': 'debug',
 
 
   'growi:migration:add-installed-date-to-config': 'debug',
   'growi:migration:add-installed-date-to-config': 'debug',
+  'growi:events:page:seen': 'debug',
 
 
   /*
   /*
    * configure level for client
    * 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
 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.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)
 * [`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"
   version     = "6.12.0"
   constraints = ">= 5.0.0, >= 6.0.0, ~> 6.0"
   constraints = ">= 5.0.0, >= 6.0.0, ~> 6.0"
   hashes = [
   hashes = [
+    "h1:8u90EMle+I3Auh4f/LPP6fEfRsAF6xCFnUZF4b7ngEs=",
     "h1:QiSzB4pjONZ4hek1L8Rcd6S9vtP+yMr5iOfczJg5/JI=",
     "h1:QiSzB4pjONZ4hek1L8Rcd6S9vtP+yMr5iOfczJg5/JI=",
     "zh:054bcbf13c6ac9ddd2247876f82f9b56493e2f71d8c88baeec142386a395165d",
     "zh:054bcbf13c6ac9ddd2247876f82f9b56493e2f71d8c88baeec142386a395165d",
     "zh:195489f16ad5621db2cec80be997d33060462a3b8d442c890bef3eceba34fa4d",
     "zh:195489f16ad5621db2cec80be997d33060462a3b8d442c890bef3eceba34fa4d",
@@ -28,6 +29,7 @@ provider "registry.terraform.io/hashicorp/random" {
   version     = "3.7.2"
   version     = "3.7.2"
   constraints = ">= 2.1.0"
   constraints = ">= 2.1.0"
   hashes = [
   hashes = [
+    "h1:356j/3XnXEKr9nyicLUufzoF4Yr6hRy481KIxRVpK0c=",
     "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
     "h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
     "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
     "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
     "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
     "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
@@ -48,6 +50,7 @@ provider "registry.terraform.io/hashicorp/tls" {
   version     = "4.1.0"
   version     = "4.1.0"
   constraints = ">= 4.0.0"
   constraints = ">= 4.0.0"
   hashes = [
   hashes = [
+    "h1:Ka8mEwRFXBabR33iN/WTIEW6RP0z13vFsDlwn11Pf2I=",
     "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
     "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=",
     "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
     "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
     "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
     "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",

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

@@ -18,6 +18,6 @@ terraform {
 }
 }
 
 
 provider "aws" {
 provider "aws" {
-  profile = "weseek"
+  profile = "weseek-tf"
   region  = "ap-northeast-1"
   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
       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",
   "name": "@growi/app",
-  "version": "7.4.0-RC.0",
+  "version": "7.4.2-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -173,7 +173,7 @@
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^14.2.32",
+    "next": "^14.2.35",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^1.0.7",
     "next-superjson": "^1.0.7",
@@ -191,8 +191,9 @@
     "passport-ldapauth": "^3.0.1",
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
+    "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
     "prop-types": "^15.8.1",
-    "qs": "^6.11.1",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "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');
   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
   // Sticky
   await page.evaluate(() => window.scrollTo(0, 250));
   await page.evaluate(() => window.scrollTo(0, 250));
   await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
   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');
       await page.goto('/Sandbox/Bootstrap5');
 
 
       // Create Sharelink
       // 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
       await page
         .getByTestId(
         .getByTestId(
           'open-page-accessories-modal-btn-with-share-link-management-data-tab',
           '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.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
   page
     .getByTestId('page-create-modal')
     .getByTestId('page-create-modal')
     .locator('.rbt-input-main')
     .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 page.goto('/admin/app');
 
 
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
   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 }) => {
 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 expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await page.getByTestId('external-accounts-tab-button').first().click();
   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 page.getByTestId('grw-external-account-add-button').click();
   await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
   await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
   await page.getByTestId('add-external-account-button').click();
   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();
   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 (
 export const collapseSidebar = async (
   page: Page,
   page: Page,
-  isCollapsed: boolean,
+  collapse: boolean,
 ): Promise<void> => {
 ): 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());
     .isVisible());
-  if (isSidebarContentsHidden === isCollapsed) {
+  if (isSidebarCollapsed === collapse) {
     return;
     return;
   }
   }
 
 
@@ -15,7 +17,7 @@ export const collapseSidebar = async (
   await expect(collapseSidebarToggle).toBeVisible();
   await expect(collapseSidebarToggle).toBeVisible();
   await collapseSidebarToggle.click();
   await collapseSidebarToggle.click();
 
 
-  if (isCollapsed) {
+  if (collapse) {
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
     await expect(page.locator('.grw-sidebar-dock')).not.toBeVisible();
   } else {
   } else {
     await expect(page.locator('.grw-sidebar-dock')).toBeVisible();
     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 20)" fill="#abb4bd"/>
       <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
       <rect width="170" height="5" transform="translate(0 30)" fill="#abb4bd"/>
     </g>
     </g>
-    <rect width="249" height="160" transform="translate(-17766 -9529)" fill="#25272f" opacity="0.271"/>
     <g transform="translate(-217 -20)">
     <g transform="translate(-217 -20)">
       <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
       <path d="M2,160H-2V0H2Z" transform="translate(-17461 -9509)" fill="#209fd8"/>
       <rect width="86" height="160" transform="translate(-17549 -9509)" fill="#f3f7fc"/>
       <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="47" height="5" transform="translate(-17530 -9481)" fill="#abb4bd"/>
       <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
       <rect width="11.787" height="5" transform="translate(-17530 -9491)" fill="#abb4bd"/>
     </g>
     </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>
   </g>
 </svg>
 </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",
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_language": "Default language for new users",
     "default_mail_visibility": "Disclose e-mail 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",
     "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",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "enable_page_bulk_export": "Enable bulk export",
     "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.",
     "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",
     "customize_settings": "Customize",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "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": "Layout",
     "layout_options": {
     "layout_options": {

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

@@ -356,9 +356,9 @@
     "confidential_example": "ex): usage interne seulement",
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_language": "Langue par défaut",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
     "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",
     "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",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "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.",
     "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",
     "customize_settings": "Interface",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "Barre latérale",
       "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": "Largeur du contenu",
     "layout_options": {
     "layout_options": {

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

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

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

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

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

@@ -365,9 +365,9 @@
     "confidential_example": "ex):仅供内部使用",
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_language": "新用户的默认语言",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "default_read_only_for_new_user": "新用戶編輯限制",
+    "set_read_only_for_new_user": "將新用戶設為僅限瀏覽",
     "file_uploading": "文件上传",
     "file_uploading": "文件上传",
-    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
     "page_bulk_export_settings": "页面批量导出设置",
     "page_bulk_export_settings": "页面批量导出设置",
     "enable_page_bulk_export": "启用批量导出",
     "enable_page_bulk_export": "启用批量导出",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
@@ -457,10 +457,7 @@
     "customize_settings": "页面定制",
     "customize_settings": "页面定制",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
       "title": "默认的侧边栏模式",
-      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
-      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
-      "dock_mode_default_open": "从头开始翻页",
-      "dock_mode_default_close": "从头开始打开关闭的页面"
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。"
     },
     },
     "layout": "布局",
     "layout": "布局",
     "layout_options": {
     "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',
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       // Convert boolean to string for radio button value
       // Convert boolean to string for radio button value
       isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
       isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
-      fileUpload: adminAppContainer.state.fileUpload ?? false,
+      isReadOnlyForNewUser: adminAppContainer.state.isReadOnlyForNewUser ?? false,
     });
     });
   }, [
   }, [
     adminAppContainer.state.title,
     adminAppContainer.state.title,
     adminAppContainer.state.confidential,
     adminAppContainer.state.confidential,
     adminAppContainer.state.globalLang,
     adminAppContainer.state.globalLang,
     adminAppContainer.state.isEmailPublishedForNewUser,
     adminAppContainer.state.isEmailPublishedForNewUser,
-    adminAppContainer.state.fileUpload,
+    adminAppContainer.state.isReadOnlyForNewUser,
     reset,
     reset,
   ]);
   ]);
 
 
@@ -57,7 +57,7 @@ const AppSetting = (props) => {
       // Convert string 'true'/'false' to boolean
       // Convert string 'true'/'false' to boolean
       const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
       const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
       await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
       await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
-      await adminAppContainer.changeFileUpload(data.fileUpload);
+      await adminAppContainer.changeIsReadOnlyForNewUserShow(data.isReadOnlyForNewUser);
 
 
       await adminAppContainer.updateAppSettingHandler();
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
@@ -163,31 +163,23 @@ const AppSetting = (props) => {
         </div>
         </div>
       </div>
       </div>
 
 
-      <div className="row mb-2">
+      <div className="row mb-5">
         <label
         <label
           className="text-start text-md-end col-md-3 col-form-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>
         </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
             <input
               type="checkbox"
               type="checkbox"
-              id="cbFileUpload"
+              id="checkbox-read-only-for-new-user"
               className="form-check-input"
               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>
           </div>
-
-          <p className="form-text text-muted">
-            {t('admin:app_setting.attach_enable')}
-          </p>
         </div>
         </div>
       </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 { t } = useTranslation(['admin', 'commons']);
 
 
   const {
   const {
-    data, update, setIsSidebarCollapsedMode, setIsSidebarClosedAtDockMode,
+    data, update, setIsSidebarCollapsedMode,
   } = useSWRxSidebarConfig();
   } = useSWRxSidebarConfig();
 
 
   const { resolvedTheme } = useNextThemes();
   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 dockIconFileName = `/images/customize-settings/dock-${resolvedTheme}.svg`;
 
 
   const onClickSubmit = useCallback(async() => {
   const onClickSubmit = useCallback(async() => {
@@ -33,7 +33,7 @@ const CustomizeSidebarsetting = (): JSX.Element => {
     return <LoadingSpinner />;
     return <LoadingSpinner />;
   }
   }
 
 
-  const { isSidebarCollapsedMode, isSidebarClosedAtDockMode } = data;
+  const { isSidebarCollapsedMode } = data;
 
 
   return (
   return (
     <React.Fragment>
     <React.Fragment>
@@ -57,9 +57,9 @@ const CustomizeSidebarsetting = (): JSX.Element => {
                   role="button"
                   role="button"
                 >
                 >
                   {/* eslint-disable-next-line @next/next/no-img-element */}
                   {/* 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">
                   <div className="card-body text-center">
-                    Drawer Mode
+                    Collapsed Mode
                   </div>
                   </div>
                 </div>
                 </div>
               </div>
               </div>
@@ -79,41 +79,6 @@ const CustomizeSidebarsetting = (): JSX.Element => {
             </div>
             </div>
           </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="row my-3">
             <div className="mx-auto">
             <div className="mx-auto">
               <button type="button" onClick={onClickSubmit} className="btn btn-primary">{ t('Update') }</button>
               <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 type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useSiteUrl } from '~/states/global';
 import { useSiteUrl } from '~/states/global';
@@ -9,8 +8,7 @@ const isValidUrl = (str: string): boolean => {
     // eslint-disable-next-line no-new
     // eslint-disable-next-line no-new
     new URL(str);
     new URL(str);
     return true;
     return true;
-  }
-  catch {
+  } catch {
     return false;
     return false;
   }
   }
 };
 };
@@ -26,9 +24,12 @@ export const AlertSiteUrlUndefined = (): JSX.Element => {
   return (
   return (
     <div className="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
     <div className="alert alert-danger rounded-0 d-edit-none mb-0 px-4 py-2">
       <span className="material-symbols-outlined">error</span>
       <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>
     </div>
   );
   );
 };
 };

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

@@ -1,17 +1,19 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
+import Link from 'next/link';
 import type { IUserHasId } from '@growi/core';
 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 { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-
 
 
 import styles from './AuthorInfo.module.scss';
 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)) {
   if (isPopulated(user)) {
     return (
     return (
       <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>
       <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>
@@ -23,44 +25,47 @@ const UserLabel = ({ user }: { user: IUserHasId | Ref<IUser> }): JSX.Element =>
   return <i>(anyone)</i>;
   return <i>(anyone)</i>;
 };
 };
 
 
-
 type AuthorInfoProps = {
 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 => {
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const { t } = useTranslation();
   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 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') {
   if (locate === 'footer') {
     try {
     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) {
       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 <></>;
       return <></>;
     }
     }
@@ -69,19 +74,23 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const renderParsedDate = () => {
   const renderParsedDate = () => {
     try {
     try {
       return format(new Date(date), formatType);
       return format(new Date(date), formatType);
-    }
-    catch (err) {
+    } catch (err) {
       return '';
       return '';
     }
     }
   };
   };
 
 
   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">
       <div className="me-2 d-none d-lg-block">
         <UserPicture user={user} size="sm" />
         <UserPicture user={user} size="sm" />
       </div>
       </div>
       <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>
         <div className="text-secondary text-date" data-vrt-blackout-datetime>
           {renderParsedDate()}
           {renderParsedDate()}
         </div>
         </div>

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

@@ -1,15 +1,21 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import { useCallback, useState } from 'react';
 import { useCallback, useState } from 'react';
-
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
 import { DropdownToggle } from 'reactstrap';
 
 
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import {
 import {
-  addBookmarkToFolder, addNewFolder, hasChildren, updateBookmarkFolder,
+  addBookmarkToFolder,
+  addNewFolder,
+  hasChildren,
+  updateBookmarkFolder,
 } from '~/client/util/bookmark-utils';
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 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 { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
 import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
@@ -20,28 +26,43 @@ import { BookmarkItem } from './BookmarkItem';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 
 type BookmarkFolderItemProps = {
 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 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 {
   const {
-    isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomepage,
-    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    bookmarkFolder,
+    isOpen: _isOpen = false,
+    isOperable,
+    level,
+    root,
+    isUserHomepage,
+    onClickDeleteMenuItemHandler,
+    bookmarkFolderTreeMutation,
   } = props;
   } = props;
 
 
   const {
   const {
-    name, _id: folderId, childFolder, parent, bookmarks,
+    name,
+    _id: folderId,
+    childFolder,
+    parent,
+    bookmarks,
   } = bookmarkFolder;
   } = bookmarkFolder;
 
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
   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 [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
 
-  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
+  const { open: openDeleteBookmarkFolderModal } =
+    useDeleteBookmarkFolderModalActions();
 
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
   const childrenExists = hasChildren({ childFolder, bookmarks });
 
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
 
-  const loadChildFolder = useCallback(async() => {
+  const loadChildFolder = useCallback(async () => {
     setIsOpen(!isOpen);
     setIsOpen(!isOpen);
     setTargetFolder(folderId);
     setTargetFolder(folderId);
   }, [folderId, isOpen]);
   }, [folderId, isOpen]);
@@ -66,95 +88,127 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
   }, []);
 
 
   // Rename for bookmark folder handler
   // 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
   // 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) {
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
       try {
         if (item.bookmarkFolder != null) {
         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();
           bookmarkFolderTreeMutation();
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
-    }
-    else {
+    } else {
       try {
       try {
         if (item != null) {
         if (item != null) {
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           await addBookmarkToFolder(item._id, bookmarkFolder._id);
           bookmarkFolderTreeMutation();
           bookmarkFolderTreeMutation();
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(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 (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;
         return false;
       }
       }
 
 
       // Maximum folder hierarchy of 2 levels
       // 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 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 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 false;
       }
       }
 
 
       return item.root !== root || item.level >= level;
       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 false;
     }
     }
     return true;
     return true;
   };
   };
 
 
-  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+  const triangleBtnClassName = (
+    isOpen: boolean,
+    childrenExists: boolean,
+  ): string => {
     if (!childrenExists) {
     if (!childrenExists) {
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
       return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
     }
     }
@@ -162,41 +216,47 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   };
   };
 
 
   const renderChildFolder = () => {
   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}
             isReadOnlyUser={isReadOnlyUser}
             isOperable={props.isOperable}
             isOperable={props.isOperable}
-            bookmarkFolder={childFolder}
+            bookmarkedPage={bookmark.page}
             level={level + 1}
             level={level + 1}
-            root={root}
-            isUserHomepage={isUserHomepage}
+            parentFolder={bookmarkFolder}
+            canMoveToRoot
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             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(() => {
   const onClickRenameHandler = useCallback(() => {
@@ -204,7 +264,9 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   }, []);
   }, []);
 
 
   const onClickDeleteHandler = useCallback(() => {
   const onClickDeleteHandler = useCallback(() => {
-    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (folderId) => {
+    const bookmarkFolderDeleteHandler: onDeletedBookmarkFolderFunction = (
+      folderId,
+    ) => {
       if (typeof folderId !== 'string') {
       if (typeof folderId !== 'string') {
         return;
         return;
       }
       }
@@ -214,21 +276,39 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (bookmarkFolder == null) {
     if (bookmarkFolder == null) {
       return;
       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 (
   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
       <DragAndDropWrapper
         key={folderId}
         key={folderId}
         type={acceptedTypes}
         type={acceptedTypes}
@@ -240,23 +320,8 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       >
       >
         <li
         <li
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
           className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded-1"
-          onClick={loadChildFolder}
           style={{ paddingLeft }}
           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 ? (
           {isRenameAction ? (
             <div className="flex-fill">
             <div className="flex-fill">
               <BookmarkFolderNameInput
               <BookmarkFolderNameInput
@@ -266,27 +331,46 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
               />
               />
             </div>
             </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">
               <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto">{name}</p>
                 <p className="text-truncate m-auto">{name}</p>
               </div>
               </div>
-            </>
+            </button>
           )}
           )}
           {isOperable && (
           {isOperable && (
             <div className="grw-foldertree-control d-flex">
             <div className="grw-foldertree-control d-flex">
               <BookmarkFolderItemControl
               <BookmarkFolderItemControl
                 onClickRename={onClickRenameHandler}
                 onClickRename={onClickRenameHandler}
                 onClickDelete={onClickDeleteHandler}
                 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>
               </BookmarkFolderItemControl>
               {/* Maximum folder hierarchy of 2 levels */}
               {/* Maximum folder hierarchy of 2 levels */}
               {!(bookmarkFolder.parent != null) && (
               {!(bookmarkFolder.parent != null) && (
@@ -304,17 +388,10 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         </li>
         </li>
       </DragAndDropWrapper>
       </DragAndDropWrapper>
       {isCreateAction && (
       {isCreateAction && (
-        <BookmarkFolderNameInput
-          onSubmit={create}
-          onCancel={cancel}
-        />
+        <BookmarkFolderNameInput onSubmit={create} onCancel={cancel} />
       )}
       )}
-      {
-        renderChildFolder()
-      }
-      {
-        renderBookmarkItem()
-      }
+      {renderChildFolder()}
+      {renderBookmarkItem()}
     </div>
     </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 { useTranslation } from 'next-i18next';
 import {
 import {
-  Dropdown, DropdownItem, DropdownMenu, DropdownToggle,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 export const BookmarkFolderItemControl: React.FC<{
 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,
   children,
   onClickMoveToRoot,
   onClickMoveToRoot,
@@ -21,23 +23,25 @@ export const BookmarkFolderItemControl: React.FC<{
 
 
   return (
   return (
     <Dropdown isOpen={isOpen} toggle={() => setIsOpen(!isOpen)}>
     <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>
           <span className="material-symbols-outlined">more_horiz</span>
         </DropdownToggle>
         </DropdownToggle>
-      ) }
+      )}
 
 
-      { isOpen && (
-        <DropdownMenu
-          container="body"
-          style={{ zIndex: 1055 }}
-        >
+      {isOpen && (
+        <DropdownMenu container="body" style={{ zIndex: 1055 }}>
           {onClickMoveToRoot && (
           {onClickMoveToRoot && (
             <DropdownItem
             <DropdownItem
               onClick={onClickMoveToRoot}
               onClick={onClickMoveToRoot}
               className="grw-page-control-dropdown-item"
               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')}
               {t('bookmark_folder.move_to_root')}
             </DropdownItem>
             </DropdownItem>
           )}
           )}
@@ -45,7 +49,9 @@ export const BookmarkFolderItemControl: React.FC<{
             onClick={onClickRename}
             onClick={onClickRename}
             className="grw-page-control-dropdown-item"
             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')}
             {t('Rename')}
           </DropdownItem>
           </DropdownItem>
 
 
@@ -55,11 +61,13 @@ export const BookmarkFolderItemControl: React.FC<{
             className="pt-2 grw-page-control-dropdown-item text-danger"
             className="pt-2 grw-page-control-dropdown-item text-danger"
             onClick={onClickDelete}
             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')}
             {t('Delete')}
           </DropdownItem>
           </DropdownItem>
         </DropdownMenu>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
     </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 { useTranslation } from 'next-i18next';
 import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 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 { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -17,43 +17,45 @@ import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 import styles from './BookmarkFolderMenu.module.scss';
 import styles from './BookmarkFolderMenu.module.scss';
 
 
 type BookmarkFolderMenuProps = {
 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 { t } = useTranslation();
 
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
 
 
   const currentUser = useCurrentUser();
   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 { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
 
   const isBookmarkFolderExists = useMemo((): boolean => {
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
     return bookmarkFolders != null && bookmarkFolders.length > 0;
   }, [bookmarkFolders]);
   }, [bookmarkFolders]);
 
 
-  const toggleBookmarkHandler = useCallback(async() => {
+  const toggleBookmarkHandler = useCallback(async () => {
     try {
     try {
       await toggleBookmark(pageId, isBookmarked);
       await toggleBookmark(pageId, isBookmarked);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [isBookmarked, pageId]);
   }, [isBookmarked, pageId]);
 
 
-  const onUnbookmarkHandler = useCallback(async() => {
+  const onUnbookmarkHandler = useCallback(async () => {
     if (onUnbookmark != null) {
     if (onUnbookmark != null) {
       onUnbookmark();
       onUnbookmark();
     }
     }
@@ -62,9 +64,15 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
     mutateCurrentUserBookmarks();
     mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutateBookmarkFolders();
     mutatePageInfo();
     mutatePageInfo();
-  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
-
-  const toggleHandler = useCallback(async() => {
+  }, [
+    onUnbookmark,
+    toggleBookmarkHandler,
+    mutateCurrentUserBookmarks,
+    mutateBookmarkFolders,
+    mutatePageInfo,
+  ]);
+
+  const toggleHandler = useCallback(async () => {
     // on close
     // on close
     if (isOpen && bookmarkFolders != null) {
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
       bookmarkFolders.forEach((bookmarkFolder) => {
@@ -89,29 +97,48 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
         await toggleBookmarkHandler();
         await toggleBookmarkHandler();
         mutateCurrentUserBookmarks();
         mutateCurrentUserBookmarks();
         mutatePageInfo();
         mutatePageInfo();
-      }
-      catch (err) {
+      } catch (err) {
         toastError(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 = () => {
   const renderBookmarkMenuItem = () => {
     return (
     return (
@@ -122,9 +149,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
           className="grw-bookmark-folder-menu-item text-danger text-truncate"
         >
         >
           <span className="material-symbols-outlined">bookmark</span>{' '}
           <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>
         </DropdownItem>
 
 
         {isBookmarkFolderExists && (
         {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"
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action px-4"
                 tabIndex={0}
                 tabIndex={0}
                 role="menuitem"
                 role="menuitem"
-                onClick={e => onMenuItemClickHandler(e, 'root')}
+                onClick={(e) => onMenuItemClickHandler(e, 'root')}
+                onKeyDown={onMenuItemKeyDownHandler('root')}
               >
               >
                 <BookmarkFolderMenuItem
                 <BookmarkFolderMenuItem
                   itemId="root"
                   itemId="root"
@@ -144,13 +170,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                 />
                 />
               </div>
               </div>
             </div>
             </div>
-            {bookmarkFolders?.map(folder => (
+            {bookmarkFolders?.map((folder) => (
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
               <React.Fragment key={`bookmark-folders-${folder._id}`}>
                 <div
                 <div
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-first list-group-item list-group-item-action"
                   tabIndex={0}
                   tabIndex={0}
                   role="menuitem"
                   role="menuitem"
-                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                  onClick={(e) => onMenuItemClickHandler(e, folder._id)}
+                  onKeyDown={onMenuItemKeyDownHandler(folder._id)}
                 >
                 >
                   <BookmarkFolderMenuItem
                   <BookmarkFolderMenuItem
                     itemId={folder._id}
                     itemId={folder._id}
@@ -158,13 +185,14 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                     isSelected={selectedItem === folder._id}
                   />
                   />
                 </div>
                 </div>
-                {folder.childFolder?.map(child => (
+                {folder.childFolder?.map((child) => (
                   <div key={child._id}>
                   <div key={child._id}>
                     <div
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"
                       tabIndex={0}
                       tabIndex={0}
                       role="menuitem"
                       role="menuitem"
-                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                      onClick={(e) => onMenuItemClickHandler(e, child._id)}
+                      onKeyDown={onMenuItemKeyDownHandler(child._id)}
                     >
                     >
                       <BookmarkFolderMenuItem
                       <BookmarkFolderMenuItem
                         itemId={child._id}
                         itemId={child._id}
@@ -183,13 +211,10 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
   };
   };
 
 
   return (
   return (
-    <UncontrolledDropdown
-      isOpen={isOpen}
-      onToggle={toggleHandler}
-    >
+    <UncontrolledDropdown isOpen={isOpen} onToggle={toggleHandler}>
       {children}
       {children}
 
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu
         <DropdownMenu
           end
           end
           persist
           persist
@@ -197,9 +222,9 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
           container="body"
           container="body"
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
           className={`grw-bookmark-folder-menu ${styles['grw-bookmark-folder-menu']}`}
         >
         >
-          { renderBookmarkMenuItem() }
+          {renderBookmarkMenuItem()}
         </DropdownMenu>
         </DropdownMenu>
-      ) }
+      )}
     </UncontrolledDropdown>
     </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<{
 export const BookmarkFolderMenuItem: React.FC<{
-  itemId: string
-  itemName: string
-  isSelected: boolean
-}> = ({
-  itemId,
-  itemName,
-  isSelected,
-}) => {
+  itemId: string;
+  itemName: string;
+  isSelected: boolean;
+}> = ({ itemId, itemName, isSelected }) => {
   return (
   return (
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
     <div className="d-flex align-items-center grw-bookmark-folder-menu-item-title">
       <input
       <input
@@ -16,10 +12,13 @@ export const BookmarkFolderMenuItem: React.FC<{
         checked={isSelected}
         checked={isSelected}
         name="bookmark-folder-menu-item"
         name="bookmark-folder-menu-item"
         id={`bookmark-folder-menu-item-${itemId}`}
         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}
         {itemName}
       </label>
       </label>
     </div>
     </div>

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

@@ -1,19 +1,26 @@
 import type { ChangeEvent, JSX } from 'react';
 import type { ChangeEvent, JSX } from 'react';
 import { useCallback, useRef, useState } from 'react';
 import { useCallback, useRef, useState } from 'react';
-
 import { useRect } from '@growi/ui/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import type { AutosizeInputProps } from 'react-input-autosize';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import type { InputValidationResult } from '~/client/util/use-input-validator';
 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';
 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 => {
 export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -23,15 +30,18 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
   const parentRef = useRef<HTMLDivElement>(null);
   const parentRef = useRef<HTMLDivElement>(null);
   const [parentRect] = useRect(parentRef);
   const [parentRect] = useRect(parentRef);
 
 
-  const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult>();
 
 
   const inputValidator = useInputValidator(ValidationTarget.FOLDER);
   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 changeHandlerDebounced = debounce(300, changeHandler);
 
 
   const cancelHandler = useCallback(() => {
   const cancelHandler = useCallback(() => {
@@ -41,9 +51,14 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
 
 
   const isInvalid = validationResult != null;
   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 (
   return (
     <div ref={parentRef}>
     <div ref={parentRef}>
@@ -52,17 +67,22 @@ export const BookmarkFolderNameInput = (props: Props): JSX.Element => {
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
         inputStyle={{ maxWidth }}
         inputStyle={{ maxWidth }}
         placeholder={t('bookmark_folder.input_placeholder')}
         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
         autoFocus
         onChange={changeHandlerDebounced}
         onChange={changeHandlerDebounced}
         onSubmit={onSubmit}
         onSubmit={onSubmit}
         onCancel={cancelHandler}
         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}
           {validationResult.message}
         </div>
         </div>
-      ) }
+      )}
     </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 type { IPageToDeleteWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
 import { DndProvider } from 'react-dnd';
 import { DndProvider } from 'react-dnd';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 import { HTML5Backend } from 'react-dnd-html5-backend';
 
 
@@ -13,7 +12,8 @@ import { useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
 import { useCurrentPageData } from '~/states/page';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
 import {
-  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+  useSWRMUTxCurrentUserBookmarks,
+  useSWRxUserBookmarks,
 } from '~/stores/bookmark';
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
 import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
@@ -30,10 +30,10 @@ import styles from './BookmarkFolderTree.module.scss';
 //  } & IPageHasId
 //  } & IPageHasId
 
 
 type Props = {
 type Props = {
-  isUserHomepage?: boolean,
-  userId?: string,
-  isOperable: boolean,
-}
+  isUserHomepage?: boolean;
+  userId?: string;
+  isOperable: boolean;
+};
 
 
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { isUserHomepage, userId } = props;
   const { isUserHomepage, userId } = props;
@@ -44,10 +44,15 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
 
   const isReadOnlyUser = useIsReadOnlyUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   const currentPage = useCurrentPageData();
   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 { open: openDeleteModal } = usePageDeleteModalActions();
 
 
   const bookmarkFolderTreeMutation = useCallback(() => {
   const bookmarkFolderTreeMutation = useCallback(() => {
@@ -55,20 +60,43 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
     mutateCurrentUserBookmarks();
     mutateCurrentUserBookmarks();
     mutatePageInfo();
     mutatePageInfo();
     mutateBookmarkFolders();
     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. */
   /* TODO: update in bookmarks folder v2. */
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
   // const itemDropHandler = async(item: DragItemDataType, dragType: string | null | symbol) => {
@@ -106,9 +134,12 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
 
   return (
   return (
     <DndProvider backend={HTML5Backend}>
     <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) => {
           {bookmarkFolders?.map((bookmarkFolder) => {
             return (
             return (
               <BookmarkFolderItem
               <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
               <BookmarkItem
                 isReadOnlyUser={!!isReadOnlyUser}
                 isReadOnlyUser={!!isReadOnlyUser}
                 isOperable={props.isOperable}
                 isOperable={props.isOperable}
@@ -156,7 +190,6 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
           </DragAndDropWrapper>
           </DragAndDropWrapper>
         )} */}
         )} */}
       </div>
       </div>
-
     </DndProvider>
     </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 { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { pathUtils } from '@growi/core/dist/utils';
-import { useRouter } from 'next/router';
+import nodePath from 'path';
 import { useTranslation } from 'react-i18next';
 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 { bookmark, unbookmark, unlink } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 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 { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import { useFetchCurrentPage } from '~/states/page';
 import { useFetchCurrentPage } from '~/states/page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
 import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/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 { PageListItemS } from '../PageList/PageListItemS';
-
 import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkItemRenameInput } from './BookmarkItemRenameInput';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { BookmarkMoveToRootBtn } from './BookmarkMoveToRootBtn';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 import { DragAndDropWrapper } from './DragAndDropWrapper';
 
 
 type Props = {
 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 => {
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
   const BASE_FOLDER_PADDING = 15;
@@ -48,46 +52,56 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const router = useRouter();
   const router = useRouter();
 
 
   const {
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
-    parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
+    isReadOnlyUser,
+    isOperable,
+    bookmarkedPage,
+    onClickDeleteMenuItemHandler,
+    parentFolder,
+    level,
+    canMoveToRoot,
+    bookmarkFolderTreeMutation,
   } = props;
   } = props;
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
   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 { fetchCurrentPage } = useFetchCurrentPage();
 
 
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
+  const paddingLeft = BASE_BOOKMARK_PADDING + BASE_FOLDER_PADDING * level;
   const dragItem: Partial<DragItemDataType> = {
   const dragItem: Partial<DragItemDataType> = {
-    ...bookmarkedPage, parentFolder,
+    ...bookmarkedPage,
+    parentFolder,
   };
   };
 
 
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPageId = bookmarkedPage?._id;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPagePath = bookmarkedPage?.path;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
   const bookmarkedPageRevision = bookmarkedPage?.revision;
 
 
-  const onClickMoveToRootHandler = useCallback(async() => {
+  const onClickMoveToRootHandler = useCallback(async () => {
     if (bookmarkedPageId == null) return;
     if (bookmarkedPageId == null) return;
 
 
     try {
     try {
       await addBookmarkToFolder(bookmarkedPageId, null);
       await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
       bookmarkFolderTreeMutation();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
   }, [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(() => {
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
     setRenameInputShown(true);
@@ -97,57 +111,81 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     setRenameInputShown(false);
     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(() => {
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
     if (bookmarkedPage == null) return;
 
 
     const { _id: pageId, path } = bookmarkedPage;
     const { _id: pageId, path } = bookmarkedPage;
-    const putBackedHandler = async() => {
+    const putBackedHandler = async () => {
       try {
       try {
         await unlink(path);
         await unlink(path);
         mutateAllPageInfo();
         mutateAllPageInfo();
@@ -155,36 +193,41 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         router.push(`/${pageId}`);
         router.push(`/${pageId}`);
         fetchCurrentPage({ force: true });
         fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
         toastSuccess(t('page_has_been_reverted', { path }));
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     };
     };
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
     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 {
       return {
-        pageTitle: '',
-        formerPagePath: '',
-        isFormerRoot: false,
+        pageTitle: dPagePath.latter,
+        formerPagePath: dPagePath.former,
+        isFormerRoot: dPagePath.isFormerRoot,
         bookmarkItemId,
         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) {
   if (bookmarkedPage == null) {
     return <></>;
     return <></>;
@@ -202,15 +245,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         id={bookmarkItemId}
         id={bookmarkItemId}
         style={{ paddingLeft }}
         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">
         <div className="grw-foldertree-control">
           <PageItemControl
           <PageItemControl
@@ -224,11 +273,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickRevertMenuItem={putBackClickHandler}
             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>
               <span className="material-symbols-outlined p-1">more_vert</span>
             </DropdownToggle>
             </DropdownToggle>
           </PageItemControl>
           </PageItemControl>

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

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

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

@@ -1,11 +1,10 @@
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { DropdownItem } from 'reactstrap';
 import { DropdownItem } from 'reactstrap';
 
 
 export const BookmarkMoveToRootBtn: React.FC<{
 export const BookmarkMoveToRootBtn: React.FC<{
-  pageId: string
-  onClickMoveToRootHandler: (pageId: string) => Promise<void>
+  pageId: string;
+  onClickMoveToRootHandler: (pageId: string) => Promise<void>;
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
 }> = React.memo(({ pageId, onClickMoveToRootHandler }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -14,7 +13,9 @@ export const BookmarkMoveToRootBtn: React.FC<{
       onClick={() => onClickMoveToRootHandler(pageId)}
       onClick={() => onClickMoveToRootHandler(pageId)}
       className="grw-page-control-dropdown-item"
       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')}
       {t('bookmark_folder.move_to_root')}
     </DropdownItem>
     </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 { useDrag, useDrop } from 'react-dnd';
 
 
 import type { DragItemDataType } from '~/interfaces/bookmark-info';
 import type { DragItemDataType } from '~/interfaces/bookmark-info';
 
 
 type DragAndDropWrapperProps = {
 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 {
   const {
-    item, children, useDragMode, useDropMode, type, onDropItem, isDropable,
+    item,
+    children,
+    useDragMode,
+    useDropMode,
+    type,
+    onDropItem,
+    isDropable,
   } = props;
   } = props;
 
 
-
   const acceptedTypes = type;
   const acceptedTypes = type;
   const sourcetype: string | symbol = type[0];
   const sourcetype: string | symbol = type[0];
 
 
-
   const [, dragRef] = useDrag({
   const [, dragRef] = useDrag({
     type: sourcetype,
     type: sourcetype,
     item,
     item,
-    collect: monitor => ({
+    collect: (monitor) => ({
       isDragging: monitor.isDragging(),
       isDragging: monitor.isDragging(),
       canDrag: monitor.canDrag(),
       canDrag: monitor.canDrag(),
     }),
     }),
@@ -48,7 +59,7 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
       }
       }
       return false;
       return false;
     },
     },
-    collect: monitor => ({
+    collect: (monitor) => ({
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
       isOver: monitor.isOver({ shallow: true }) && monitor.canDrop(),
     }),
     }),
   }));
   }));
@@ -57,17 +68,18 @@ export const DragAndDropWrapper = (props: DragAndDropWrapperProps): JSX.Element
     if (useDragMode && useDropMode) {
     if (useDragMode && useDropMode) {
       dragRef(c);
       dragRef(c);
       dropRef(c);
       dropRef(c);
-    }
-    else if (useDragMode) {
+    } else if (useDragMode) {
       dragRef(c);
       dragRef(c);
-    }
-    else if (useDropMode) {
+    } else if (useDropMode) {
       dropRef(c);
       dropRef(c);
     }
     }
   };
   };
 
 
   return (
   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}
       {children}
     </div>
     </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 type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
 import { useCurrentUser } from '~/states/global';
 import { useCurrentUser } from '~/states/global';
@@ -13,25 +10,28 @@ import { useIsTrashPage } from '~/states/page';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 
-
 const { isTopPage } = pagePathUtils;
 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 = {
 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 => {
 export const Comments = (props: CommentsProps): JSX.Element => {
-
-  const {
-    pageId, pagePath, revision, onLoaded,
-  } = props;
+  const { pageId, pagePath, revision, onLoaded } = props;
 
 
   const { t } = useTranslation('');
   const { t } = useTranslation('');
 
 
@@ -42,7 +42,10 @@ export const Comments = (props: CommentsProps): JSX.Element => {
 
 
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
 
 
-  const onLoadedDebounced = useMemo(() => debounce(500, () => onLoaded?.()), [onLoaded]);
+  const onLoadedDebounced = useMemo(
+    () => debounce(500, () => onLoaded?.()),
+    [onLoaded],
+  );
 
 
   useEffect(() => {
   useEffect(() => {
     const parent = pageCommentParentRef.current;
     const parent = pageCommentParentRef.current;
@@ -73,7 +76,11 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   return (
   return (
     <div className="page-comments-row mt-5 py-4 border-top d-edit-none d-print-none">
     <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>
       <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
         <PageComment
           pageId={pageId}
           pageId={pageId}
           pagePath={pagePath}
           pagePath={pagePath}
@@ -93,5 +100,4 @@ export const Comments = (props: CommentsProps): JSX.Element => {
       )}
       )}
     </div>
     </div>
   );
   );
-
 };
 };

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

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

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

@@ -2,18 +2,17 @@ import type { FC } from 'react';
 import React from 'react';
 import React from 'react';
 
 
 type CountProps = {
 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;
   const { count, offset = 0 } = props;
 
 
-
   return (
   return (
     <span className="grw-count-badge px-2 badge bg-body-tertiary text-body-tertiary">
     <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>
     </span>
   );
   );
 };
 };

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

@@ -1,14 +1,13 @@
 import type { FC } from 'react';
 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 { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Tooltip } from 'reactstrap';
 import { Tooltip } from 'reactstrap';
 
 
 type Props = {
 type Props = {
-  message: string
-  textToBeCopied?: string
-}
+  message: string;
+  textToBeCopied?: string;
+};
 
 
 // To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
 // To get different messages for each copy happend, wrapping CopyToClipBoard and Tooltip together
 const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
 const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
@@ -26,7 +25,9 @@ const CustomCopyToClipBoard: FC<Props> = (props: Props) => {
     <>
     <>
       <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
       <CopyToClipboard text={props.textToBeCopied || ''} onCopy={showToolTip}>
         <div className="btn input-group-text" id="tooltipTarget">
         <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>
         </div>
       </CopyToClipboard>
       </CopyToClipboard>
       <Tooltip target="tooltipTarget" fade={false} isOpen={tooltipOpen}>
       <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';
 import { useDrawerOpened } from '~/states/ui/sidebar';
 
 
@@ -6,14 +6,12 @@ import styles from './DrawerToggler.module.scss';
 
 
 const moduleClass = styles['grw-drawer-toggler'];
 const moduleClass = styles['grw-drawer-toggler'];
 
 
-
 type Props = {
 type Props = {
-  className?: string,
-  children?: ReactNode,
-}
+  className?: string;
+  children?: ReactNode;
+};
 
 
 export const DrawerToggler = (props: Props): JSX.Element => {
 export const DrawerToggler = (props: Props): JSX.Element => {
-
   const { className, children } = props;
   const { className, children } = props;
 
 
   const [isOpened, setIsOpened] = useDrawerOpened();
   const [isOpened, setIsOpened] = useDrawerOpened();
@@ -31,5 +29,4 @@ export const DrawerToggler = (props: Props): JSX.Element => {
       </button>
       </button>
     </div>
     </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 { render } from '@testing-library/react';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
 import { PageItemControl } from './PageItemControl';
 import { PageItemControl } from './PageItemControl';
 
 
-
 // mock for isIPageInfoForOperation and isIPageInfoForEmpty
 // mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 
 const mocks = vi.hoisted(() => ({
 const mocks = vi.hoisted(() => ({
@@ -20,10 +20,9 @@ vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
   isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 }));
 
 
-
 describe('PageItemControl.tsx', () => {
 describe('PageItemControl.tsx', () => {
   describe('Should trigger onClickRenameMenuItem() when clicking the rename button', () => {
   describe('Should trigger onClickRenameMenuItem() when clicking the rename button', () => {
-    it('without fetching PageInfo by useSWRxPageInfo', async() => {
+    it('without fetching PageInfo by useSWRxPageInfo', async () => {
       // setup
       // setup
       const pageInfo = mock<IPageInfoForOperation>();
       const pageInfo = mock<IPageInfoForOperation>();
 
 
@@ -47,7 +46,9 @@ describe('PageItemControl.tsx', () => {
       render(<PageItemControl {...props} />);
       render(<PageItemControl {...props} />);
 
 
       // when
       // 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);
       fireEvent.click(button);
       const renameMenuItem = await screen.findByTestId('rename-page-btn');
       const renameMenuItem = await screen.findByTestId('rename-page-btn');
       fireEvent.click(renameMenuItem);
       fireEvent.click(renameMenuItem);
@@ -56,7 +57,7 @@ describe('PageItemControl.tsx', () => {
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
     });
 
 
-    it('with empty page (IPageInfoForEmpty)', async() => {
+    it('with empty page (IPageInfoForEmpty)', async () => {
       // setup - Create an empty page mock with required properties
       // setup - Create an empty page mock with required properties
       const pageInfo: IPageInfoForEmpty = {
       const pageInfo: IPageInfoForEmpty = {
         emptyPageId: 'empty-page-id',
         emptyPageId: 'empty-page-id',
@@ -94,14 +95,19 @@ describe('PageItemControl.tsx', () => {
       render(<PageItemControl {...props} />);
       render(<PageItemControl {...props} />);
 
 
       // when
       // 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);
       fireEvent.click(button);
       const renameMenuItem = await screen.findByTestId('rename-page-btn');
       const renameMenuItem = await screen.findByTestId('rename-page-btn');
       fireEvent.click(renameMenuItem);
       fireEvent.click(renameMenuItem);
 
 
       // then
       // then
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
       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 {
 import {
-  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
+  type IPageInfoExt,
+  isIPageInfoForEmpty,
+  isIPageInfoForOperation,
 } from '@growi/core/dist/interfaces';
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
-  Dropdown, DropdownMenu, DropdownToggle, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
@@ -19,7 +21,6 @@ import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 
 const logger = loggerFactory('growi:cli:PageItemControl');
 const logger = loggerFactory('growi:cli:PageItemControl');
 
 
-
 export const MenuItemType = {
 export const MenuItemType = {
   BOOKMARK: 'bookmark',
   BOOKMARK: 'bookmark',
   RENAME: 'rename',
   RENAME: 'rename',
@@ -29,276 +30,355 @@ export const MenuItemType = {
   PATH_RECOVERY: 'pathRecovery',
   PATH_RECOVERY: 'pathRecovery',
   SWITCH_CONTENT_WIDTH: 'switch_content_width',
   SWITCH_CONTENT_WIDTH: 'switch_content_width',
 } as const;
 } as const;
-export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
+export type MenuItemType = (typeof MenuItemType)[keyof typeof MenuItemType];
 
 
 export type ForceHideMenuItems = MenuItemType[];
 export type ForceHideMenuItems = MenuItemType[];
 
 
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 
 
 type CommonProps = {
 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 & {
 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';
 PageItemControlDropdownMenu.displayName = 'PageItemControl';
 
 
-
 type PageItemControlSubstanceProps = CommonProps & {
 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 {
   const {
-    pageId, pageInfo: presetPageInfo, children, onClickBookmarkMenuItem, onClickRenameMenuItem,
-    onClickDuplicateMenuItem, onClickDeleteMenuItem, onClickPathRecoveryMenuItem,
+    pageId,
+    pageInfo: presetPageInfo,
+    children,
+    onClickBookmarkMenuItem,
+    onClickRenameMenuItem,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    onClickPathRecoveryMenuItem,
   } = props;
   } = props;
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
   const [shouldFetch, setShouldFetch] = 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)
   // update shouldFetch (and will never be false)
   useEffect(() => {
   useEffect(() => {
@@ -311,42 +391,47 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   }, [isOpen, presetPageInfo, shouldFetch]);
   }, [isOpen, presetPageInfo, shouldFetch]);
 
 
   // mutate after handle event
   // 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)
   // 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) {
     if (onClickRenameMenuItem == null) {
       return;
       return;
     }
     }
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
     await onClickRenameMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
   }, [onClickRenameMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
-  const duplicateMenuItemClickHandler = useCallback(async() => {
+  const duplicateMenuItemClickHandler = useCallback(async () => {
     if (onClickDuplicateMenuItem == null) {
     if (onClickDuplicateMenuItem == null) {
       return;
       return;
     }
     }
     await onClickDuplicateMenuItem(pageId);
     await onClickDuplicateMenuItem(pageId);
   }, [onClickDuplicateMenuItem, pageId]);
   }, [onClickDuplicateMenuItem, pageId]);
 
 
-  const deleteMenuItemClickHandler = useCallback(async() => {
+  const deleteMenuItemClickHandler = useCallback(async () => {
     if (onClickDeleteMenuItem == null) {
     if (onClickDeleteMenuItem == null) {
       return;
       return;
     }
     }
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
     await onClickDeleteMenuItem(pageId, fetchedPageInfo ?? presetPageInfo);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
   }, [onClickDeleteMenuItem, pageId, fetchedPageInfo, presetPageInfo]);
 
 
-  const pathRecoveryMenuItemClickHandler = useCallback(async() => {
+  const pathRecoveryMenuItemClickHandler = useCallback(async () => {
     if (onClickPathRecoveryMenuItem == null) {
     if (onClickPathRecoveryMenuItem == null) {
       return;
       return;
     }
     }
@@ -355,14 +440,23 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
 
 
   return (
   return (
     <NotAvailableForGuest>
     <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>
             <span className="material-symbols-outlined">more_vert</span>
           </DropdownToggle>
           </DropdownToggle>
-        ) }
+        )}
 
 
-        { isOpen && (
+        {isOpen && (
           <PageItemControlDropdownMenu
           <PageItemControlDropdownMenu
             {...props}
             {...props}
             isLoading={isLoading}
             isLoading={isLoading}
@@ -374,21 +468,17 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
             onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
             onClickPathRecoveryMenuItem={pathRecoveryMenuItemClickHandler}
           />
           />
-        ) }
+        )}
       </Dropdown>
       </Dropdown>
-
     </NotAvailableForGuest>
     </NotAvailableForGuest>
-
   );
   );
-
 };
 };
 
 
-
 export type PageItemControlProps = CommonProps & {
 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 => {
 export const PageItemControl = (props: PageItemControlProps): JSX.Element => {
   const { pageId } = props;
   const { pageId } = props;

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

@@ -1,16 +1,9 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
 import React, { useCallback, useEffect, useState } from 'react';
-
 import canvasToBlob from 'async-canvas-to-blob';
 import canvasToBlob from 'async-canvas-to-blob';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import ReactCrop from 'react-image-crop';
 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 { toastError } from '~/client/util/toastr';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -19,28 +12,32 @@ import 'react-image-crop/dist/ReactCrop.css';
 const logger = loggerFactory('growi:ImageCropModal');
 const logger = loggerFactory('growi:ImageCropModal');
 
 
 interface ICropOptions {
 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 = {
 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 ImageCropModal: FC<Props> = (props: Props) => {
-
   const {
   const {
-    isShow, src, onModalClose, onImageProcessCompleted, isCircular, showCropOption,
+    isShow,
+    src,
+    onModalClose,
+    onImageProcessCompleted,
+    isCircular,
+    showCropOption,
   } = props;
   } = props;
 
 
   const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
   const [imageRef, setImageRef] = useState<HTMLImageElement | null>(null);
@@ -77,91 +74,133 @@ const ImageCropModal: FC<Props> = (props: Props) => {
   }, [reset]);
   }, [reset]);
 
 
   // Memoize image processing functions
   // 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
   // Convert base64 Image to blob
-  const convertBase64ToBlob = useCallback(async(base64Image: string) => {
+  const convertBase64ToBlob = useCallback(async (base64Image: string) => {
     const base64Response = await fetch(base64Image);
     const base64Response = await fetch(base64Image);
     return base64Response.blob();
     return base64Response.blob();
   }, []);
   }, []);
 
 
-
   // Memoize event handlers
   // Memoize event handlers
-  const onModalCloseHandler = useCallback(async() => {
+  const onModalCloseHandler = useCallback(async () => {
     setImageRef(null);
     setImageRef(null);
     onModalClose();
     onModalClose();
   }, [onModalClose]);
   }, [onModalClose]);
 
 
-  const processAndSaveImage = useCallback(async() => {
+  const processAndSaveImage = useCallback(async () => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
     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
       // Save image to database
       onImageProcessCompleted(processedImage);
       onImageProcessCompleted(processedImage);
     }
     }
     onModalCloseHandler();
     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 (
   return (
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
       {isShow && (
       {isShow && (
         <>
         <>
-          <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
+          <ModalHeader
+            tag="h4"
+            toggle={onModalCloseHandler}
+            className="text-info"
+          >
             {t('crop_image_modal.image_crop')}
             {t('crop_image_modal.image_crop')}
           </ModalHeader>
           </ModalHeader>
           <ModalBody className="my-4">
           <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>
           </ModalBody>
           <ModalFooter>
           <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')}
               {t('commons:Reset')}
             </button>
             </button>
-            { !showCropOption && (
+            {!showCropOption && (
               <div className="me-auto">
               <div className="me-auto">
                 <div className="form-check form-switch">
                 <div className="form-check form-switch">
                   <input
                   <input
@@ -171,18 +210,30 @@ const ImageCropModal: FC<Props> = (props: Props) => {
                     checked={isCropImage}
                     checked={isCropImage}
                     onChange={toggleCropMode}
                     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>
                   </label>
                 </div>
                 </div>
               </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')}
               {t('crop_image_modal.cancel')}
             </button>
             </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>
             </button>
           </ModalFooter>
           </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 = {
 type Props = {
-  shouldRender: boolean | (() => boolean),
-  children: JSX.Element,
-}
+  shouldRender: boolean | (() => boolean);
+  children: JSX.Element;
+};
 
 
 export const LazyRenderer = (props: Props): JSX.Element => {
 export const LazyRenderer = (props: Props): JSX.Element => {
   const { shouldRender: _shouldRender, children } = props;
   const { shouldRender: _shouldRender, children } = props;
 
 
   const [isActivated, setActivated] = useState(false);
   const [isActivated, setActivated] = useState(false);
 
 
-  const shouldRender = typeof _shouldRender === 'function'
-    ? _shouldRender()
-    : _shouldRender;
+  const shouldRender =
+    typeof _shouldRender === 'function' ? _shouldRender() : _shouldRender;
 
 
   useEffect(() => {
   useEffect(() => {
     if (isActivated) {
     if (isActivated) {
@@ -28,5 +27,4 @@ export const LazyRenderer = (props: Props): JSX.Element => {
   const child = React.Children.only(children);
   const child = React.Children.only(children);
 
 
   return React.cloneElement(child, { visibility: shouldRender });
   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 = () => {
 export const RendererErrorMessage: React.FC = () => {
   return (
   return (
     <p className="alert alert-warning">
     <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>
     </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 type { AutosizeInputProps } from 'react-input-autosize';
 import AutosizeInput from 'react-input-autosize';
 import AutosizeInput from 'react-input-autosize';
 
 
 import type { SubmittableInputProps } from './types';
 import type { SubmittableInputProps } from './types';
 import { useSubmittable } from './use-submittable';
 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
   // eslint-disable-next-line no-nested-ternary
   const bsFormPaddingSize = size === 'sm' ? 8 : size === 'md' ? 12 : 16; // by bootstrap form
   const bsFormPaddingSize = size === 'sm' ? 8 : size === 'md' ? 12 : 16; // by bootstrap form
   // eslint-disable-next-line no-nested-ternary
   // eslint-disable-next-line no-nested-ternary
   const bsValidationIconSize = size === 'sm' ? 25 : size === 'md' ? 24 : 26; // by bootstrap form validation
   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);
   const submittableProps = useSubmittable(props);
 
 
   return (
   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 type { SubmittableInputProps } from './types';
 import { useSubmittable } from './use-submittable';
 import { useSubmittable } from './use-submittable';
 
 
-
-export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTMLInputElement> => {
+export const SubmittableInput = (
+  props: SubmittableInputProps,
+): ReactElement<HTMLInputElement> => {
   // // autoFocus
   // // autoFocus
   // useEffect(() => {
   // useEffect(() => {
   //   if (inputRef?.current == null) {
   //   if (inputRef?.current == null) {
@@ -17,7 +16,5 @@ export const SubmittableInput = (props: SubmittableInputProps): ReactElement<HTM
 
 
   const submittableProps = useSubmittable(props);
   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 './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 type React from 'react';
-import {
-  useCallback, useState,
-} from 'react';
+import type { CompositionEvent } from 'react';
+import { useCallback, useState } from 'react';
 
 
 import type { SubmittableInputProps } from './types';
 import type { SubmittableInputProps } from './types';
 
 
-export const useSubmittable = (props: SubmittableInputProps): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
-
+export const useSubmittable = (
+  props: SubmittableInputProps,
+): Partial<React.InputHTMLAttributes<HTMLInputElement>> => {
   const {
   const {
     value,
     value,
-    onChange, onBlur,
-    onCompositionStart, onCompositionEnd,
-    onSubmit, onCancel,
+    onChange,
+    onBlur,
+    onCompositionStart,
+    onCompositionEnd,
+    onSubmit,
+    onCancel,
   } = props;
   } = props;
 
 
   const [inputText, setInputText] = useState(value ?? '');
   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 [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 {
   const {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    value: _value, onSubmit: _onSubmit, onCancel: _onCancel,
+    value: _value,
+    onSubmit: _onSubmit,
+    onCancel: _onCancel,
     ...cleanedProps
     ...cleanedProps
   } = props;
   } = props;
 
 
@@ -84,5 +103,4 @@ export const useSubmittable = (props: SubmittableInputProps): Partial<React.Inpu
     onCompositionStart: compositionStartHandler,
     onCompositionStart: compositionStartHandler,
     onCompositionEnd: compositionEndHandler,
     onCompositionEnd: compositionEndHandler,
   };
   };
-
 };
 };

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

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

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React from 'react';
 import React from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 export const CompleteUserRegistration: FC = () => {
 export const CompleteUserRegistration: FC = () => {
@@ -15,7 +14,8 @@ export const CompleteUserRegistration: FC = () => {
           </p>
           </p>
           {/* If the transition source is "/login", use <a /> tag since the transition will not occur if next/link is used. */}
           {/* If the transition source is "/login", use <a /> tag since the transition will not occur if next/link is used. */}
           <a href="/login">
           <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>
           </a>
         </div>
         </div>
       </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 { useRouter } from 'next/router';
+import { useTranslation } from 'next-i18next';
 
 
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 import { UserActivationErrorCode } from '~/interfaces/errors/user-activation';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 
 
 import { toastError } from '../util/toastr';
 import { toastError } from '../util/toastr';
-
 import { CompleteUserRegistration } from './CompleteUserRegistration';
 import { CompleteUserRegistration } from './CompleteUserRegistration';
 
 
-
 import styles from './CompleteUserRegistrationForm.module.scss';
 import styles from './CompleteUserRegistrationForm.module.scss';
 
 
 const moduleClass = styles['complete-user-registration-form'] ?? '';
 const moduleClass = styles['complete-user-registration-form'] ?? '';
 
 
-
 interface Props {
 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) => {
 export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
     email,
     email,
@@ -48,14 +44,13 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
   const router = useRouter();
   const router = useRouter();
 
 
   useEffect(() => {
   useEffect(() => {
-    const delayDebounceFn = setTimeout(async() => {
+    const delayDebounceFn = setTimeout(async () => {
       try {
       try {
         const { data } = await apiv3Get('/check-username', { username });
         const { data } = await apiv3Get('/check-username', { username });
         if (data.ok) {
         if (data.ok) {
           setUsernameAvailable(data.valid);
           setUsernameAvailable(data.valid);
         }
         }
-      }
-      catch (error) {
+      } catch (error) {
         toastError(error);
         toastError(error);
       }
       }
     }, 500);
     }, 500);
@@ -63,64 +58,83 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
     return () => clearTimeout(delayDebounceFn);
     return () => clearTimeout(delayDebounceFn);
   }, [username]);
   }, [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 <CompleteUserRegistration />;
   }
   }
 
 
   return (
   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="row mx-0">
           <div className="col-12 px-4">
           <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">
               <p className="alert alert-danger">
                 <span>{t('message.email_authentication_is_not_enabled')}</span>
                 <span>{t('message.email_authentication_is_not_enabled')}</span>
               </p>
               </p>
             )}
             )}
 
 
-            <form role="form" onSubmit={handleSubmitRegistration} id="registration-form">
+            <form onSubmit={handleSubmitRegistration} id="registration-form">
               <input type="hidden" name="token" value={token} />
               <input type="hidden" name="token" value={token} />
 
 
               <div className="input-group">
               <div className="input-group">
                 <span className="p-2 text-white opacity-75">
                 <span className="p-2 text-white opacity-75">
                   <span className="material-symbols-outlined">mail</span>
                   <span className="material-symbols-outlined">mail</span>
                 </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>
 
 
               <div className="input-group" id="input-group-username">
               <div className="input-group" id="input-group-username">
@@ -132,7 +146,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   className="form-control rounded"
                   className="form-control rounded"
                   placeholder={t('User ID')}
                   placeholder={t('User ID')}
                   name="username"
                   name="username"
-                  onChange={e => setUsername(e.target.value)}
+                  onChange={(e) => setUsername(e.target.value)}
                   required
                   required
                   disabled={forceDisableForm || disableForm}
                   disabled={forceDisableForm || disableForm}
                 />
                 />
@@ -158,7 +172,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   placeholder={t('Name')}
                   placeholder={t('Name')}
                   name="name"
                   name="name"
                   value={name}
                   value={name}
-                  onChange={e => setName(e.target.value)}
+                  onChange={(e) => setName(e.target.value)}
                   required
                   required
                   disabled={forceDisableForm || disableForm}
                   disabled={forceDisableForm || disableForm}
                 />
                 />
@@ -174,7 +188,7 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
                   placeholder={t('Password')}
                   placeholder={t('Password')}
                   name="password"
                   name="password"
                   value={password}
                   value={password}
-                  onChange={e => setPassword(e.target.value)}
+                  onChange={(e) => setPassword(e.target.value)}
                   required
                   required
                   disabled={forceDisableForm || disableForm}
                   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"
                   className="btn btn-secondary btn-register col-6 mx-auto d-flex"
                 >
                 >
                   <span>
                   <span>
-                    <span className="material-symbols-outlined">person_add</span>
+                    <span className="material-symbols-outlined">
+                      person_add
+                    </span>
                   </span>
                   </span>
                   <span className="flex-grow-1">{t('Create')}</span>
                   <span className="flex-grow-1">{t('Create')}</span>
                 </button>
                 </button>
@@ -195,7 +211,8 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
 
 
               <div className="input-group mt-5 d-flex">
               <div className="input-group mt-5 d-flex">
                 <a href="https://growi.org" className="link-growi-org">
                 <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>
                 </a>
               </div>
               </div>
             </form>
             </form>
@@ -204,5 +221,4 @@ export const CompleteUserRegistrationForm: React.FC<Props> = (props: Props) => {
       </div>
       </div>
     </>
     </>
   );
   );
-
 };
 };

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

@@ -1,13 +1,18 @@
 import React, { type JSX } from 'react';
 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 { useTranslation } from 'next-i18next';
 import { Link as ScrollLink } from 'react-scroll';
 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 BookMarkLinkButton = React.memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
-    <ScrollLink to="bookmarks-list" offset={-120}>
+    <ScrollLink to={BOOKMARKS_LIST_ID} offset={-120}>
       <button
       <button
         type="button"
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
         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 RecentlyCreatedLinkButton = React.memo(() => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
-    <ScrollLink to="recently-created-list" offset={-120}>
+    <ScrollLink to={RECENTLY_CREATED_LIST_ID} offset={-120}>
       <button
       <button
         type="button"
         type="button"
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100 px-3"
         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';
 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 = {
 export type ContentLinkButtonsProps = {
-  author?: IUserHasId,
-}
+  author?: IUserHasId;
+};
 
 
-export const ContentLinkButtons = (props: ContentLinkButtonsProps): JSX.Element => {
+export const ContentLinkButtons = (
+  props: ContentLinkButtonsProps,
+): JSX.Element => {
   const { author } = props;
   const { author } = props;
 
 
   if (author == null || author.status === USER_STATUS.DELETED) {
   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">
     <div className="d-grid gap-2">
       <BookMarkLinkButton />
       <BookMarkLinkButton />
       <RecentlyCreatedLinkButton />
       <RecentlyCreatedLinkButton />
+      <RecentActivityLinkButton />
     </div>
     </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 { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 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 { useCreateTemplatePage } from '~/client/services/create-page';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
-import type { TargetType, LabelType } from '~/interfaces/template';
-
+import type { LabelType, TargetType } from '~/interfaces/template';
 
 
 type TemplateCardProps = {
 type TemplateCardProps = {
   target: TargetType;
   target: TargetType;
@@ -17,7 +16,10 @@ type TemplateCardProps = {
 };
 };
 
 
 const TemplateCard: React.FC<TemplateCardProps> = ({
 const TemplateCard: React.FC<TemplateCardProps> = ({
-  target, label, isPageCreating, onClickHandler,
+  target,
+  label,
+  isPageCreating,
+  onClickHandler,
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -25,8 +27,12 @@ const TemplateCard: React.FC<TemplateCardProps> = ({
     <div className="card card-select-template">
     <div className="card card-select-template">
       <div className="card-header">{t(`template.${target}.label`)}</div>
       <div className="card-header">{t(`template.${target}.label`)}</div>
       <div className="card-body">
       <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>
       <div className="card-footer text-center">
       <div className="card-footer text-center">
         <button
         <button
@@ -51,50 +57,58 @@ type CreateTemplateModalProps = {
 };
 };
 
 
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
 export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
-  path, isOpen, onClose,
+  path,
+  isOpen,
+  onClose,
 }) => {
 }) => {
   const { t } = useTranslation(['translation', 'commons']);
   const { t } = useTranslation(['translation', 'commons']);
 
 
   const { createTemplate, isCreating, isCreatable } = useCreateTemplatePage();
   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
   // Memoize computed path
   const parentPath = useMemo(() => pathUtils.addTrailingSlash(path), [path]);
   const parentPath = useMemo(() => pathUtils.addTrailingSlash(path), [path]);
 
 
   // Memoize template card rendering function
   // 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 (
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
-      {(isCreatable && isOpen) && (
+      {isCreatable && isOpen && (
         <>
         <>
           <ModalHeader tag="h4" toggle={onClose}>
           <ModalHeader tag="h4" toggle={onClose}>
             {t('template.modal_label.Create/Edit Template Page')}
             {t('template.modal_label.Create/Edit Template Page')}
           </ModalHeader>
           </ModalHeader>
           <ModalBody>
           <ModalBody>
             <div>
             <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')}
                 {t('template.modal_label.Create template under')}
-              </label>
+              </div>
               <div className="row row-cols-2">
               <div className="row row-cols-2">
                 {renderTemplateCard('children', '_template')}
                 {renderTemplateCard('children', '_template')}
                 {renderTemplateCard('descendants', '__template')}
                 {renderTemplateCard('descendants', '__template')}

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

@@ -8,12 +8,21 @@ type CreateTemplateModalProps = {
   onClose: () => void;
   onClose: () => void;
 };
 };
 
 
-export const CreateTemplateModalLazyLoaded = (props: CreateTemplateModalProps): JSX.Element => {
+export const CreateTemplateModalLazyLoaded = (
+  props: CreateTemplateModalProps,
+): JSX.Element => {
   const CreateTemplateModal = useLazyLoader<CreateTemplateModalProps>(
   const CreateTemplateModal = useLazyLoader<CreateTemplateModalProps>(
     'create-template-modal',
     'create-template-modal',
-    () => import('./CreateTemplateModal').then(mod => ({ default: mod.CreateTemplateModal })),
+    () =>
+      import('./CreateTemplateModal').then((mod) => ({
+        default: mod.CreateTemplateModal,
+      })),
     props.isOpen,
     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, {
 import React, {
-  useEffect, useState, useRef, useMemo, useCallback, type JSX,
+  type JSX,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
 } from 'react';
 } from 'react';
-
 import type { Breakpoint } from '@growi/ui/dist/interfaces';
 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 type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 
 import styles from './CustomNav.module.scss';
 import styles from './CustomNav.module.scss';
 
 
-
-function getBreakpointOneLevelLarger(breakpoint: Breakpoint): Omit<Breakpoint, 'xs' | 'sm'> {
+function getBreakpointOneLevelLarger(
+  breakpoint: Breakpoint,
+): Omit<Breakpoint, 'xs' | 'sm'> {
   switch (breakpoint) {
   switch (breakpoint) {
     case 'xs':
     case 'xs':
       return 'sm';
       return 'sm';
@@ -28,17 +31,16 @@ function getBreakpointOneLevelLarger(breakpoint: Breakpoint): Omit<Breakpoint, '
   }
   }
 }
 }
 
 
-
 type CustomNavDropdownProps = {
 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];
   const { Icon, i18n } = navTabMapping[activeTab];
 
 
@@ -47,19 +49,22 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   const dropdownButtonRef = useRef<HTMLButtonElement>(null);
   const dropdownButtonRef = useRef<HTMLButtonElement>(null);
 
 
   const toggleDropdown = () => {
   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 (
   return (
     <div className="btn-group">
     <div className="btn-group">
@@ -74,15 +79,19 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
         data-testid="custom-nav-dropdown"
         data-testid="custom-nav-dropdown"
       >
       >
         <span className="float-start">
         <span className="float-start">
-          { Icon != null && <Icon /> } {i18n}
+          {Icon != null && <Icon />} {i18n}
         </span>
         </span>
       </button>
       </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]) => {
         {Object.entries(navTabMapping).map(([key, value]) => {
-
           const isActive = activeTab === key;
           const isActive = activeTab === key;
           const _isLinkEnabled = value.isLinkEnabled ?? true;
           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;
           const { Icon, i18n } = value;
 
 
           return (
           return (
@@ -93,7 +102,7 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
               disabled={!isLinkEnabled}
               disabled={!isLinkEnabled}
               onClick={() => menuItemClickHandler(key)}
               onClick={() => menuItemClickHandler(key)}
             >
             >
-              { Icon != null && <Icon /> } {i18n}
+              {Icon != null && <Icon />} {i18n}
             </button>
             </button>
           );
           );
         })}
         })}
@@ -102,14 +111,13 @@ export const CustomNavDropdown = (props: CustomNavDropdownProps): JSX.Element =>
   );
   );
 };
 };
 
 
-
 type CustomNavTabProps = {
 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 => {
 export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
@@ -117,9 +125,12 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
   const [sliderMarginLeft, setSliderMarginLeft] = useState(0);
 
 
   const {
   const {
-    activeTab, navTabMapping, onNavSelected,
+    activeTab,
+    navTabMapping,
+    onNavSelected,
     hideBorderBottom,
     hideBorderBottom,
-    breakpointToHideInactiveTabsDown, navRightElement,
+    breakpointToHideInactiveTabsDown,
+    navRightElement,
   } = props;
   } = props;
 
 
   const navContainerRef = useRef<HTMLDivElement>(null);
   const navContainerRef = useRef<HTMLDivElement>(null);
@@ -132,11 +143,14 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
     return obj;
     return obj;
   }, [navTabMapping]);
   }, [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) {
   function registerNavLink(key: string, anchorElem: HTMLAnchorElement | null) {
     if (anchorElem != null) {
     if (anchorElem != null) {
@@ -145,9 +159,9 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
   }
   }
 
 
   // Might make this dynamic for px, %, pt, em
   // 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(() => {
   useEffect(() => {
     if (activeTab == null || activeTab === '') {
     if (activeTab == null || activeTab === '') {
@@ -162,7 +176,10 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
 
 
     let marginLeft = 0;
     let marginLeft = 0;
     for (const [key, anchorElem] of Object.entries(navTabRefs)) {
     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) {
       if (key === activeTab) {
         setSliderWidth(width);
         setSliderWidth(width);
@@ -172,25 +189,32 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
 
 
       marginLeft += width;
       marginLeft += width;
     }
     }
-  }, [activeTab, navTabRefs, navTabMapping]);
+  }, [activeTab, getPercentage, navTabRefs]);
 
 
   // determine inactive classes to hide NavItem
   // determine inactive classes to hide NavItem
   const inactiveClassnames: string[] = [];
   const inactiveClassnames: string[] = [];
   if (breakpointToHideInactiveTabsDown != null) {
   if (breakpointToHideInactiveTabsDown != null) {
-    const breakpointOneLevelLarger = getBreakpointOneLevelLarger(breakpointToHideInactiveTabsDown);
+    const breakpointOneLevelLarger = getBreakpointOneLevelLarger(
+      breakpointToHideInactiveTabsDown,
+    );
     inactiveClassnames.push('d-none');
     inactiveClassnames.push('d-none');
     inactiveClassnames.push(`d-${breakpointOneLevelLarger}-block`);
     inactiveClassnames.push(`d-${breakpointOneLevelLarger}-block`);
   }
   }
 
 
   return (
   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">
       <div ref={navContainerRef} className="d-flex justify-content-between">
         <Nav className="nav-title">
         <Nav className="nav-title">
           {Object.entries(navTabMapping).map(([key, value]) => {
           {Object.entries(navTabMapping).map(([key, value]) => {
-
             const isActive = activeTab === key;
             const isActive = activeTab === key;
             const _isLinkEnabled = value.isLinkEnabled ?? true;
             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;
             const { Icon, i18n } = value;
 
 
             return (
             return (
@@ -198,8 +222,19 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
                 key={key}
                 key={key}
                 className={`p-0 ${isActive ? 'active' : inactiveClassnames.join(' ')}`}
                 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>
                 </NavLink>
               </NavItem>
               </NavItem>
             );
             );
@@ -207,30 +242,32 @@ export const CustomNavTab = (props: CustomNavTabProps): JSX.Element => {
         </Nav>
         </Nav>
         {navRightElement}
         {navRightElement}
       </div>
       </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>
     </div>
   );
   );
-
 };
 };
 
 
-
 type CustomNavProps = {
 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 CustomNav = (props: CustomNavProps): JSX.Element => {
-
   const tabClassnames = ['d-none'];
   const tabClassnames = ['d-none'];
   const dropdownClassnames = ['d-block'];
   const dropdownClassnames = ['d-block'];
 
 
   // determine classes to show/hide
   // determine classes to show/hide
-  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(props.breakpointToSwitchDropdownDown ?? 'sm');
+  const breakpointOneLevelLarger = getBreakpointOneLevelLarger(
+    props.breakpointToSwitchDropdownDown ?? 'sm',
+  );
   tabClassnames.push(`d-${breakpointOneLevelLarger}-block`);
   tabClassnames.push(`d-${breakpointOneLevelLarger}-block`);
   dropdownClassnames.push(`d-${breakpointOneLevelLarger}-none`);
   dropdownClassnames.push(`d-${breakpointOneLevelLarger}-none`);
 
 
@@ -244,7 +281,6 @@ const CustomNav = (props: CustomNavProps): JSX.Element => {
       </div>
       </div>
     </div>
     </div>
   );
   );
-
 };
 };
 
 
 export default CustomNav;
 export default CustomNav;

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

@@ -1,26 +1,34 @@
 import type { ReactNode } from 'react';
 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';
 import CustomTabContent from './CustomTabContent';
 
 
 type CustomNavAndContentsProps = {
 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 {
   const {
-    navTabMapping, defaultTabIndex, navigationMode = 'tab', tabContentClasses = ['p-4'], breakpointToHideInactiveTabsDown, navRightElement,
+    navTabMapping,
+    defaultTabIndex,
+    navigationMode = 'tab',
+    tabContentClasses = ['p-4'],
+    breakpointToHideInactiveTabsDown,
+    navRightElement,
   } = props;
   } = 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) {
   switch (navigationMode) {
     case 'tab':
     case 'tab':
       SelectedNav = CustomNavTab;
       SelectedNav = CustomNavTab;
@@ -42,7 +50,11 @@ const CustomNavAndContents = (props: CustomNavAndContentsProps): JSX.Element =>
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
         breakpointToHideInactiveTabsDown={breakpointToHideInactiveTabsDown}
         navRightElement={navRightElement}
         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 React, { type JSX } from 'react';
-
-import {
-  TabContent, TabPane,
-} from 'reactstrap';
+import { TabContent, TabPane } from 'reactstrap';
 
 
 import type { ICustomNavTabMappings } from '~/interfaces/ui';
 import type { ICustomNavTabMappings } from '~/interfaces/ui';
 
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 import { LazyRenderer } from '../Common/LazyRenderer';
 
 
-
 type Props = {
 type Props = {
-  navTabMapping: ICustomNavTabMappings,
-  activeTab?: string,
-  additionalClassNames?: string[],
-}
+  navTabMapping: ICustomNavTabMappings;
+  activeTab?: string;
+  additionalClassNames?: string[];
+};
 
 
 const CustomTabContent = (props: Props): JSX.Element => {
 const CustomTabContent = (props: Props): JSX.Element => {
-
   const { activeTab, navTabMapping, additionalClassNames } = props;
   const { activeTab, navTabMapping, additionalClassNames } = props;
 
 
   return (
   return (
-    <TabContent activeTab={activeTab} className={additionalClassNames != null ? additionalClassNames.join(' ') : ''}>
+    <TabContent
+      activeTab={activeTab}
+      className={
+        additionalClassNames != null ? additionalClassNames.join(' ') : ''
+      }
+    >
       {Object.entries(navTabMapping).map(([key, value]) => {
       {Object.entries(navTabMapping).map(([key, value]) => {
-
         const { Content } = value;
         const { Content } = value;
         const content = Content != null ? <Content /> : <></>;
         const content = Content != null ? <Content /> : <></>;
 
 
@@ -36,7 +35,6 @@ const CustomTabContent = (props: Props): JSX.Element => {
       })}
       })}
     </TabContent>
     </TabContent>
   );
   );
-
 };
 };
 
 
 export default CustomTabContent;
 export default CustomTabContent;

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

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
@@ -15,19 +14,31 @@ const DataTransferForm = (): JSX.Element => {
   return (
   return (
     <div data-testid="installerForm" className="py-3 px-4">
     <div data-testid="installerForm" className="py-3 px-4">
       <p className="text-white fs-5 mt-2">
       <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>
       </p>
 
 
       <div className="row mt-3">
       <div className="row mt-3">
         <div className="col-md-12">
         <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')}
             {t('g2g_data_transfer.publish_transfer_key')}
           </button>
           </button>
         </div>
         </div>
         <div className="col-md-12 mt-2">
         <div className="col-md-12 mt-2">
           <div className="d-flex">
           <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>
         </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-1">{t('g2g_data_transfer.once_transfer_key_used')}</p>
         <p
         <p
           className="mb-0"
           className="mb-0"
-          // eslint-disable-next-line react/no-danger
+          // biome-ignore lint/security/noDangerouslySetInnerHtml: translation contains HTML link
           dangerouslySetInnerHTML={{
           dangerouslySetInnerHTML={{
-            __html: t('g2g_data_transfer.transfer_to_growi_cloud', { documentationUrl }),
+            __html: t('g2g_data_transfer.transfer_to_growi_cloud', {
+              documentationUrl,
+            }),
           }}
           }}
         />
         />
       </div>
       </div>

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

@@ -1,17 +1,16 @@
-
 import type { FC } from 'react';
 import type { FC } from 'react';
 import { useCallback } from 'react';
 import { useCallback } from 'react';
-
 import { useTranslation } from 'next-i18next';
 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 { FolderIcon } from '~/client/components/Icons/FolderIcon';
 import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
 import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
 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)
  * DeleteBookmarkFolderModalSubstance - Presentation component (all logic here)
@@ -29,20 +28,19 @@ const DeleteBookmarkFolderModalSubstance = ({
 }: DeleteBookmarkFolderModalSubstanceProps): React.JSX.Element => {
 }: DeleteBookmarkFolderModalSubstanceProps): React.JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const deleteBookmark = useCallback(async() => {
+  const deleteBookmark = useCallback(async () => {
     try {
     try {
       await deleteBookmarkFolder(bookmarkFolder._id);
       await deleteBookmarkFolder(bookmarkFolder._id);
       if (onDeleted != null) {
       if (onDeleted != null) {
         onDeleted(bookmarkFolder._id);
         onDeleted(bookmarkFolder._id);
       }
       }
       closeModal();
       closeModal();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [bookmarkFolder, onDeleted, closeModal]);
   }, [bookmarkFolder, onDeleted, closeModal]);
 
 
-  const onClickDeleteButton = useCallback(async() => {
+  const onClickDeleteButton = useCallback(async () => {
     await deleteBookmark();
     await deleteBookmark();
   }, [deleteBookmark]);
   }, [deleteBookmark]);
 
 
@@ -54,7 +52,10 @@ const DeleteBookmarkFolderModalSubstance = ({
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div className="pb-1 text-break">
         <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}
           <FolderIcon isOpen={false} /> {bookmarkFolder?.name}
         </div>
         </div>
         {t('bookmark_folder.delete_modal.modal_body_alert')}
         {t('bookmark_folder.delete_modal.modal_body_alert')}
@@ -65,7 +66,9 @@ const DeleteBookmarkFolderModalSubstance = ({
           className="btn btn-danger"
           className="btn btn-danger"
           onClick={onClickDeleteButton}
           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')}
           {t('bookmark_folder.delete_modal.modal_footer_button')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
@@ -77,11 +80,18 @@ const DeleteBookmarkFolderModalSubstance = ({
  * DeleteBookmarkFolderModal - Container component (lightweight, always rendered)
  * DeleteBookmarkFolderModal - Container component (lightweight, always rendered)
  */
  */
 const DeleteBookmarkFolderModal: FC = () => {
 const DeleteBookmarkFolderModal: FC = () => {
-  const { isOpened, bookmarkFolder, opts } = useDeleteBookmarkFolderModalStatus();
+  const { isOpened, bookmarkFolder, opts } =
+    useDeleteBookmarkFolderModalStatus();
   const { close: closeModal } = useDeleteBookmarkFolderModalActions();
   const { close: closeModal } = useDeleteBookmarkFolderModalActions();
 
 
   return (
   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 && (
       {isOpened && bookmarkFolder != null && (
         <DeleteBookmarkFolderModalSubstance
         <DeleteBookmarkFolderModalSubstance
           bookmarkFolder={bookmarkFolder}
           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 => {
 export const DeleteBookmarkFolderModalLazyLoaded = (): JSX.Element => {
   const status = useDeleteBookmarkFolderModalStatus();
   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 /> : <></>;
   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 {
 import type {
   IDataWithMeta,
   IDataWithMeta,
   IPageHasId,
   IPageHasId,
@@ -11,10 +10,16 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPagingResult } from '~/interfaces/paging-result';
 import type { IPagingResult } from '~/interfaces/paging-result';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import {
 import {
   mutatePageTree,
   mutatePageTree,
-  useSWRxPageInfoForList, useSWRxPageList, mutateRecentlyUpdated,
+  mutateRecentlyUpdated,
+  useSWRxPageInfoForList,
+  useSWRxPageList,
 } from '~/stores/page-listing';
 } from '~/stores/page-listing';
 
 
 import type { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
 import type { ForceHideMenuItems } from './Common/Dropdown/PageItemControl';
@@ -22,30 +27,36 @@ import PageList from './PageList/PageList';
 import PaginationWrapper from './PaginationWrapper';
 import PaginationWrapper from './PaginationWrapper';
 
 
 type SubstanceProps = {
 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 };
   return { data: page };
 };
 };
 
 
 const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
 const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
-
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const {
   const {
-    pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
+    pagingResult,
+    activePage,
+    setActivePage,
+    forceHideMenuItems,
+    onPagesDeleted,
+    onPagePutBacked,
   } = props;
   } = props;
 
 
   const isGuestUser = useIsGuestUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
   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);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
 
 
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
   let pageWithMetas: IDataWithMeta<IPageHasId, IPageInfoForOperation>[] = [];
@@ -53,36 +64,43 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
   // initial data
   // initial data
   if (pagingResult != null) {
   if (pagingResult != null) {
     // convert without meta at first
     // 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
     // inject data for listing
     pageWithMetas = injectTo(dataWithMetas);
     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) {
   if (pagingResult == null) {
     return (
     return (
@@ -107,35 +125,43 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
         onPagePutBacked={pagePutBackedHandler}
         onPagePutBacked={pagePutBackedHandler}
       />
       />
 
 
-      { showPager && (
+      {showPager && (
         <div className="my-4">
         <div className="my-4">
           <PaginationWrapper
           <PaginationWrapper
             activePage={activePage}
             activePage={activePage}
-            changePage={selectedPageNumber => setActivePage(selectedPageNumber)}
+            changePage={(selectedPageNumber) =>
+              setActivePage(selectedPageNumber)
+            }
             totalItemsCount={pagingResult.totalCount}
             totalItemsCount={pagingResult.totalCount}
             pagingLimit={pagingResult.limit}
             pagingLimit={pagingResult.limit}
             align="center"
             align="center"
           />
           />
         </div>
         </div>
-      ) }
+      )}
     </>
     </>
   );
   );
 };
 };
 
 
 export type DescendantsPageListProps = {
 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 { path, limit, forceHideMenuItems } = props;
 
 
   const [activePage, setActivePage] = useState(1);
   const [activePage, setActivePage] = useState(1);
 
 
   const isSharedUser = useIsSharedUser();
   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) {
   if (error != null) {
     return (
     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';
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
 
@@ -33,7 +33,9 @@ vi.mock('~/states/context', () => ({
 }));
 }));
 
 
 vi.mock('../DescendantsPageList', () => ({
 vi.mock('../DescendantsPageList', () => ({
-  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+  DescendantsPageList: () => (
+    <div data-testid="descendants-page-list">DescendantsPageList</div>
+  ),
 }));
 }));
 
 
 vi.mock('../PageTimeline', () => ({
 vi.mock('../PageTimeline', () => ({
@@ -41,7 +43,6 @@ vi.mock('../PageTimeline', () => ({
 }));
 }));
 
 
 describe('DescendantsPageListModal.tsx', () => {
 describe('DescendantsPageListModal.tsx', () => {
-
   it('should render the modal when isOpened is true', () => {
   it('should render the modal when isOpened is true', () => {
     render(<DescendantsPageListModal />);
     render(<DescendantsPageListModal />);
     expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
     expect(screen.getByTestId('descendants-page-list-modal')).not.toBeNull();
@@ -55,7 +56,6 @@ describe('DescendantsPageListModal.tsx', () => {
   });
   });
 
 
   describe('when device is larger than lg', () => {
   describe('when device is larger than lg', () => {
-
     it('should render CustomNavTab', () => {
     it('should render CustomNavTab', () => {
       render(<DescendantsPageListModal />);
       render(<DescendantsPageListModal />);
       expect(screen.getByTestId('custom-nav-tab')).not.toBeNull();
       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 dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 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 { useIsSharedUser } from '~/states/context';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
 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 { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -21,9 +19,38 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 
 import styles from './DescendantsPageListModal.module.scss';
 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)
  * DescendantsPageListModalSubstance - Presentation component (all logic here)
@@ -58,26 +85,19 @@ const DescendantsPageListModalSubstance = ({
   const navTabMapping = useMemo(() => {
   const navTabMapping = useMemo(() => {
     return {
     return {
       pagelist: {
       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'),
         i18n: t('page_list'),
         isLinkEnabled: () => !isSharedUser,
         isLinkEnabled: () => !isSharedUser,
       },
       },
       timeline: {
       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'),
         i18n: t('Timeline View'),
         isLinkEnabled: () => !isSharedUser,
         isLinkEnabled: () => !isSharedUser,
       },
       },
     };
     };
-  }, [isSharedUser, path, t]);
+  }, [isSharedUser, t]);
 
 
   // Memoize event handlers
   // Memoize event handlers
   const expandWindow = useCallback(() => {
   const expandWindow = useCallback(() => {
@@ -90,20 +110,32 @@ const DescendantsPageListModalSubstance = ({
   }, [onExpandedChange]);
   }, [onExpandedChange]);
   const onNavSelected = useCallback((v: string) => setActiveTab(v), []);
   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 (
   return (
     <div>
     <div>
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
+      <ModalHeader
+        className={isDeviceLargerThanLg ? 'p-0' : ''}
+        toggle={closeModal}
+        close={buttons}
+      >
         {isDeviceLargerThanLg && (
         {isDeviceLargerThanLg && (
           <CustomNavTab
           <CustomNavTab
             activeTab={activeTab}
             activeTab={activeTab}
@@ -125,7 +157,11 @@ const DescendantsPageListModalSubstance = ({
         <CustomTabContent
         <CustomTabContent
           activeTab={activeTab}
           activeTab={activeTab}
           navTabMapping={navTabMapping}
           navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+          additionalClassNames={
+            !isDeviceLargerThanLg
+              ? ['grw-tab-content-style-md-down']
+              : undefined
+          }
         />
         />
       </ModalBody>
       </ModalBody>
     </div>
     </div>

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

@@ -10,7 +10,10 @@ export const DescendantsPageListModalLazyLoaded = (): JSX.Element => {
 
 
   const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
   const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
     'descendants-page-list-modal',
     'descendants-page-list-modal',
-    () => import('./DescendantsPageListModal').then(mod => ({ default: mod.DescendantsPageListModal })),
+    () =>
+      import('./DescendantsPageListModal').then((mod) => ({
+        default: mod.DescendantsPageListModal,
+      })),
     status?.isOpened ?? false,
     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 { pagePathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
-
 const { convertToNewAffiliationPath } = pagePathUtils;
 const { convertToNewAffiliationPath } = pagePathUtils;
 
 
 type DuplicatedPathsTableProps = {
 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 { t } = useTranslation();
 
 
-  const {
-    fromPath, toPath, existingPaths,
-  } = props;
+  const { fromPath, toPath, existingPaths } = props;
 
 
   return (
   return (
     <table className="table table-bordered grw-duplicated-paths-table">
     <table className="table table-bordered grw-duplicated-paths-table">
@@ -29,17 +27,17 @@ const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: Duplic
       </thead>
       </thead>
       <tbody className="overflow-auto d-block">
       <tbody className="overflow-auto d-block">
         {existingPaths.map((existPath) => {
         {existingPaths.map((existPath) => {
-          const convertedPath = convertToNewAffiliationPath(toPath, fromPath, existPath);
+          const convertedPath = convertToNewAffiliationPath(
+            toPath,
+            fromPath,
+            existPath,
+          );
           return (
           return (
             <tr key={existPath} className="d-flex">
             <tr key={existPath} className="d-flex">
               <td className="text-break w-50">
               <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>
+              <td className="text-break text-danger w-50">{existPath}</td>
             </tr>
             </tr>
           );
           );
         })}
         })}
@@ -48,5 +46,4 @@ const DuplicatedPathsTable: React.FC<DuplicatedPathsTableProps> = (props: Duplic
   );
   );
 };
 };
 
 
-
 export default DuplicatedPathsTable;
 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';
 import { useTranslation } from 'next-i18next';
 
 
 type EmptyTrashButtonProps = {
 type EmptyTrashButtonProps = {
-  onEmptyTrashButtonClick: () => void,
-  disableEmptyButton: boolean
+  onEmptyTrashButtonClick: () => void;
+  disableEmptyButton: boolean;
 };
 };
 
 
-
 const EmptyTrashButton = (props: EmptyTrashButtonProps): JSX.Element => {
 const EmptyTrashButton = (props: EmptyTrashButtonProps): JSX.Element => {
   const { onEmptyTrashButtonClick, disableEmptyButton } = props;
   const { onEmptyTrashButtonClick, disableEmptyButton } = props;
   const { t } = useTranslation();
   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 type { FC } from 'react';
-import React, { useState, useCallback, useMemo } from 'react';
-
+import { useCallback, useMemo, useState } from 'react';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 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 { 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';
 import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
 
 
@@ -32,7 +33,7 @@ const EmptyTrashModalSubstance = ({
 
 
   const [errs, setErrs] = useState<Error[] | null>(null);
   const [errs, setErrs] = useState<Error[] | null>(null);
 
 
-  const emptyTrash = useCallback(async() => {
+  const emptyTrash = useCallback(async () => {
     if (pages == null) {
     if (pages == null) {
       return;
       return;
     }
     }
@@ -43,22 +44,21 @@ const EmptyTrashModalSubstance = ({
         onEmptiedTrash();
         onEmptiedTrash();
       }
       }
       closeModal();
       closeModal();
-    }
-    catch (err) {
+    } catch (err) {
       setErrs([err]);
       setErrs([err]);
     }
     }
   }, [pages, onEmptiedTrash, closeModal]);
   }, [pages, onEmptiedTrash, closeModal]);
 
 
-  const emptyTrashButtonHandler = useCallback(async() => {
+  const emptyTrashButtonHandler = useCallback(async () => {
     await emptyTrash();
     await emptyTrash();
   }, [emptyTrash]);
   }, [emptyTrash]);
 
 
   // Memoize page paths rendering
   // Memoize page paths rendering
   const renderPagePaths = useMemo(() => {
   const renderPagePaths = useMemo(() => {
     if (pages != null) {
     if (pages != null) {
-      return pages.map(page => (
+      return pages.map((page) => (
         <p key={page.data._id} className="mb-1">
         <p key={page.data._id} className="mb-1">
-          <code>{ page.data.path }</code>
+          <code>{page.data.path}</code>
         </p>
         </p>
       ));
       ));
     }
     }
@@ -73,11 +73,13 @@ const EmptyTrashModalSubstance = ({
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div className="grw-scrollable-modal-body pb-1">
         <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 */}
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {renderPagePaths}
           {renderPagePaths}
         </div>
         </div>
-        {!canDeleteAllPages && t('modal_empty.not_deletable_notice')}<br />
+        {!canDeleteAllPages && t('modal_empty.not_deletable_notice')}
+        <br />
         {t('modal_empty.notice')}
         {t('modal_empty.notice')}
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
@@ -87,7 +89,9 @@ const EmptyTrashModalSubstance = ({
           className="btn btn-danger"
           className="btn btn-danger"
           onClick={emptyTrashButtonHandler}
           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')}
           {t('modal_empty.empty_the_trash_button')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
@@ -103,7 +107,12 @@ export const EmptyTrashModal: FC = () => {
   const { close: closeModal } = useEmptyTrashModalActions();
   const { close: closeModal } = useEmptyTrashModalActions();
 
 
   return (
   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 && (
       {isOpened && (
         <EmptyTrashModalSubstance
         <EmptyTrashModalSubstance
           pages={pages}
           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>(
   const EmptyTrashModal = useLazyLoader<EmptyTrashModalProps>(
     'empty-trash-modal',
     'empty-trash-modal',
-    () => import('./EmptyTrashModal').then(mod => ({ default: mod.EmptyTrashModal })),
+    () =>
+      import('./EmptyTrashModal').then((mod) => ({
+        default: mod.EmptyTrashModal,
+      })),
     status?.isOpened ?? false,
     status?.isOpened ?? false,
   );
   );
 
 

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

@@ -1,12 +1,10 @@
 import React from 'react';
 import React from 'react';
-
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 /**
 /**
  * @see https://reactjs.org/docs/error-boundaries.html
  * @see https://reactjs.org/docs/error-boundaries.html
  */
  */
 class ErrorBoundary extends React.Component {
 class ErrorBoundary extends React.Component {
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
     this.state = { error: null, errorInfo: null };
     this.state = { error: null, errorInfo: null };
@@ -26,7 +24,6 @@ class ErrorBoundary extends React.Component {
   render() {
   render() {
     const { error, errorInfo } = this.state;
     const { error, errorInfo } = this.state;
     if (errorInfo != null) {
     if (errorInfo != null) {
-
       // split componetStack
       // split componetStack
       // see https://regex101.com/r/Uc448G/1
       // see https://regex101.com/r/Uc448G/1
       const firstStack = errorInfo.componentStack.split(/\s*in\s/)[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-header">Error occured in {firstStack}</div>
           <div className="card-body">
           <div className="card-body">
             <h5>{error && error.toString()}</h5>
             <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}
               {errorInfo.componentStack}
             </details>
             </details>
           </div>
           </div>
@@ -47,7 +47,6 @@ class ErrorBoundary extends React.Component {
     // Normally, just render children
     // Normally, just render children
     return this.props.children;
     return this.props.children;
   }
   }
-
 }
 }
 
 
 ErrorBoundary.propTypes = {
 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';
 import styles from './ExpandOrContractButton.module.scss';
 
 
 type Props = {
 type Props = {
-  isWindowExpanded: boolean,
-  contractWindow?: () => void,
-  expandWindow?: () => void,
+  isWindowExpanded: boolean;
+  contractWindow?: () => void;
+  expandWindow?: () => void;
 };
 };
 
 
 const moduleClass = styles['btn-expand-or-contract'] ?? '';
 const moduleClass = styles['btn-expand-or-contract'] ?? '';
 
 
-
 const ExpandOrContractButton: FC<Props> = (props: Props) => {
 const ExpandOrContractButton: FC<Props> = (props: Props) => {
   const { isWindowExpanded, contractWindow, expandWindow } = props;
   const { isWindowExpanded, contractWindow, expandWindow } = props;
 
 
@@ -31,7 +30,9 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
     <button
     <button
       type="button"
       type="button"
       className={`btn ${moduleClass}`}
       className={`btn ${moduleClass}`}
-      onClick={isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler}
+      onClick={
+        isWindowExpanded ? clickContractButtonHandler : clickExpandButtonHandler
+      }
     >
     >
       <span className="material-symbols-outlined fw-bold">
       <span className="material-symbols-outlined fw-bold">
         {isWindowExpanded ? 'close_fullscreen' : 'open_in_full'}
         {isWindowExpanded ? 'close_fullscreen' : 'open_in_full'}
@@ -40,5 +41,4 @@ const ExpandOrContractButton: FC<Props> = (props: Props) => {
   );
   );
 };
 };
 
 
-
 export default ExpandOrContractButton;
 export default ExpandOrContractButton;

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

@@ -1,10 +1,9 @@
 import React, { type JSX } from 'react';
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 type Props = {
 type Props = {
-  isLinkSharingDisabled?: boolean,
-}
+  isLinkSharingDisabled?: boolean;
+};
 
 
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
 const ForbiddenPage = React.memo((props: Props): JSX.Element => {
   const { t } = useTranslation();
   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="row not-found-message-row mb-4">
         <div className="col-lg-12">
         <div className="col-lg-12">
           <h2 className="text-muted">
           <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
             Forbidden
           </h2>
           </h2>
         </div>
         </div>
@@ -23,8 +24,12 @@ const ForbiddenPage = React.memo((props: Props): JSX.Element => {
       <div className="row row-alerts d-edit-none">
       <div className="row row-alerts d-edit-none">
         <div className="col-sm-12">
         <div className="col-sm-12">
           <p className="alert alert-primary py-3 px-4">
           <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>
           </p>
         </div>
         </div>
       </div>
       </div>

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

@@ -1,13 +1,12 @@
 import React from 'react';
 import React from 'react';
-
-import { format, formatDistanceStrict, differenceInSeconds } from 'date-fns';
+import { differenceInSeconds, format, formatDistanceStrict } from 'date-fns';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 import { UncontrolledTooltip } from 'reactstrap';
 import { UncontrolledTooltip } from 'reactstrap';
 
 
 const FormattedDistanceDate = (props) => {
 const FormattedDistanceDate = (props) => {
-
   // cast to date if string
   // 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();
   const baseDate = props.baseDate || new Date();
 
 
@@ -23,14 +22,19 @@ const FormattedDistanceDate = (props) => {
   return (
   return (
     <>
     <>
       <span id={elemId}>{formatDistanceStrict(date, baseDate)}</span>
       <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 = {
 FormattedDistanceDate.propTypes = {
   id: PropTypes.string.isRequired,
   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),
   baseDate: PropTypes.instanceOf(Date),
   // the number(sec) from 'baseDate' to avoid format
   // the number(sec) from 'baseDate' to avoid format
   differenceForAvoidingFormat: PropTypes.number,
   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 { useTranslation } from 'react-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import {
 import {
-  useGrantedGroupsInheritanceSelectModalActions, useGrantedGroupsInheritanceSelectModalStatus,
+  useGrantedGroupsInheritanceSelectModalActions,
+  useGrantedGroupsInheritanceSelectModalStatus,
 } from '~/states/ui/modal/granted-groups-inheritance-select';
 } from '~/states/ui/modal/granted-groups-inheritance-select';
 
 
 /**
 /**
  * GrantedGroupsInheritanceSelectModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  * GrantedGroupsInheritanceSelectModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
  */
 type GrantedGroupsInheritanceSelectModalSubstanceProps = {
 type GrantedGroupsInheritanceSelectModalSubstanceProps = {
-  onCreateBtnClick: ((onlyInheritUserRelatedGrantedGroups: boolean) => Promise<void>) | undefined;
+  onCreateBtnClick:
+    | ((onlyInheritUserRelatedGrantedGroups: boolean) => Promise<void>)
+    | undefined;
   closeModal: () => void;
   closeModal: () => void;
 };
 };
 
 
-const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheritanceSelectModalSubstanceProps): React.JSX.Element => {
+const GrantedGroupsInheritanceSelectModalSubstance = (
+  props: GrantedGroupsInheritanceSelectModalSubstanceProps,
+): React.JSX.Element => {
   const { onCreateBtnClick: _onCreateBtnClick, closeModal } = props;
   const { onCreateBtnClick: _onCreateBtnClick, closeModal } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
-  const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
+  const [
+    onlyInheritUserRelatedGrantedGroups,
+    setOnlyInheritUserRelatedGrantedGroups,
+  ] = useState(false);
 
 
-  const onCreateBtnClick = useCallback(async() => {
+  const onCreateBtnClick = useCallback(async () => {
     await _onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
     await _onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
     setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
     setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
   }, [_onCreateBtnClick, onlyInheritUserRelatedGrantedGroups]);
   }, [_onCreateBtnClick, onlyInheritUserRelatedGrantedGroups]);
 
 
-  const setInheritAll = useCallback(() => setOnlyInheritUserRelatedGrantedGroups(false), []);
-  const setInheritRelatedOnly = useCallback(() => setOnlyInheritUserRelatedGrantedGroups(true), []);
+  const setInheritAll = useCallback(
+    () => setOnlyInheritUserRelatedGrantedGroups(false),
+    [],
+  );
+  const setInheritRelatedOnly = useCallback(
+    () => setOnlyInheritUserRelatedGrantedGroups(true),
+    [],
+  );
 
 
   return (
   return (
     <>
     <>
@@ -48,7 +59,9 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
               onChange={setInheritAll}
               onChange={setInheritAll}
             />
             />
             <label className="form-check-label" htmlFor="inheritAllGroupsRadio">
             <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>
             </label>
           </div>
           </div>
           <div className="form-check radio-primary">
           <div className="form-check radio-primary">
@@ -60,15 +73,30 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
               checked={onlyInheritUserRelatedGrantedGroups}
               checked={onlyInheritUserRelatedGrantedGroups}
               onChange={setInheritRelatedOnly}
               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>
             </label>
           </div>
           </div>
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter className="grw-modal-footer">
       <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')}
           {t('modal_granted_groups_inheritance_select.create_page')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>
@@ -80,14 +108,12 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
  * GrantedGroupsInheritanceSelectModal - Container component (lightweight, always rendered)
  * GrantedGroupsInheritanceSelectModal - Container component (lightweight, always rendered)
  */
  */
 export const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
 export const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
-  const { isOpened, onCreateBtnClick } = useGrantedGroupsInheritanceSelectModalStatus();
+  const { isOpened, onCreateBtnClick } =
+    useGrantedGroupsInheritanceSelectModalStatus();
   const { close: closeModal } = useGrantedGroupsInheritanceSelectModalActions();
   const { close: closeModal } = useGrantedGroupsInheritanceSelectModalActions();
 
 
   return (
   return (
-    <Modal
-      isOpen={isOpened}
-      toggle={() => closeModal()}
-    >
+    <Modal isOpen={isOpened} toggle={() => closeModal()}>
       {isOpened && (
       {isOpened && (
         <GrantedGroupsInheritanceSelectModalSubstance
         <GrantedGroupsInheritanceSelectModalSubstance
           onCreateBtnClick={onCreateBtnClick}
           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>;
 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 PropTypes from 'prop-types';
 import { GlobalHotKeys } from 'react-hotkeys';
 import { GlobalHotKeys } from 'react-hotkeys';
 
 
 import HotkeyStroke from '~/client/models/HotkeyStroke';
 import HotkeyStroke from '~/client/models/HotkeyStroke';
 
 
 const HotkeysDetector = (props) => {
 const HotkeysDetector = (props) => {
-
   const { keySet, strokeSet, onDetected } = props;
   const { keySet, strokeSet, onDetected } = props;
 
 
   // memorize HotkeyStroke instances
   // 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
    * return key expression string includes modifier
@@ -43,19 +38,22 @@ const HotkeysDetector = (props) => {
   /**
   /**
    * evaluate the key user pressed and trigger onDetected
    * 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
   // memorize keyMap for GlobalHotKeys
   const keyMap = useMemo(() => {
   const keyMap = useMemo(() => {
@@ -67,10 +65,7 @@ const HotkeysDetector = (props) => {
     return { check: checkHandler };
     return { check: checkHandler };
   }, [checkHandler]);
   }, [checkHandler]);
 
 
-  return (
-    <GlobalHotKeys keyMap={keyMap} handlers={handlers} />
-  );
-
+  return <GlobalHotKeys keyMap={keyMap} handlers={handlers} />;
 };
 };
 
 
 HotkeysDetector.propTypes = {
 HotkeysDetector.propTypes = {

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

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

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

@@ -1,12 +1,10 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
-
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 import { useCurrentPagePath } from '~/states/page';
 import { useCurrentPagePath } from '~/states/page';
 import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
 import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
 
 
 const CreatePage = React.memo((props) => {
 const CreatePage = React.memo((props) => {
-
   const { open: openCreateModal } = usePageCreateModalActions();
   const { open: openCreateModal } = usePageCreateModalActions();
   const currentPath = useCurrentPagePath();
   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 { useCallback, useEffect, useRef } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useStartEditing } from '~/client/services/use-start-editing';
 import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPathname } from '~/states/global';
 import { useCurrentPathname } from '~/states/global';
-import { useIsEditable, useCurrentPagePath } from '~/states/page';
+import { useCurrentPagePath, useIsEditable } from '~/states/page';
 
 
 type Props = {
 type Props = {
-  onDeleteRender: () => void,
-}
+  onDeleteRender: () => void;
+};
 
 
 /**
 /**
  * Custom hook for edit page logic
  * Custom hook for edit page logic
  */
  */
 const useEditPage = (
 const useEditPage = (
-    onCompleted: () => void,
-    onError?: (path: string) => void,
+  onCompleted: () => void,
+  onError?: (path: string) => void,
 ): void => {
 ): void => {
   const isEditable = useIsEditable();
   const isEditable = useIsEditable();
   const startEditing = useStartEditing();
   const startEditing = useStartEditing();
@@ -26,7 +25,7 @@ const useEditPage = (
   const isExecutedRef = useRef(false);
   const isExecutedRef = useRef(false);
 
 
   useEffect(() => {
   useEffect(() => {
-    (async() => {
+    (async () => {
       // Prevent multiple executions
       // Prevent multiple executions
       if (isExecutedRef.current) return;
       if (isExecutedRef.current) return;
       isExecutedRef.current = true;
       isExecutedRef.current = true;
@@ -42,8 +41,7 @@ const useEditPage = (
 
 
       try {
       try {
         await startEditing(path);
         await startEditing(path);
-      }
-      catch (err) {
+      } catch (err) {
         onError?.(path);
         onError?.(path);
       }
       }
 
 
@@ -58,9 +56,12 @@ const useEditPage = (
 const EditPage = (props: Props): null => {
 const EditPage = (props: Props): null => {
   const { t } = useTranslation('commons');
   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);
   useEditPage(props.onDeleteRender, handleError);
 
 

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

@@ -1,9 +1,11 @@
 import { useEffect } from 'react';
 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';
 import { useIsEditable } from '~/states/page';
 
 
-
 const FocusToGlobalSearch = (props) => {
 const FocusToGlobalSearch = (props) => {
   const isEditable = useIsEditable();
   const isEditable = useIsEditable();
   const searchModalData = useSearchModalStatus();
   const searchModalData = useSearchModalStatus();
@@ -20,7 +22,6 @@ const FocusToGlobalSearch = (props) => {
       // remove this
       // remove this
       props.onDeleteRender();
       props.onDeleteRender();
     }
     }
-
   }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
   }, [isEditable, openSearchModal, props, searchModalData.isOpened]);
 
 
   return null;
   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 = {
 type Props = {
-  onDeleteRender: () => void,
-}
+  onDeleteRender: () => void;
+};
 const ShowShortcutsModal = (props: Props): JSX.Element => {
 const ShowShortcutsModal = (props: Props): JSX.Element => {
-
   const status = useShortcutsModalStatus();
   const status = useShortcutsModalStatus();
   const { open } = useShortcutsModalActions();
   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';
 import StaffCredit from '../../StaffCredit/StaffCredit';
 
 
 const ShowStaffCredit = (props) => {
 const ShowStaffCredit = (props) => {
-
   return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
   return <StaffCredit onClosed={() => props.onDeleteRender(this)} />;
-
 };
 };
 
 
 ShowStaffCredit.propTypes = {
 ShowStaffCredit.propTypes = {
@@ -13,7 +11,20 @@ ShowStaffCredit.propTypes = {
 };
 };
 
 
 ShowStaffCredit.getHotkeyStrokes = () => {
 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;
 export default ShowStaffCredit;

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

@@ -1,9 +1,7 @@
 import React, { useEffect } from 'react';
 import React, { useEffect } from 'react';
-
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
 const SwitchToMirrorMode = (props) => {
 const SwitchToMirrorMode = (props) => {
-
   // setup effect
   // setup effect
   useEffect(() => {
   useEffect(() => {
     document.body.classList.add('mirror');
     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';
 import React, { type JSX } from 'react';
 
 
 type Props = {
 type Props = {
-  isOpen: boolean
-}
+  isOpen: boolean;
+};
 export const FolderIcon = (props: Props): JSX.Element => {
 export const FolderIcon = (props: Props): JSX.Element => {
   const { isOpen } = props;
   const { isOpen } = props;
 
 
@@ -10,12 +10,9 @@ export const FolderIcon = (props: Props): JSX.Element => {
     <>
     <>
       {!isOpen ? (
       {!isOpen ? (
         <span className="material-symbols-outlined">folder</span>
         <span className="material-symbols-outlined">folder</span>
-
       ) : (
       ) : (
         <span className="material-symbols-outlined">folder_open</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"
     height="20"
     viewBox="0 0 20 20"
     viewBox="0 0 20 20"
   >
   >
-
+    <title>Recently created</title>
     <g transform="translate(-921.906 192.966)">
     <g transform="translate(-921.906 192.966)">
-
       <rect
       <rect
         width="20"
         width="20"
         height="20"
         height="20"

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

@@ -1,23 +1,25 @@
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
 import React from 'react';
 import React from 'react';
-
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 
 
 import { useCurrentPathname } from '~/states/global';
 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 { PageListItemL } from './PageList/PageListItemL';
 
 
-
 import styles from './IdenticalPathPage.module.scss';
 import styles from './IdenticalPathPage.module.scss';
 
 
-
 type IdenticalPathAlertProps = {
 type IdenticalPathAlertProps = {
-  path? : string | null,
-}
+  path?: string | null;
+};
 
 
-const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAlertProps) => {
+const IdenticalPathAlert: FC<IdenticalPathAlertProps> = (
+  props: IdenticalPathAlertProps,
+) => {
   const { path } = props;
   const { path } = props;
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
@@ -30,16 +32,26 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
     _pageName = devidedPath.latter;
     _pageName = devidedPath.latter;
   }
   }
 
 
-
   return (
   return (
     <div className="alert alert-warning py-3">
     <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>
       <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
         <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>
       <p className="mb-1">{t('duplicated_page_alert.select_page_to_see')}</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 => {
 export const IdenticalPathPage = (): JSX.Element => {
-
   const currentPath = useCurrentPathname();
   const currentPath = useCurrentPathname();
 
 
   const { data: pages } = useSWRxPagesByPath(currentPath);
   const { data: pages } = useSWRxPagesByPath(currentPath);
@@ -83,7 +93,6 @@ export const IdenticalPathPage = (): JSX.Element => {
           })}
           })}
         </ul>
         </ul>
       </div>
       </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 { useTranslation } from 'next-i18next';
 import { useRipple } from 'react-use-ripple';
 import { useRipple } from 'react-use-ripple';
 import {
 import {
-  Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
 } from 'reactstrap';
 } from 'reactstrap';
 
 
 import { useGlobalSocket } from '~/states/socket-io';
 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';
 import InAppNotificationList from './InAppNotificationList';
 
 
@@ -20,11 +23,14 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const limit = 6;
   const limit = 6;
 
 
   const socket = useGlobalSocket();
   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
   // ripple
   const buttonRef = useRef(null);
   const buttonRef = useRef(null);
@@ -43,9 +49,12 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     }
     }
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
   }, [mutateInAppNotificationUnreadStatusCount, socket]);
 
 
-
-  const toggleDropdownHandler = async() => {
-    if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
+  const toggleDropdownHandler = async () => {
+    if (
+      !isOpen &&
+      inAppNotificationUnreadStatusCount != null &&
+      inAppNotificationUnreadStatusCount > 0
+    ) {
       mutateInAppNotificationUnreadStatusCount();
       mutateInAppNotificationUnreadStatusCount();
     }
     }
 
 
@@ -56,34 +65,45 @@ export const InAppNotificationDropdown = (): JSX.Element => {
     setIsOpen(newIsOpenState);
     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 (
   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}>
       <DropdownToggle className="px-3" color="primary" innerRef={buttonRef}>
         <span className="material-symbols-outlined">notifications</span> {badge}
         <span className="material-symbols-outlined">notifications</span> {badge}
       </DropdownToggle>
       </DropdownToggle>
 
 
-      { isOpen && (
+      {isOpen && (
         <DropdownMenu end>
         <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 divider />
           <DropdownItem tag="a" href="/me/all-in-app-notifications">
           <DropdownItem tag="a" href="/me/all-in-app-notifications">
-            { t('in_app_notification.see_all') }
+            {t('in_app_notification.see_all')}
           </DropdownItem>
           </DropdownItem>
         </DropdownMenu>
         </DropdownMenu>
-      ) }
+      )}
     </Dropdown>
     </Dropdown>
   );
   );
 };
 };

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

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

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

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

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

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import type { FC } from 'react';
 import React, { useState } from 'react';
 import React, { useState } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
@@ -8,117 +7,143 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { showPageLimitationXLAtom } from '~/states/server-configurations';
 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 CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import PaginationWrapper from '../PaginationWrapper';
 import PaginationWrapper from '../PaginationWrapper';
-
 import InAppNotificationList from './InAppNotificationList';
 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 { t } = useTranslation('commons');
 
 
   const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
   const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
-
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
   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>
           </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>
         </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 = {
   const navTabMapping = {
     user_infomation: {
     user_infomation: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(),
+      Icon: EmptyIcon,
+      Content: InAppNotificationAllTabContent,
       i18n: t('in_app_notification.all'),
       i18n: t('in_app_notification.all'),
     },
     },
     external_accounts: {
     external_accounts: {
-      Icon: () => <></>,
-      Content: () => InAppNotificationCategoryByStatus(InAppNotificationStatuses.STATUS_UNOPENED),
+      Icon: EmptyIcon,
+      Content: InAppNotificationUnreadTabContent,
       i18n: t('in_app_notification.unopend'),
       i18n: t('in_app_notification.unopend'),
     },
     },
   };
   };
 
 
   return (
   return (
     <div data-testid="grw-in-app-notification-page">
     <div data-testid="grw-in-app-notification-page">
-      <CustomNavAndContents navTabMapping={navTabMapping} tabContentClasses={['mt-4']} />
+      <CustomNavAndContents
+        navTabMapping={navTabMapping}
+        tabContentClasses={['mt-4']}
+      />
     </div>
     </div>
   );
   );
 };
 };

Неке датотеке нису приказане због велике количине промена