Kaynağa Gözat

Merge branch 'master' into support/156162-176217-app-some-client-components-biome-6

Futa Arai 3 ay önce
ebeveyn
işleme
0fba5ba55b
100 değiştirilmiş dosya ile 3043 ekleme ve 1977 silme
  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. 26 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 1
      apps/app/docker/README.md
  12. 3 0
      apps/app/docker/codebuild/.terraform.lock.hcl
  13. 1 1
      apps/app/docker/codebuild/main.tf
  14. 8 0
      apps/app/docker/codebuild/oidc.tf
  15. 2 2
      apps/app/package.json
  16. 11 0
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  17. 4 1
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  18. 6 2
      apps/app/playwright/23-editor/saving.spec.ts
  19. 0 2
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  20. 9 3
      apps/app/playwright/60-home/home.spec.ts
  21. 7 5
      apps/app/playwright/utils/CollapseSidebar.ts
  22. 34 0
      apps/app/public/images/customize-settings/collapsed-dark.svg
  23. 4 1
      apps/app/public/images/customize-settings/collapsed-light.svg
  24. 0 31
      apps/app/public/images/customize-settings/drawer-dark.svg
  25. 3 4
      apps/app/public/static/locales/en_US/admin.json
  26. 3 4
      apps/app/public/static/locales/fr_FR/admin.json
  27. 3 4
      apps/app/public/static/locales/ja_JP/admin.json
  28. 3 4
      apps/app/public/static/locales/ko_KR/admin.json
  29. 3 4
      apps/app/public/static/locales/zh_CN/admin.json
  30. 28 0
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  31. 5 40
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  32. 45 36
      apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx
  33. 60 27
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.tsx
  34. 6 7
      apps/app/src/client/components/Common/CountBadge.tsx
  35. 7 6
      apps/app/src/client/components/Common/CustomCopyToClipBoard.tsx
  36. 4 7
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx
  37. 17 11
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  38. 373 283
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  39. 138 87
      apps/app/src/client/components/Common/ImageCropModal.tsx
  40. 6 8
      apps/app/src/client/components/Common/LazyRenderer.tsx
  41. 4 3
      apps/app/src/client/components/Common/RendererErrorMessage.tsx
  42. 19 12
      apps/app/src/client/components/Common/SubmittableInput/AutosizeSubmittableInput.tsx
  43. 5 8
      apps/app/src/client/components/Common/SubmittableInput/SubmittableInput.tsx
  44. 1 1
      apps/app/src/client/components/Common/SubmittableInput/index.ts
  45. 8 7
      apps/app/src/client/components/Common/SubmittableInput/types.d.ts
  46. 73 55
      apps/app/src/client/components/Common/SubmittableInput/use-submittable.ts
  47. 1 5
      apps/app/src/client/components/Common/UserPictureList.jsx
  48. 26 2
      apps/app/src/client/components/ContentLinkButtons.tsx
  49. 46 32
      apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx
  50. 12 3
      apps/app/src/client/components/CreateTemplateModal/dynamic.tsx
  51. 105 69
      apps/app/src/client/components/CustomNavigation/CustomNav.tsx
  52. 27 15
      apps/app/src/client/components/CustomNavigation/CustomNavAndContents.tsx
  53. 11 13
      apps/app/src/client/components/CustomNavigation/CustomTabContent.tsx
  54. 24 14
      apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx
  55. 9 5
      apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx
  56. 25 16
      apps/app/src/client/components/EmptyTrashModal/EmptyTrashModal.tsx
  57. 4 1
      apps/app/src/client/components/EmptyTrashModal/dynamic.tsx
  58. 48 22
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx
  59. 18 9
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/dynamic.tsx
  60. 22 27
      apps/app/src/client/components/Hotkeys/HotkeysDetector.jsx
  61. 5 4
      apps/app/src/client/components/Hotkeys/HotkeysManager.jsx
  62. 0 2
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx
  63. 13 12
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  64. 4 3
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  65. 7 5
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx
  66. 14 3
      apps/app/src/client/components/Hotkeys/Subscribers/ShowStaffCredit.jsx
  67. 0 2
      apps/app/src/client/components/Hotkeys/Subscribers/SwitchToMirrorMode.jsx
  68. 3 6
      apps/app/src/client/components/Icons/FolderIcon.tsx
  69. 1 2
      apps/app/src/client/components/Icons/RecentlyCreatedIcon.tsx
  70. 1 0
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  71. 41 29
      apps/app/src/client/components/Maintenance/Maintenance.tsx
  72. 1 5
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss
  73. 267 175
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  74. 27 30
      apps/app/src/client/components/Navbar/GrowiNavbarBottom.tsx
  75. 32 34
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  76. 106 45
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  77. 30 21
      apps/app/src/client/components/PageAccessoriesModal/PageAttachment.tsx
  78. 7 6
      apps/app/src/client/components/PageAccessoriesModal/PageHistory.tsx
  79. 39 23
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLink.tsx
  80. 115 57
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx
  81. 41 33
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  82. 4 1
      apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx
  83. 32 29
      apps/app/src/client/components/PageAccessoriesModal/hooks.tsx
  84. 66 43
      apps/app/src/client/components/PageComment/Comment.tsx
  85. 8 6
      apps/app/src/client/components/PageComment/CommentControl.tsx
  86. 153 113
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  87. 2 7
      apps/app/src/client/components/PageComment/CommentPreview.tsx
  88. 74 56
      apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.tsx
  89. 7 3
      apps/app/src/client/components/PageComment/DeleteCommentModal/dynamic.tsx
  90. 35 23
      apps/app/src/client/components/PageComment/ReplyComments.tsx
  91. 12 21
      apps/app/src/client/components/PageComment/SwitchingButtonGroup.tsx
  92. 44 31
      apps/app/src/client/components/PageControls/BookmarkButtons.tsx
  93. 38 23
      apps/app/src/client/components/PageControls/LikeButtons.tsx
  94. 148 93
      apps/app/src/client/components/PageControls/PageControls.tsx
  95. 1 4
      apps/app/src/client/components/PageControls/SearchButton.tsx
  96. 28 12
      apps/app/src/client/components/PageControls/SeenUserInfo.tsx
  97. 11 9
      apps/app/src/client/components/PageControls/SubscribeButton.tsx
  98. 23 15
      apps/app/src/client/components/PageEditor/Cheatsheet.tsx
  99. 131 73
      apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx
  100. 4 1
      apps/app/src/client/components/PageEditor/ConflictDiffModal/dynamic.tsx

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

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

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

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

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

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

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

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

+ 1 - 0
.gitignore

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

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

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

+ 50 - 1
CHANGELOG.md

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

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

@@ -46,6 +46,32 @@ module.exports = {
     'src/client/components/Admin/Users/**',
     'src/client/components/Admin/UserGroup/**',
     'src/client/components/Admin/UserGroupDetail/**',
+    'src/client/components/AuthorInfo/**',
+    'src/client/components/Common/**',
+    'src/client/components/CreateTemplateModal/**',
+    'src/client/components/CustomNavigation/**',
+    'src/client/components/DeleteBookmarkFolderModal/**',
+    'src/client/components/EmptyTrashModal/**',
+    'src/client/components/GrantedGroupsInheritanceSelectModal/**',
+    'src/client/components/Icons/**',
+    'src/client/components/Maintenance/**',
+    'src/client/components/PageControls/**',
+    'src/client/components/PageComment/**',
+    'src/client/components/PageAccessoriesModal/**',
+    'src/client/components/PageHistory/**',
+    'src/client/components/Presentation/**',
+    'src/client/components/PutbackPageModal/**',
+    'src/client/components/RecentActivity/**',
+    'src/client/components/RecentCreated/**',
+    'src/client/components/RevisionComparer/**',
+    'src/client/components/ShortcutsModal/**',
+    'src/client/components/StaffCredit/**',
+    'src/client/components/TemplateModal/**',
+    'src/client/components/PageEditor/**',
+    'src/client/components/Hotkeys/**',
+    'src/client/components/Navbar/**',
+    'src/client/components/PageHeader/**',
+    'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',
     'src/stores/**',

+ 3 - 0
apps/app/.gitignore

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

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


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

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

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

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

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

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

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

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

+ 2 - 2
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.4.0-RC.0",
+  "version": "7.4.2-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -173,7 +173,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.32",
+    "next": "^14.2.35",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^1.0.7",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -356,6 +356,8 @@
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_mail_visibility": "Disclose e-mail for new users",
+    "default_read_only_for_new_user": "Editing Restrictions for New Users",
+    "set_read_only_for_new_user": "Set new users to read-only mode",
     "file_uploading": "File uploading",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "enable_page_bulk_export": "Enable bulk export",
@@ -446,10 +448,7 @@
     "customize_settings": "Customize",
     "default_sidebar_mode": {
       "title": "Default sidebar mode",
-      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
-      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
-      "dock_mode_default_open": "Open the page as it was opened from the beginning",
-      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+      "desc": "You can set the sidebar mode for new users and guests visiting the page."
     },
     "layout": "Layout",
     "layout_options": {

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

@@ -356,6 +356,8 @@
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
+    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
     "file_uploading": "Téléversement de fichiers",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
@@ -446,10 +448,7 @@
     "customize_settings": "Interface",
     "default_sidebar_mode": {
       "title": "Barre latérale",
-      "desc": "Mode d'affichage et comportement par défaut de la barre latérale.",
-      "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
-      "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
-      "dock_mode_default_close": "Afficher la page comme si elle était fermée"
+      "desc": "Mode d'affichage et comportement par défaut de la barre latérale."
     },
     "layout": "Largeur du contenu",
     "layout_options": {

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

@@ -365,6 +365,8 @@
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_mail_visibility": "新規ユーザーの初期メール公開設定",
+    "default_read_only_for_new_user": "新規ユーザーの編集制限",
+    "set_read_only_for_new_user": "新規ユーザーを閲覧専用にする",
     "file_uploading": "ファイルアップロード",
     "page_bulk_export_settings": "ページ一括エクスポート設定",
     "enable_page_bulk_export": "一括エクスポートを有効にする",
@@ -455,10 +457,7 @@
     "customize_settings": "カスタマイズ",
     "default_sidebar_mode": {
       "title": "デフォルトのサイドバーモード",
-      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
-      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
-      "dock_mode_default_open": "初めから開いた状態でページを開く",
-      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。"
     },
     "layout": "レイアウト",
     "layout_options": {

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

@@ -356,6 +356,8 @@
     "confidential_example": "예): 내부 전용",
     "default_language": "새 사용자를 위한 기본 언어",
     "default_mail_visibility": "새 사용자를 위한 이메일 공개",
+    "default_read_only_for_new_user": "신규 사용자의 편집 제한",
+    "set_read_only_for_new_user": "신규 사용자를 열람 전용으로 설정",
     "file_uploading": "파일 업로드",
     "page_bulk_export_settings": "페이지 대량 내보내기 설정",
     "enable_page_bulk_export": "대량 내보내기 활성화",
@@ -446,10 +448,7 @@
     "customize_settings": "사용자 지정",
     "default_sidebar_mode": {
       "title": "기본 사이드바 모드",
-      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다.",
-      "dock_mode_default_desc": "독 모드가 선택되었을 때 사이드바의 초기 상태를 설정할 수 있습니다.",
-      "dock_mode_default_open": "처음부터 열린 상태로 페이지 열기",
-      "dock_mode_default_close": "처음부터 닫힌 상태로 페이지 열기"
+      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다."
     },
     "layout": "레이아웃",
     "layout_options": {

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

@@ -365,6 +365,8 @@
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "default_read_only_for_new_user": "新用戶編輯限制",
+    "set_read_only_for_new_user": "將新用戶設為僅限瀏覽",
     "file_uploading": "文件上传",
     "page_bulk_export_settings": "页面批量导出设置",
     "enable_page_bulk_export": "启用批量导出",
@@ -455,10 +457,7 @@
     "customize_settings": "页面定制",
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
-      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
-      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
-      "dock_mode_default_open": "从头开始翻页",
-      "dock_mode_default_close": "从头开始打开关闭的页面"
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。"
     },
     "layout": "布局",
     "layout_options": {

+ 28 - 0
apps/app/src/client/components/Admin/App/AppSetting.jsx

@@ -30,12 +30,15 @@ const AppSetting = (props) => {
       isEmailPublishedForNewUser: String(
         adminAppContainer.state.isEmailPublishedForNewUser ?? true,
       ),
+      isReadOnlyForNewUser:
+        adminAppContainer.state.isReadOnlyForNewUser ?? false,
     });
   }, [
     adminAppContainer.state.title,
     adminAppContainer.state.confidential,
     adminAppContainer.state.globalLang,
     adminAppContainer.state.isEmailPublishedForNewUser,
+    adminAppContainer.state.isReadOnlyForNewUser,
     reset,
   ]);
 
@@ -55,6 +58,9 @@ const AppSetting = (props) => {
         await adminAppContainer.changeIsEmailPublishedForNewUserShow(
           isEmailPublished,
         );
+        await adminAppContainer.changeIsReadOnlyForNewUserShow(
+          data.isReadOnlyForNewUser,
+        );
 
         await adminAppContainer.updateAppSettingHandler();
         toastSuccess(
@@ -185,6 +191,28 @@ const AppSetting = (props) => {
         </div>
       </div>
 
+      <div className="row mb-5">
+        <label className="text-start text-md-end col-md-3 col-form-label">
+          {t('admin:app_setting.default_read_only_for_new_user')}
+        </label>
+        <div className="col-md-6 py-2">
+          <div className="form-check form-check-inline">
+            <input
+              type="checkbox"
+              id="checkbox-read-only-for-new-user"
+              className="form-check-input"
+              {...register('isReadOnlyForNewUser')}
+            />
+            <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>
+      </div>
+
       <AdminUpdateButtonRow
         type="submit"
         disabled={adminAppContainer.state.retrieveError != null}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 26 - 2
apps/app/src/client/components/ContentLinkButtons.tsx

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 41 - 29
apps/app/src/client/components/Maintenance/Maintenance.tsx

@@ -1,55 +1,67 @@
 import type { JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 
-
 export const Maintenance = (): JSX.Element => {
   const { t } = useTranslation();
 
   const currentUser = useCurrentUser();
 
-  const logoutHandler = async() => {
+  const logoutHandler = async () => {
     try {
       await apiv3Post('/logout');
       window.location.reload();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   };
 
   return (
     <div className="text-center">
-      <h1><span className="material-symbols-outlined large">error</span></h1>
-      <h1 className="text-center">{ t('maintenance_mode.maintenance_mode') }</h1>
-      <h3>{ t('maintenance_mode.growi_is_under_maintenance') }</h3>
+      <h1>
+        <span className="material-symbols-outlined large">error</span>
+      </h1>
+      <h1 className="text-center">{t('maintenance_mode.maintenance_mode')}</h1>
+      <h3>{t('maintenance_mode.growi_is_under_maintenance')}</h3>
       <hr />
       <div className="text-start">
-        {currentUser?.admin
-              && (
-                <p>
-                  <span className="material-symbols-outlined">arrow_circle_right</span>
-                  <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
-                </p>
-              )}
-        {currentUser != null
-          ? (
-            <p>
-              <span className="material-symbols-outlined">arrow_circle_right</span>
-              <a className="btn btn-link" onClick={logoutHandler} id="maintanounse-mode-logout">{ t('maintenance_mode.logout') }</a>
-            </p>
-          )
-          : (
-            <p>
-              <span className="material-symbols-outlined">arrow_circle_right</span>
-              <a className="btn btn-link" href="/login">{ t('maintenance_mode.login') }</a>
-            </p>
-          )
-        }
+        {currentUser?.admin && (
+          <p>
+            <span className="material-symbols-outlined">
+              arrow_circle_right
+            </span>
+            <a className="btn btn-link" href="/admin">
+              {t('maintenance_mode.admin_page')}
+            </a>
+          </p>
+        )}
+        {currentUser != null ? (
+          <p>
+            <span className="material-symbols-outlined">
+              arrow_circle_right
+            </span>
+            <button
+              type="button"
+              className="btn btn-link"
+              onClick={logoutHandler}
+              id="maintanounse-mode-logout"
+            >
+              {t('maintenance_mode.logout')}
+            </button>
+          </p>
+        ) : (
+          <p>
+            <span className="material-symbols-outlined">
+              arrow_circle_right
+            </span>
+            <a className="btn btn-link" href="/login">
+              {t('maintenance_mode.login')}
+            </a>
+          </p>
+        )}
       </div>
     </div>
   );

+ 1 - 5
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.module.scss

@@ -1,12 +1,8 @@
 @use '~/styles/mixins';
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
-.grw-contextual-sub-navigation {
+.grw-min-height-sub-navigation {
   min-height: 46px;
-
-  @include bs.media-breakpoint-up(lg) {
-    min-height: 46px;
-  }
 }
 
 @include mixins.at-editing() {

+ 267 - 175
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,32 +1,42 @@
-import React, {
-  useState, useCallback, useMemo, type JSX,
-} from 'react';
-
-
-import { isPopulated } from '@growi/core';
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import dynamic from 'next/dynamic';
+import Link from 'next/link';
+import { useRouter } from 'next/router';
 import type {
+  IPageInfoForEntity,
   IPagePopulatedToShowRevision,
-  IPageToRenameWithMeta, IPageWithMeta, IPageInfoForEntity,
+  IPageToRenameWithMeta,
+  IPageWithMeta,
 } from '@growi/core';
+import { isPopulated } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import Link from 'next/link';
-import { useRouter } from 'next/router';
 import Sticky from 'react-stickynode';
-import { DropdownItem, UncontrolledTooltip, Tooltip } from 'reactstrap';
+import { DropdownItem, Tooltip, UncontrolledTooltip } from 'reactstrap';
 
-import { exportAsMarkdown, updateContentWidth, syncLatestRevisionBody } from '~/client/services/page-operation';
+import {
+  exportAsMarkdown,
+  syncLatestRevisionBody,
+  updateContentWidth,
+} from '~/client/services/page-operation';
 import { usePrintMode } from '~/client/services/use-print-mode';
-import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { usePageBulkExportSelectModalActions } from '~/features/page-bulk-export/client/states/modal';
-import type { OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction } from '~/interfaces/ui';
+import type {
+  OnDeletedFunction,
+  OnDuplicatedFunction,
+  OnRenamedFunction,
+} from '~/interfaces/ui';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { useCurrentPathname, useCurrentUser } from '~/states/global';
 import { useCurrentPageId, useFetchCurrentPage } from '~/states/page';
 import { useShareLinkId } from '~/states/page/hooks';
@@ -38,18 +48,22 @@ import {
 } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode } from '~/states/ui/editor';
-import { PageAccessoriesModalContents, usePageAccessoriesModalActions } from '~/states/ui/modal/page-accessories';
+import {
+  PageAccessoriesModalContents,
+  usePageAccessoriesModalActions,
+} from '~/states/ui/modal/page-accessories';
 import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
-import { usePageDuplicateModalActions, type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import {
+  type IPageForPageDuplicateModal,
+  usePageDuplicateModalActions,
+} from '~/states/ui/modal/page-duplicate';
 import { usePresentationModalActions } from '~/states/ui/modal/page-presentation';
 import { usePageRenameModalActions } from '~/states/ui/modal/page-rename';
 import {
-  useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
+  useIsAbleToShowPageManagement,
 } from '~/states/ui/page-abilities';
-import {
-  useSWRxPageInfo,
-} from '~/stores/page';
+import { useSWRxPageInfo } from '~/stores/page';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 
 import { CreateTemplateModalLazyLoaded } from '../CreateTemplateModal';
@@ -59,29 +73,38 @@ import { Skeleton } from '../Skeleton';
 import styles from './GrowiContextualSubNavigation.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
+const moduleClass = styles['grw-contextual-sub-navigation'];
+const minHeightSubNavigation = styles['grw-min-height-sub-navigation'];
 
 const PageEditorModeManager = dynamic(
-  () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
-  { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },
+  () =>
+    import('./PageEditorModeManager').then((mod) => mod.PageEditorModeManager),
+  {
+    ssr: false,
+    loading: () => (
+      <Skeleton
+        additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`}
+      />
+    ),
+  },
 );
 const PageControls = dynamic(
-  () => import('../PageControls').then(mod => mod.PageControls),
+  () => import('../PageControls').then((mod) => mod.PageControls),
   { ssr: false, loading: () => <></> },
 );
 
-
 type PageOperationMenuItemsProps = {
-  pageId: string,
-  revisionId: string,
-  isLinkSharingDisabled?: boolean,
-}
+  pageId: string;
+  revisionId: string;
+  isLinkSharingDisabled?: boolean;
+};
 
-const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element => {
+const PageOperationMenuItems = (
+  props: PageOperationMenuItemsProps,
+): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    pageId, revisionId, isLinkSharingDisabled,
-  } = props;
+  const { pageId, revisionId, isLinkSharingDisabled } = props;
 
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -91,9 +114,12 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 
   const { open: openPresentationModal } = usePresentationModalActions();
   const { open: openAccessoriesModal } = usePageAccessoriesModalActions();
-  const { open: openPageBulkExportSelectModal } = usePageBulkExportSelectModalActions();
+  const { open: openPageBulkExportSelectModal } =
+    usePageBulkExportSelectModalActions();
 
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.MAIN,
+  );
 
   const [isBulkExportTooltipOpen, setIsBulkExportTooltipOpen] = useState(false);
 
@@ -117,8 +143,7 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         }
 
         toastSuccess(t('sync-latest-revision-body.success-toaster'));
-      }
-      catch {
+      } catch {
         toastError(t('sync-latest-revision-body.error-toaster'));
       }
     }
@@ -130,7 +155,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         onClick={() => syncLatestRevisionBodyHandler()}
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">sync</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          sync
+        </span>
         {t('sync-latest-revision-body.menuitem')}
       </DropdownItem>
 
@@ -140,7 +167,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         data-testid="open-presentation-modal-btn"
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">jamboard_kiosk</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          jamboard_kiosk
+        </span>
         {t('Presentation Mode')}
       </DropdownItem>
 
@@ -149,7 +178,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         onClick={() => exportAsMarkdown(pageId, revisionId, 'md')}
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          cloud_download
+        </span>
         {t('page_export.export_page_markdown')}
       </DropdownItem>
 
@@ -162,7 +193,9 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
               className="grw-page-control-dropdown-item"
               disabled={!isUploadEnabled ?? true}
             >
-              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">cloud_download</span>
+              <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+                cloud_download
+              </span>
               {t('page_export.bulk_export')}
             </DropdownItem>
           </span>
@@ -185,32 +218,47 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
         refs: PageAccessoriesModalControl
       */}
       <DropdownItem
-        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.PageHistory)}
+        onClick={() =>
+          openAccessoriesModal(PageAccessoriesModalContents.PageHistory)
+        }
         disabled={!!isGuestUser || !!isSharedUser}
         data-testid="open-page-accessories-modal-btn-with-history-tab"
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">history</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          history
+        </span>
         {t('History')}
       </DropdownItem>
 
       <DropdownItem
-        onClick={() => openAccessoriesModal(PageAccessoriesModalContents.Attachment)}
+        onClick={() =>
+          openAccessoriesModal(PageAccessoriesModalContents.Attachment)
+        }
         data-testid="open-page-accessories-modal-btn-with-attachment-data-tab"
         className="grw-page-control-dropdown-item"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">attachment</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          attachment
+        </span>
         {t('attachment_data')}
       </DropdownItem>
 
       {!isGuestUser && !isReadOnlyUser && !isSharedUser && (
-        <NotAvailable isDisabled={isLinkSharingDisabled ?? false} title="Disabled by admin">
+        <NotAvailable
+          isDisabled={isLinkSharingDisabled ?? false}
+          title="Disabled by admin"
+        >
           <DropdownItem
-            onClick={() => openAccessoriesModal(PageAccessoriesModalContents.ShareLink)}
+            onClick={() =>
+              openAccessoriesModal(PageAccessoriesModalContents.ShareLink)
+            }
             data-testid="open-page-accessories-modal-btn-with-share-link-management-data-tab"
             className="grw-page-control-dropdown-item"
           >
-            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">share</span>
+            <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+              share
+            </span>
             {t('share_links.share_link_management')}
           </DropdownItem>
         </NotAvailable>
@@ -220,10 +268,12 @@ const PageOperationMenuItems = (props: PageOperationMenuItemsProps): JSX.Element
 };
 
 type CreateTemplateMenuItemsProps = {
-  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void,
-}
+  onClickTemplateMenuItem: (isPageTemplateModalShown: boolean) => void;
+};
 
-const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Element => {
+const CreateTemplateMenuItems = (
+  props: CreateTemplateMenuItemsProps,
+): JSX.Element => {
   const { t } = useTranslation();
 
   const { onClickTemplateMenuItem } = props;
@@ -240,7 +290,9 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
         className="grw-page-control-dropdown-item"
         data-testid="open-page-template-modal-btn"
       >
-        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">contract_edit</span>
+        <span className="material-symbols-outlined me-1 grw-page-control-dropdown-icon">
+          contract_edit
+        </span>
         {t('template.option_label.create/edit')}
       </DropdownItem>
     </>
@@ -248,11 +300,12 @@ const CreateTemplateMenuItems = (props: CreateTemplateMenuItemsProps): JSX.Eleme
 };
 
 type GrowiContextualSubNavigationProps = {
-  currentPage?: IPagePopulatedToShowRevision | null,
+  currentPage?: IPagePopulatedToShowRevision | null;
 };
 
-const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps): JSX.Element => {
-
+const GrowiContextualSubNavigation = (
+  props: GrowiContextualSubNavigationProps,
+): JSX.Element => {
   const { currentPage } = props;
 
   const { t } = useTranslation();
@@ -267,14 +320,17 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const isSharedPage = pagePathUtils.isSharedPage(currentPathname ?? '');
 
   const revision = currentPage?.revision;
-  const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
+  const revisionId =
+    revision != null && isPopulated(revision) ? revision._id : undefined;
 
   const { editorMode } = useEditorMode();
   const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
-  const isLocalAccountRegistrationEnabled = useAtomValue(isLocalAccountRegistrationEnabledAtom);
+  const isLocalAccountRegistrationEnabled = useAtomValue(
+    isLocalAccountRegistrationEnabledAtom,
+  );
   const isLinkSharingDisabled = useAtomValue(disableLinkSharingAtom);
   const isSharedUser = useIsSharedUser();
 
@@ -293,67 +349,87 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const path = currentPage?.path ?? currentPathname;
 
-  const [isPageTemplateModalShown, setIsPageTempleteModalShown] = useState(false);
-
-  const duplicateItemClickedHandler = useCallback(async (page: IPageForPageDuplicateModal) => {
-    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
-      router.push(toPath);
-    };
-    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal, router]);
-
-  const renameItemClickedHandler = useCallback(async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
-    const renamedHandler: OnRenamedFunction = () => {
-      fetchCurrentPage({ force: true });
-      mutatePageInfo();
-      mutatePageTree();
-      mutateRecentlyUpdated();
-    };
-    openRenameModal(page, { onRenamed: renamedHandler });
-  }, [fetchCurrentPage, mutatePageInfo, openRenameModal]);
-
-  const deleteItemClickedHandler = useCallback((pageWithMeta: IPageWithMeta) => {
-    const deletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
+  const [isPageTemplateModalShown, setIsPageTempleteModalShown] =
+    useState(false);
+
+  const duplicateItemClickedHandler = useCallback(
+    async (page: IPageForPageDuplicateModal) => {
+      const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
+        router.push(toPath);
+      };
+      openDuplicateModal(page, { onDuplicated: duplicatedHandler });
+    },
+    [openDuplicateModal, router],
+  );
 
-      const path = pathOrPathsToDelete;
+  const renameItemClickedHandler = useCallback(
+    async (page: IPageToRenameWithMeta<IPageInfoForEntity>) => {
+      const renamedHandler: OnRenamedFunction = () => {
+        fetchCurrentPage({ force: true });
+        mutatePageInfo();
+        mutatePageTree();
+        mutateRecentlyUpdated();
+      };
+      openRenameModal(page, { onRenamed: renamedHandler });
+    },
+    [fetchCurrentPage, mutatePageInfo, openRenameModal],
+  );
 
-      if (isCompletely) {
-        // redirect to NotFound Page
-        router.push(path);
-      }
-      else if (currentPathname != null) {
-        router.push(currentPathname);
-      }
+  const deleteItemClickedHandler = useCallback(
+    (pageWithMeta: IPageWithMeta) => {
+      const deletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') {
+          return;
+        }
 
-      fetchCurrentPage({ force: true });
-      mutatePageInfo();
-      mutatePageTree();
-      mutateRecentlyUpdated();
-    };
-    openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, fetchCurrentPage, openDeleteModal, router, mutatePageInfo]);
-
-  const switchContentWidthHandler = useCallback(async (pageId: string, value: boolean) => {
-    if (!isSharedPage) {
-      await updateContentWidth(pageId, value);
-      fetchCurrentPage({ force: true });
-    }
-  }, [isSharedPage, fetchCurrentPage]);
+        const path = pathOrPathsToDelete;
+
+        if (isCompletely) {
+          // redirect to NotFound Page
+          router.push(path);
+        } else if (currentPathname != null) {
+          router.push(currentPathname);
+        }
+
+        fetchCurrentPage({ force: true });
+        mutatePageInfo();
+        mutatePageTree();
+        mutateRecentlyUpdated();
+      };
+      openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
+    },
+    [
+      currentPathname,
+      fetchCurrentPage,
+      openDeleteModal,
+      router,
+      mutatePageInfo,
+    ],
+  );
+
+  const switchContentWidthHandler = useCallback(
+    async (pageId: string, value: boolean) => {
+      if (!isSharedPage) {
+        await updateContentWidth(pageId, value);
+        fetchCurrentPage({ force: true });
+      }
+    },
+    [isSharedPage, fetchCurrentPage],
+  );
 
   const additionalMenuItemsRenderer = useCallback(() => {
     if (revisionId == null || pageId == null) {
       return (
         <>
-          {!isReadOnlyUser
-            && (
-              <CreateTemplateMenuItems
-                onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
-              />
-            )
-          }
+          {!isReadOnlyUser && (
+            <CreateTemplateMenuItems
+              onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
+            />
+          )}
         </>
       );
     }
@@ -371,8 +447,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
               onClickTemplateMenuItem={() => setIsPageTempleteModalShown(true)}
             />
           </>
-        )
-        }
+        )}
       </>
     );
   }, [isLinkSharingDisabled, pageId, revisionId, isReadOnlyUser]);
@@ -384,75 +459,94 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   return (
     <>
+      {/* for App Title for mobile */}
       <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
 
+      {/* for Sub Navigation */}
+      <GroundGlassBar
+        className={`position-fixed z-1 d-edit-none d-print-none w-100 end-0 ${minHeightSubNavigation}`}
+      />
+
       <Sticky
-        className="z-1"
+        className="z-3"
         enabled={!isPrinting}
-        onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}
+        onStateChange={(status) =>
+          setStickyActive(status.status === Sticky.STATUS_FIXED)
+        }
         innerActiveClass="w-100 end-0"
       >
-        <GroundGlassBar>
-
-          <nav
-            className={`${styles['grw-contextual-sub-navigation']}
-              d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
-            `}
-            data-testid="grw-contextual-sub-nav"
-            id="grw-contextual-sub-nav"
-          >
-
-            <PageControls
-              pageId={pageId}
-              revisionId={revisionId}
-              shareLinkId={shareLinkId}
-              path={path ?? currentPathname} // If the page is empty, "path" is undefined
-              expandContentWidth={shouldExpandContent}
-              disableSeenUserInfoPopover={isSharedUser}
-              hideSubControls={hideSubControls}
-              showPageControlDropdown={isAbleToShowPageManagement}
-              additionalMenuItemRenderer={additionalMenuItemsRenderer}
-              onClickDuplicateMenuItem={duplicateItemClickedHandler}
-              onClickRenameMenuItem={renameItemClickedHandler}
-              onClickDeleteMenuItem={deleteItemClickedHandler}
-              onClickSwitchContentWidth={switchContentWidthHandler}
+        <nav
+          className={`${moduleClass} ${minHeightSubNavigation}
+            d-flex align-items-center justify-content-end pe-2 pe-sm-3 pe-md-4 py-1 gap-2 gap-md-4 d-print-none
+          `}
+          data-testid="grw-contextual-sub-nav"
+          id="grw-contextual-sub-nav"
+        >
+          <PageControls
+            pageId={pageId}
+            revisionId={revisionId}
+            shareLinkId={shareLinkId}
+            path={path ?? currentPathname} // If the page is empty, "path" is undefined
+            expandContentWidth={shouldExpandContent}
+            disableSeenUserInfoPopover={isSharedUser}
+            hideSubControls={hideSubControls}
+            showPageControlDropdown={isAbleToShowPageManagement}
+            additionalMenuItemRenderer={additionalMenuItemsRenderer}
+            onClickDuplicateMenuItem={duplicateItemClickedHandler}
+            onClickRenameMenuItem={renameItemClickedHandler}
+            onClickDeleteMenuItem={deleteItemClickedHandler}
+            onClickSwitchContentWidth={switchContentWidthHandler}
+          />
+
+          {isAbleToChangeEditorMode && (
+            <PageEditorModeManager
+              editorMode={editorMode}
+              isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
+              path={path}
             />
-
-            {isAbleToChangeEditorMode && (
-              <PageEditorModeManager
-                editorMode={editorMode}
-                isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
-                path={path}
-              />
-            )}
-
-            {isGuestUser && (
-              <div className="mt-2">
-                <span>
-                  <span className="d-inline-block" id="sign-up-link">
-                    <Link
-                      href={!isLocalAccountRegistrationEnabled ? '#' : '/login#register'}
-                      className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
-                      style={{ pointerEvents: !isLocalAccountRegistrationEnabled ? 'none' : undefined }}
-                      prefetch={false}
-                    >
-                      <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
-                    </Link>
-                  </span>
-                  {!isLocalAccountRegistrationEnabled && (
-                    <UncontrolledTooltip target="sign-up-link" fade={false}>
-                      {t('tooltip.login_required')}
-                    </UncontrolledTooltip>
-                  )}
+          )}
+
+          {isGuestUser && (
+            <div>
+              <span>
+                <span className="d-inline-block" id="sign-up-link">
+                  <Link
+                    href={
+                      !isLocalAccountRegistrationEnabled
+                        ? '#'
+                        : '/login#register'
+                    }
+                    className={`btn me-2 ${!isLocalAccountRegistrationEnabled ? 'opacity-25' : ''}`}
+                    style={{
+                      pointerEvents: !isLocalAccountRegistrationEnabled
+                        ? 'none'
+                        : undefined,
+                    }}
+                    prefetch={false}
+                  >
+                    <span className="material-symbols-outlined me-1">
+                      person_add
+                    </span>
+                    {t('Sign up')}
+                  </Link>
                 </span>
-                <Link href="/login#login" className="btn btn-primary" prefetch={false}>
-                  <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
-                </Link>
-              </div>
-            )}
-          </nav>
-
-        </GroundGlassBar>
+                {!isLocalAccountRegistrationEnabled && (
+                  <UncontrolledTooltip target="sign-up-link" fade={false}>
+                    {t('tooltip.login_required')}
+                  </UncontrolledTooltip>
+                )}
+              </span>
+              <Link
+                href="/login#login"
+                className="btn btn-primary"
+                prefetch={false}
+              >
+                <span className="material-symbols-outlined me-1">login</span>
+                {t('Sign in')}
+              </Link>
+            </div>
+          )}
+        </nav>
       </Sticky>
 
       {path != null && currentUser != null && !isReadOnlyUser && (
@@ -464,8 +558,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       )}
     </>
   );
-
 };
 
-
 export default GrowiContextualSubNavigation;

+ 27 - 30
apps/app/src/client/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { type JSX, useCallback } from 'react';
 
 import { GroundGlassBar } from '~/components/Navbar/GroundGlassBar';
 import { useSearchModalActions } from '~/features/search/client/states/modal/search';
@@ -9,9 +9,7 @@ import { useDrawerOpened } from '~/states/ui/sidebar';
 
 import styles from './GrowiNavbarBottom.module.scss';
 
-
 export const GrowiNavbarBottom = (): JSX.Element => {
-
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
   const { open: openCreateModal } = usePageCreateModalActions();
   const currentPagePath = useCurrentPagePath();
@@ -23,61 +21,60 @@ export const GrowiNavbarBottom = (): JSX.Element => {
   }, [openSearchModal]);
 
   return (
-    <GroundGlassBar className={`
+    <GroundGlassBar
+      className={`
       ${styles['grw-navbar-bottom']}
       ${isDrawerOpened ? styles['grw-navbar-bottom-drawer-opened'] : ''}
       d-md-none d-edit-none d-print-none fixed-bottom`}
     >
       <div className="navbar navbar-expand px-4 px-sm-5">
-
         <ul className="navbar-nav flex-grow-1 d-flex align-items-center justify-content-between">
           <li className="nav-item">
-            <a
-              role="button"
+            <button
+              type="button"
               className="nav-link btn-lg"
               onClick={() => setIsDrawerOpened(true)}
             >
               <span className="material-symbols-outlined fs-2">reorder</span>
-            </a>
+            </button>
           </li>
 
           <li className="nav-item">
-            <a
-              role="button"
+            <button
+              type="button"
               className="nav-link btn-lg"
               onClick={() => openCreateModal(currentPagePath || '')}
             >
               <span className="material-symbols-outlined fs-2">edit</span>
-            </a>
+            </button>
           </li>
 
-          {
-            !isSearchPage && (
-              <li className="nav-item">
-                <a
-                  role="button"
-                  className="nav-link btn-lg"
-                  onClick={searchButtonClickHandler}
-                >
-                  <span className="material-symbols-outlined fs-2">search</span>
-                </a>
-              </li>
-            )
-          }
+          {!isSearchPage && (
+            <li className="nav-item">
+              <button
+                type="button"
+                className="nav-link btn-lg"
+                onClick={searchButtonClickHandler}
+              >
+                <span className="material-symbols-outlined fs-2">search</span>
+              </button>
+            </li>
+          )}
 
           <li className="nav-item">
-            <a
-              role="button"
+            <button
+              type="button"
               className="nav-link btn-lg"
               onClick={() => {}}
+              aria-label="Notifications"
             >
-              <span className="material-symbols-outlined fs-2">notifications</span>
-            </a>
+              <span className="material-symbols-outlined fs-2">
+                notifications
+              </span>
+            </button>
           </li>
-
         </ul>
       </div>
-
     </GroundGlassBar>
   );
 };

+ 32 - 34
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -1,7 +1,4 @@
-import React, {
-  type ReactNode, useCallback, useMemo, type JSX,
-} from 'react';
-
+import React, { type JSX, type ReactNode, useCallback, useMemo } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useCreatePage } from '~/client/services/create-page';
@@ -9,24 +6,24 @@ import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
-import { useEditorMode, EditorMode } from '~/states/ui/editor';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 import styles from './PageEditorModeManager.module.scss';
 
-
 type PageEditorModeButtonProps = {
-  currentEditorMode: EditorMode,
-  editorMode: EditorMode,
-  children?: ReactNode,
-  isBtnDisabled?: boolean,
-  onClick?: () => void,
-}
+  currentEditorMode: EditorMode;
+  editorMode: EditorMode;
+  children?: ReactNode;
+  isBtnDisabled?: boolean;
+  onClick?: () => void;
+};
 const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
-  const {
-    currentEditorMode, isBtnDisabled, editorMode, children, onClick,
-  } = props;
+  const { currentEditorMode, isBtnDisabled, editorMode, children, onClick } =
+    props;
 
-  const classNames = ['btn py-1 px-2 d-flex align-items-center justify-content-center'];
+  const classNames = [
+    'btn py-1 px-2 d-flex align-items-center justify-content-center',
+  ];
   if (currentEditorMode === editorMode) {
     classNames.push('active');
   }
@@ -47,17 +44,13 @@ const PageEditorModeButton = React.memo((props: PageEditorModeButtonProps) => {
 });
 
 type Props = {
-  editorMode: EditorMode | undefined,
-  isBtnDisabled: boolean,
-  path?: string,
-}
+  editorMode: EditorMode | undefined;
+  isBtnDisabled: boolean;
+  path?: string;
+};
 
 export const PageEditorModeManager = (props: Props): JSX.Element => {
-  const {
-    editorMode = EditorMode.View,
-    isBtnDisabled,
-    path,
-  } = props;
+  const { editorMode = EditorMode.View, isBtnDisabled, path } = props;
 
   const { t } = useTranslation('commons');
 
@@ -71,8 +64,7 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
   const editButtonClickedHandler = useCallback(async () => {
     try {
       await startEditing(path);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
   }, [startEditing, path, t]);
@@ -91,9 +83,8 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   return (
     <>
-      <div
+      <fieldset
         className={`btn-group grw-page-editor-mode-manager ${styles['grw-page-editor-mode-manager']}`}
-        role="group"
         aria-label="page-editor-mode-manager"
         id="grw-page-editor-mode-manager"
         data-testid="grw-page-editor-mode-manager"
@@ -105,7 +96,8 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             isBtnDisabled={_isBtnDisabled}
             onClick={() => setEditorMode(EditorMode.View)}
           >
-            <span className="material-symbols-outlined fs-4">play_arrow</span>{t('View')}
+            <span className="material-symbols-outlined fs-4">play_arrow</span>
+            {t('View')}
           </PageEditorModeButton>
         )}
         {(isDeviceLargerThanMd || editorMode === EditorMode.View) && (
@@ -115,12 +107,18 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
             isBtnDisabled={_isBtnDisabled}
             onClick={editButtonClickedHandler}
           >
-            <span className="material-symbols-outlined me-1 fs-5">edit_square</span>{t('Edit')}
-            {circleColor != null && <span className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`} />}
+            <span className="material-symbols-outlined me-1 fs-5">
+              edit_square
+            </span>
+            {t('Edit')}
+            {circleColor != null && (
+              <span
+                className={`position-absolute top-0 start-100 translate-middle p-1 rounded-circle ${circleColor}`}
+              />
+            )}
           </PageEditorModeButton>
         )}
-      </div>
+      </fieldset>
     </>
   );
-
 };

+ 106 - 45
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -1,18 +1,21 @@
-import React, {
-  useMemo, useCallback, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
+import dynamic from 'next/dynamic';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import {
-  Modal, ModalBody, ModalHeader,
-} from 'reactstrap';
+import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSharedUser,
+} from '~/states/context';
 import { disableLinkSharingAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
-import { usePageAccessoriesModalStatus, usePageAccessoriesModalActions, PageAccessoriesModalContents } from '~/states/ui/modal/page-accessories';
+import {
+  PageAccessoriesModalContents,
+  usePageAccessoriesModalActions,
+  usePageAccessoriesModalStatus,
+} from '~/states/ui/modal/page-accessories';
 
 import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
 import CustomTabContent from '../CustomNavigation/CustomTabContent';
@@ -20,18 +23,51 @@ import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import styles from './PageAccessoriesModal.module.scss';
 
+const PageAttachment = dynamic(() => import('./PageAttachment'), {
+  ssr: false,
+});
+const PageHistory = dynamic(
+  () => import('./PageHistory').then((mod) => mod.PageHistory),
+  { ssr: false },
+);
+const ShareLink = dynamic(
+  () => import('./ShareLink').then((mod) => mod.ShareLink),
+  { ssr: false },
+);
+
+const PageHistoryIcon = (): JSX.Element => (
+  <span className="material-symbols-outlined">history</span>
+);
+const PageAttachmentIcon = (): JSX.Element => (
+  <span className="material-symbols-outlined">attachment</span>
+);
+const ShareLinkIcon = (): JSX.Element => (
+  <span className="material-symbols-outlined">share</span>
+);
+
+const PageHistoryContent = (): JSX.Element => {
+  const { close } = usePageAccessoriesModalActions();
 
-const PageAttachment = dynamic(() => import('./PageAttachment'), { ssr: false });
-const PageHistory = dynamic(() => import('./PageHistory').then(mod => mod.PageHistory), { ssr: false });
-const ShareLink = dynamic(() => import('./ShareLink').then(mod => mod.ShareLink), { ssr: false });
+  return <PageHistory onClose={close} />;
+};
+
+const PageAttachmentContent = (): JSX.Element => {
+  return <PageAttachment />;
+};
+
+const ShareLinkContent = (): JSX.Element => {
+  return <ShareLink />;
+};
 
 interface PageAccessoriesModalSubstanceProps {
   isWindowExpanded: boolean;
   setIsWindowExpanded: (expanded: boolean) => void;
 }
 
-const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }: PageAccessoriesModalSubstanceProps): JSX.Element => {
-
+const PageAccessoriesModalSubstance = ({
+  isWindowExpanded,
+  setIsWindowExpanded,
+}: PageAccessoriesModalSubstanceProps): JSX.Element => {
   const { t } = useTranslation();
 
   const isSharedUser = useIsSharedUser();
@@ -47,45 +83,57 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
   const navTabMapping = useMemo(() => {
     return {
       [PageAccessoriesModalContents.PageHistory]: {
-        Icon: () => <span className="material-symbols-outlined">history</span>,
-        Content: () => {
-          return <PageHistory onClose={close} />;
-        },
+        Icon: PageHistoryIcon,
+        Content: PageHistoryContent,
         i18n: t('History'),
         isLinkEnabled: () => !isGuestUser && !isSharedUser,
       },
       [PageAccessoriesModalContents.Attachment]: {
-        Icon: () => <span className="material-symbols-outlined">attachment</span>,
-        Content: () => {
-          return <PageAttachment />;
-        },
+        Icon: PageAttachmentIcon,
+        Content: PageAttachmentContent,
         i18n: t('attachment_data'),
       },
       [PageAccessoriesModalContents.ShareLink]: {
-        Icon: () => <span className="material-symbols-outlined">share</span>,
-        Content: () => {
-          return <ShareLink />;
-        },
+        Icon: ShareLinkIcon,
+        Content: ShareLinkContent,
         i18n: t('share_links.share_link_management'),
-        isLinkEnabled: () => !isGuestUser && !isReadOnlyUser && !isSharedUser && !isLinkSharingDisabled,
+        isLinkEnabled: () =>
+          !isGuestUser &&
+          !isReadOnlyUser &&
+          !isSharedUser &&
+          !isLinkSharingDisabled,
       },
     };
-  }, [t, close, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
+  }, [t, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   // Memoize expand/contract handlers
-  const expandWindow = useCallback(() => setIsWindowExpanded(true), [setIsWindowExpanded]);
-  const contractWindow = useCallback(() => setIsWindowExpanded(false), [setIsWindowExpanded]);
-
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={expandWindow}
-        contractWindow={contractWindow}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={close} aria-label="Close"></button>
-    </span>
-  ), [close, isWindowExpanded, expandWindow, contractWindow]);
+  const expandWindow = useCallback(
+    () => setIsWindowExpanded(true),
+    [setIsWindowExpanded],
+  );
+  const contractWindow = useCallback(
+    () => setIsWindowExpanded(false),
+    [setIsWindowExpanded],
+  );
+
+  const buttons = useMemo(
+    () => (
+      <span className="me-3">
+        <ExpandOrContractButton
+          isWindowExpanded={isWindowExpanded}
+          expandWindow={expandWindow}
+          contractWindow={contractWindow}
+        />
+        <button
+          type="button"
+          className="btn btn-close ms-2"
+          onClick={close}
+          aria-label="Close"
+        ></button>
+      </span>
+    ),
+    [close, isWindowExpanded, expandWindow, contractWindow],
+  );
 
   if (status == null || status.activatedContents == null) {
     return <></>;
@@ -93,7 +141,11 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
 
   return (
     <>
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
+      <ModalHeader
+        className={isDeviceLargerThanLg ? 'p-0' : ''}
+        toggle={close}
+        close={buttons}
+      >
         {isDeviceLargerThanLg && (
           <CustomNavTab
             activeTab={status.activatedContents}
@@ -115,7 +167,11 @@ const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }
         <CustomTabContent
           activeTab={status.activatedContents}
           navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+          additionalClassNames={
+            !isDeviceLargerThanLg
+              ? ['grw-tab-content-style-md-down']
+              : undefined
+          }
         />
       </ModalBody>
     </>
@@ -139,7 +195,12 @@ export const PageAccessoriesModal = (): JSX.Element => {
       data-testid="page-accessories-modal"
       className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
     >
-      {status.isOpened && <PageAccessoriesModalSubstance isWindowExpanded={isWindowExpanded} setIsWindowExpanded={setIsWindowExpanded} />}
+      {status.isOpened && (
+        <PageAccessoriesModalSubstance
+          isWindowExpanded={isWindowExpanded}
+          setIsWindowExpanded={setIsWindowExpanded}
+        />
+      )}
     </Modal>
   );
 };

+ 30 - 21
apps/app/src/client/components/PageAccessoriesModal/PageAttachment.tsx

@@ -1,7 +1,4 @@
-import React, {
-  useCallback, useMemo, useState, type JSX,
-} from 'react';
-
+import React, { type JSX, useCallback, useMemo, useState } from 'react';
 import type { IAttachmentHasId } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
@@ -19,7 +16,6 @@ const checkIfFileInUse = (markdown: string, attachment): boolean => {
 };
 
 const PageAttachment = (): JSX.Element => {
-
   const pageId = useCurrentPageId();
   const currentPage = useCurrentPageData();
 
@@ -32,32 +28,40 @@ const PageAttachment = (): JSX.Element => {
   const [pageNumber, setPageNumber] = useState(1);
 
   // SWRs
-  const { data: dataAttachments, remove } = useSWRxAttachments(pageId, pageNumber);
+  const { data: dataAttachments, remove } = useSWRxAttachments(
+    pageId,
+    pageNumber,
+  );
   const { open: openDeleteAttachmentModal } = useDeleteAttachmentModalActions();
   const markdown = currentPage?.revision?.body;
 
   // Custom hooks
-  const inUseAttachmentsMap: { [id: string]: boolean } | undefined = useMemo(() => {
-    if (markdown == null || dataAttachments == null) {
-      return undefined;
-    }
-
-    const attachmentEntries = dataAttachments.attachments
-      .map((attachment) => {
-        return [attachment._id, checkIfFileInUse(markdown, attachment)];
-      });
+  const inUseAttachmentsMap: { [id: string]: boolean } | undefined =
+    useMemo(() => {
+      if (markdown == null || dataAttachments == null) {
+        return undefined;
+      }
+
+      const attachmentEntries = dataAttachments.attachments.map(
+        (attachment) => {
+          return [attachment._id, checkIfFileInUse(markdown, attachment)];
+        },
+      );
 
-    return Object.fromEntries(attachmentEntries);
-  }, [dataAttachments, markdown]);
+      return Object.fromEntries(attachmentEntries);
+    }, [dataAttachments, markdown]);
 
   // Methods
   const onChangePageHandler = useCallback((newPageNumber: number) => {
     setPageNumber(newPageNumber);
   }, []);
 
-  const onAttachmentDeleteClicked = useCallback((attachment: IAttachmentHasId) => {
-    openDeleteAttachmentModal(attachment, remove);
-  }, [openDeleteAttachmentModal, remove]);
+  const onAttachmentDeleteClicked = useCallback(
+    (attachment: IAttachmentHasId) => {
+      openDeleteAttachmentModal(attachment, remove);
+    },
+    [openDeleteAttachmentModal, remove],
+  );
 
   // Renderers
   const renderPageAttachmentList = useCallback(() => {
@@ -77,7 +81,12 @@ const PageAttachment = (): JSX.Element => {
         isUserLoggedIn={!isPageAttachmentDisabled}
       />
     );
-  }, [dataAttachments, inUseAttachmentsMap, isPageAttachmentDisabled, onAttachmentDeleteClicked]);
+  }, [
+    dataAttachments,
+    inUseAttachmentsMap,
+    isPageAttachmentDisabled,
+    onAttachmentDeleteClicked,
+  ]);
 
   const renderPaginationWrapper = useCallback(() => {
     if (dataAttachments == null || dataAttachments.attachments.length === 0) {

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

@@ -1,19 +1,20 @@
-import React from 'react';
+import type React from 'react';
 
-import { useCurrentPagePath, useCurrentPageId } from '~/states/page';
+import { useCurrentPageId, useCurrentPagePath } from '~/states/page';
 import loggerFactory from '~/utils/logger';
 
 import { PageRevisionTable } from '../PageHistory/PageRevisionTable';
-
 import { useAutoComparingRevisionsByQueryParam } from './hooks';
 
 const logger = loggerFactory('growi:PageHistory');
 
 type PageHistoryProps = {
-  onClose: () => void
-}
+  onClose: () => void;
+};
 
-export const PageHistory: React.FC<PageHistoryProps> = (props: PageHistoryProps) => {
+export const PageHistory: React.FC<PageHistoryProps> = (
+  props: PageHistoryProps,
+) => {
   const { onClose } = props;
 
   const currentPageId = useCurrentPageId();

+ 39 - 23
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLink.tsx

@@ -1,9 +1,8 @@
-import React, { useState, useCallback, type JSX } from 'react';
-
+import React, { type JSX, useCallback, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { apiv3Delete } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentPageId } from '~/states/page';
 import { useSWRxSharelink } from '~/stores/share-link';
 
@@ -12,46 +11,61 @@ import ShareLinkList from './ShareLinkList';
 
 export const ShareLink = (): JSX.Element => {
   const { t } = useTranslation();
-  const [isOpenShareLinkForm, setIsOpenShareLinkForm] = useState<boolean>(false);
+  const [isOpenShareLinkForm, setIsOpenShareLinkForm] =
+    useState<boolean>(false);
 
   const currentPageId = useCurrentPageId();
 
   const { data: currentShareLinks, mutate } = useSWRxSharelink(currentPageId);
 
   const toggleShareLinkFormHandler = useCallback(() => {
-    setIsOpenShareLinkForm(prev => !prev);
+    setIsOpenShareLinkForm((prev) => !prev);
     mutate();
   }, [mutate]);
 
-  const deleteAllLinksButtonHandler = useCallback(async() => {
+  const deleteAllLinksButtonHandler = useCallback(async () => {
     try {
-      const res = await apiv3Delete('/share-links/', { relatedPage: currentPageId });
+      const res = await apiv3Delete('/share-links/', {
+        relatedPage: currentPageId,
+      });
       const count = res.data.n;
       toastSuccess(t('toaster.remove_share_link', { count, ns: 'commons' }));
       mutate();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [mutate, currentPageId, t]);
 
-  const deleteLinkById = useCallback(async(shareLinkId) => {
-    try {
-      const res = await apiv3Delete(`/share-links/${shareLinkId}`);
-      const { deletedShareLink } = res.data;
-      toastSuccess(t('toaster.remove_share_link_success', { shareLinkId: deletedShareLink._id, ns: 'commons' }));
-      mutate();
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }, [mutate, t]);
+  const deleteLinkById = useCallback(
+    async (shareLinkId) => {
+      try {
+        const res = await apiv3Delete(`/share-links/${shareLinkId}`);
+        const { deletedShareLink } = res.data;
+        toastSuccess(
+          t('toaster.remove_share_link_success', {
+            shareLinkId: deletedShareLink._id,
+            ns: 'commons',
+          }),
+        );
+        mutate();
+      } catch (err) {
+        toastError(err);
+      }
+    },
+    [mutate, t],
+  );
 
   return (
     <div className="container p-0" data-testid="share-link-management">
       <h3 className="d-flex pb-2">
-        { t('share_links.share_link_list') }
-        <button className="btn btn-danger ms-auto " type="button" onClick={deleteAllLinksButtonHandler}>{t('delete_all')}</button>
+        {t('share_links.share_link_list')}
+        <button
+          className="btn btn-danger ms-auto "
+          type="button"
+          onClick={deleteAllLinksButtonHandler}
+        >
+          {t('delete_all')}
+        </button>
       </h3>
       <div>
         <ShareLinkList
@@ -66,7 +80,9 @@ export const ShareLink = (): JSX.Element => {
         >
           {isOpenShareLinkForm ? t('Close') : t('New')}
         </button>
-        {isOpenShareLinkForm && <ShareLinkForm onCloseForm={toggleShareLinkFormHandler} />}
+        {isOpenShareLinkForm && (
+          <ShareLinkForm onCloseForm={toggleShareLinkFormHandler} />
+        )}
       </div>
     </div>
   );

+ 115 - 57
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkForm.tsx

@@ -1,43 +1,48 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
-import {
-  format, parse, addDays, set,
-} from 'date-fns';
+import React, { useCallback, useState } from 'react';
+import { addDays, format, parse, set } from 'date-fns';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 import { useCurrentPageId } from '~/states/page';
 
-
 const ExpirationType = {
   UNLIMITED: 'unlimited',
   CUSTOM: 'custom',
   NUMBER_OF_DAYS: 'numberOfDays',
 } as const;
 
-type ExpirationType = typeof ExpirationType[keyof typeof ExpirationType];
+type ExpirationType = (typeof ExpirationType)[keyof typeof ExpirationType];
 
 type Props = {
-  onCloseForm: () => void,
-}
+  onCloseForm: () => void;
+};
 
 export const ShareLinkForm: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const { onCloseForm } = props;
 
-  const [expirationType, setExpirationType] = useState<ExpirationType>(ExpirationType.UNLIMITED);
+  const [expirationType, setExpirationType] = useState<ExpirationType>(
+    ExpirationType.UNLIMITED,
+  );
   const [numberOfDays, setNumberOfDays] = useState<number>(7);
   const [description, setDescription] = useState<string>('');
-  const [customExpirationDate, setCustomExpirationDate] = useState<Date>(new Date());
-  const [customExpirationTime, setCustomExpirationTime] = useState<Date>(new Date());
+  const [customExpirationDate, setCustomExpirationDate] = useState<Date>(
+    new Date(),
+  );
+  const [customExpirationTime, setCustomExpirationTime] = useState<Date>(
+    new Date(),
+  );
 
   const currentPageId = useCurrentPageId();
 
-  const handleChangeExpirationType = useCallback((expirationType: ExpirationType) => {
-    setExpirationType(expirationType);
-  }, []);
+  const handleChangeExpirationType = useCallback(
+    (expirationType: ExpirationType) => {
+      setExpirationType(expirationType);
+    },
+    [],
+  );
 
   const handleChangeNumberOfDays = useCallback((numberOfDays: number) => {
     setNumberOfDays(numberOfDays);
@@ -47,21 +52,27 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
     setDescription(description);
   }, []);
 
-  const handleChangeCustomExpirationDate = useCallback((customExpirationDate: string) => {
-    // set customExpirationDate to today if the input is empty
-    if (customExpirationDate.length === 0) {
-      setCustomExpirationDate(new Date());
-      return;
-    }
+  const handleChangeCustomExpirationDate = useCallback(
+    (customExpirationDate: string) => {
+      // set customExpirationDate to today if the input is empty
+      if (customExpirationDate.length === 0) {
+        setCustomExpirationDate(new Date());
+        return;
+      }
 
-    const parsedDate = parse(customExpirationDate, 'yyyy-MM-dd', new Date());
-    setCustomExpirationDate(parsedDate);
-  }, []);
+      const parsedDate = parse(customExpirationDate, 'yyyy-MM-dd', new Date());
+      setCustomExpirationDate(parsedDate);
+    },
+    [],
+  );
 
-  const handleChangeCustomExpirationTime = useCallback((customExpirationTime: string) => {
-    const parsedTime = parse(customExpirationTime, 'HH:mm', new Date());
-    setCustomExpirationTime(parsedTime);
-  }, []);
+  const handleChangeCustomExpirationTime = useCallback(
+    (customExpirationTime: string) => {
+      const parsedTime = parse(customExpirationTime, 'HH:mm', new Date());
+      setCustomExpirationTime(parsedTime);
+    },
+    [],
+  );
 
   const generateExpired = useCallback(() => {
     if (expirationType === ExpirationType.UNLIMITED) {
@@ -76,9 +87,18 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
     }
 
     if (expirationType === ExpirationType.CUSTOM) {
-      return set(customExpirationDate, { hours: customExpirationTime.getHours(), minutes: customExpirationTime.getMinutes() });
+      return set(customExpirationDate, {
+        hours: customExpirationTime.getHours(),
+        minutes: customExpirationTime.getMinutes(),
+      });
     }
-  }, [t, customExpirationTime, customExpirationDate, expirationType, numberOfDays]);
+  }, [
+    t,
+    customExpirationTime,
+    customExpirationDate,
+    expirationType,
+    numberOfDays,
+  ]);
 
   const closeForm = useCallback(() => {
     if (onCloseForm == null) {
@@ -87,36 +107,38 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
     onCloseForm();
   }, [onCloseForm]);
 
-  const handleIssueShareLink = useCallback(async() => {
-    let expiredAt;
+  const handleIssueShareLink = useCallback(async () => {
+    let expiredAt: Date | null | undefined;
 
     try {
       expiredAt = generateExpired();
-    }
-    catch (err) {
+    } catch (err) {
       return toastError(err);
     }
 
     try {
-      await apiv3Post('/share-links/', { relatedPage: currentPageId, expiredAt, description });
+      await apiv3Post('/share-links/', {
+        relatedPage: currentPageId,
+        expiredAt,
+        description,
+      });
       closeForm();
       toastSuccess(t('toaster.issue_share_link'));
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   }, [t, currentPageId, description, closeForm, generateExpired]);
 
   return (
     <div className="share-link-form p-3">
-      <h3 className="pb-2"> { t('share_links.share_settings') }</h3>
+      <h3 className="pb-2"> {t('share_links.share_settings')}</h3>
       <div className=" p-3">
-
         {/* ExpirationTypeOptions */}
         <div className="row">
-          <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.expire')}</label>
+          <label htmlFor="inputDesc" className="col-md-5 col-form-label">
+            {t('share_links.expire')}
+          </label>
           <div className="col-md-7">
-
             <div className="form-check">
               <input
                 type="radio"
@@ -125,9 +147,16 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                 name="expirationType"
                 value="customRadio1"
                 checked={expirationType === ExpirationType.UNLIMITED}
-                onChange={() => { handleChangeExpirationType(ExpirationType.UNLIMITED) }}
+                onChange={() => {
+                  handleChangeExpirationType(ExpirationType.UNLIMITED);
+                }}
               />
-              <label className="form-label form-check-label" htmlFor="customRadio1">{t('share_links.Unlimited')}</label>
+              <label
+                className="form-label form-check-label"
+                htmlFor="customRadio1"
+              >
+                {t('share_links.Unlimited')}
+              </label>
             </div>
 
             <div className="form-check">
@@ -137,10 +166,15 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                 id="customRadio2"
                 value="customRadio2"
                 checked={expirationType === ExpirationType.NUMBER_OF_DAYS}
-                onChange={() => { handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS) }}
+                onChange={() => {
+                  handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS);
+                }}
                 name="expirationType"
               />
-              <label className="form-label form-check-label" htmlFor="customRadio2">
+              <label
+                className="form-label form-check-label"
+                htmlFor="customRadio2"
+              >
                 <div className="row align-items-center m-0">
                   <input
                     type="number"
@@ -148,8 +182,12 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                     className="col-4"
                     name="expirationType"
                     value={numberOfDays}
-                    onFocus={() => { handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS) }}
-                    onChange={e => handleChangeNumberOfDays(Number(e.target.value))}
+                    onFocus={() => {
+                      handleChangeExpirationType(ExpirationType.NUMBER_OF_DAYS);
+                    }}
+                    onChange={(e) =>
+                      handleChangeNumberOfDays(Number(e.target.value))
+                    }
                   />
                   <span className="col-auto">{t('share_links.Days')}</span>
                 </div>
@@ -164,9 +202,14 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                 name="expirationType"
                 value="customRadio3"
                 checked={expirationType === ExpirationType.CUSTOM}
-                onChange={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
+                onChange={() => {
+                  handleChangeExpirationType(ExpirationType.CUSTOM);
+                }}
               />
-              <label className="form-label form-check-label" htmlFor="customRadio3">
+              <label
+                className="form-label form-check-label"
+                htmlFor="customRadio3"
+              >
                 {t('share_links.Custom')}
               </label>
               <div className="d-inline-flex flex-wrap">
@@ -175,16 +218,24 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
                   className="ms-3 mb-2"
                   name="customExpirationDate"
                   value={format(customExpirationDate, 'yyyy-MM-dd')}
-                  onFocus={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
-                  onChange={e => handleChangeCustomExpirationDate(e.target.value)}
+                  onFocus={() => {
+                    handleChangeExpirationType(ExpirationType.CUSTOM);
+                  }}
+                  onChange={(e) =>
+                    handleChangeCustomExpirationDate(e.target.value)
+                  }
                 />
                 <input
                   type="time"
                   className="ms-3 mb-2"
                   name="customExpiration"
                   value={format(customExpirationTime, 'HH:mm')}
-                  onFocus={() => { handleChangeExpirationType(ExpirationType.CUSTOM) }}
-                  onChange={e => handleChangeCustomExpirationTime(e.target.value)}
+                  onFocus={() => {
+                    handleChangeExpirationType(ExpirationType.CUSTOM);
+                  }}
+                  onChange={(e) =>
+                    handleChangeCustomExpirationTime(e.target.value)
+                  }
                 />
               </div>
             </div>
@@ -193,7 +244,9 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
 
         {/* DescriptionForm */}
         <div className="row">
-          <label htmlFor="inputDesc" className="col-md-5 col-form-label">{t('share_links.description')}</label>
+          <label htmlFor="inputDesc" className="col-md-5 col-form-label">
+            {t('share_links.description')}
+          </label>
           <div className="col-md-4">
             <input
               type="text"
@@ -201,14 +254,19 @@ export const ShareLinkForm: FC<Props> = (props: Props) => {
               id="inputDesc"
               placeholder={t('share_links.enter_desc')}
               value={description}
-              onChange={e => handleChangeDescription(e.target.value)}
+              onChange={(e) => handleChangeDescription(e.target.value)}
             />
           </div>
         </div>
 
         <div className="row mt-4">
           <div className="col">
-            <button type="button" className="btn btn-primary d-block mx-auto px-5" onClick={handleIssueShareLink} data-testid="btn-sharelink-issue">
+            <button
+              type="button"
+              className="btn btn-primary d-block mx-auto px-5"
+              onClick={handleIssueShareLink}
+              data-testid="btn-sharelink-issue"
+            >
               {t('share_links.Issue')}
             </button>
           </div>

+ 41 - 33
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -1,17 +1,15 @@
 import React, { type JSX } from 'react';
-
 import { format as dateFnsFormat } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 
 import { CopyDropdown } from '../../Common/CopyDropdown';
 
-
 type ShareLinkTrProps = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  shareLink: any,
-  isAdmin?: boolean,
-  onDelete?: () => void,
-}
+  shareLink: any;
+  isAdmin?: boolean;
+  onDelete?: () => void;
+};
 
 const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
   const { t } = useTranslation();
@@ -27,7 +25,7 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
       <td className="d-flex justify-content-between align-items-center">
         <span data-testid="share-link">{shareLinkId}</span>
 
-        { isRelatedPageExists && (
+        {isRelatedPageExists && (
           <CopyDropdown
             pagePath={relatedPage.path}
             dropdownToggleId={`copydropdown-for-share-link-list-${shareLinkId}`}
@@ -36,47 +34,53 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
           >
             Copy Link
           </CopyDropdown>
-        ) }
+        )}
       </td>
-      { isAdmin && (
+      {isAdmin && (
         <td>
-          { isRelatedPageExists
-            ? <a href={relatedPage.path}>{relatedPage.path}</a>
-            : '(Page is not found)'
-          }
+          {isRelatedPageExists ? (
+            <a href={relatedPage.path}>{relatedPage.path}</a>
+          ) : (
+            '(Page is not found)'
+          )}
         </td>
-      ) }
-      <td style={{ verticalAlign: 'middle' }}>
-        {shareLink.description}
-      </td>
+      )}
+      <td style={{ verticalAlign: 'middle' }}>{shareLink.description}</td>
       <td style={{ verticalAlign: 'middle' }}>
-        {shareLink.expiredAt && <span>{dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}</span>}
+        {shareLink.expiredAt && (
+          <span>
+            {dateFnsFormat(new Date(shareLink.expiredAt), 'yyyy-MM-dd HH:mm')}
+          </span>
+        )}
       </td>
       <td style={{ maxWidth: '50', textAlign: 'center' }}>
-        <button className="btn btn-outline-danger" type="button" onClick={onDelete}>
-          <span className="material-symbols-outlined">delete</span>{t('Delete')}
+        <button
+          className="btn btn-outline-danger"
+          type="button"
+          onClick={onDelete}
+        >
+          <span className="material-symbols-outlined">delete</span>
+          {t('Delete')}
         </button>
       </td>
     </tr>
   );
 };
 
-
 type Props = {
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  shareLinks: any[],
-  onClickDeleteButton?: (shareLinkId: string) => void,
-  isAdmin?: boolean,
-}
+  shareLinks: any[];
+  onClickDeleteButton?: (shareLinkId: string) => void;
+  isAdmin?: boolean;
+};
 
 const ShareLinkList = (props: Props): JSX.Element => {
-
   const { t } = useTranslation('commons');
 
   function renderShareLinks() {
     return (
       <>
-        {props.shareLinks.map(shareLink => (
+        {props.shareLinks.map((shareLink) => (
           <ShareLinkTr
             key={shareLink._id}
             isAdmin={props.isAdmin}
@@ -98,16 +102,20 @@ const ShareLinkList = (props: Props): JSX.Element => {
       <table className="table table-bordered">
         <thead>
           <tr>
-            <th style={{ width: '350px' }}>{t('share_links.Share Link', { ns: 'commons' })}</th>
-            {props.isAdmin && <th>{t('share_links.Page Path', { ns: 'commons' })}</th>}
+            <th style={{ width: '350px' }}>
+              {t('share_links.Share Link', { ns: 'commons' })}
+            </th>
+            {props.isAdmin && (
+              <th>{t('share_links.Page Path', { ns: 'commons' })}</th>
+            )}
             <th>{t('share_links.description', { ns: 'commons' })}</th>
-            <th style={{ width: '150px' }}>{t('share_links.expire', { ns: 'commons' })}</th>
+            <th style={{ width: '150px' }}>
+              {t('share_links.expire', { ns: 'commons' })}
+            </th>
             <th></th>
           </tr>
         </thead>
-        <tbody>
-          {renderShareLinks()}
-        </tbody>
+        <tbody>{renderShareLinks()}</tbody>
       </table>
     </div>
   );

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

@@ -14,7 +14,10 @@ export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
 
   const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
     'page-accessories-modal',
-    () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),
+    () =>
+      import('./PageAccessoriesModal').then((mod) => ({
+        default: mod.PageAccessoriesModal,
+      })),
     status?.isOpened ?? false,
   );
 

+ 32 - 29
apps/app/src/client/components/PageAccessoriesModal/hooks.tsx

@@ -1,9 +1,13 @@
 import { useEffect, useState } from 'react';
 
-import { usePageAccessoriesModalStatus, usePageAccessoriesModalActions, PageAccessoriesModalContents } from '~/states/ui/modal/page-accessories';
+import {
+  PageAccessoriesModalContents,
+  usePageAccessoriesModalActions,
+  usePageAccessoriesModalStatus,
+} from '~/states/ui/modal/page-accessories';
 
 function getURLQueryParamValue(key: string) {
-// window.location.href is page URL;
+  // window.location.href is page URL;
   const queryStr: URLSearchParams = new URL(window.location.href).searchParams;
   return queryStr.get(key);
 }
@@ -11,7 +15,6 @@ function getURLQueryParamValue(key: string) {
 // https://regex101.com/r/YHTDsr/1
 const queryCompareFormat = /^([0-9a-f]{24})...([0-9a-f]{24})$/i;
 
-
 export const useAutoOpenModalByQueryParam = (): void => {
   const [isArleadyMounted, setIsArleadyMounted] = useState(false);
 
@@ -41,40 +44,40 @@ export const useAutoOpenModalByQueryParam = (): void => {
 
     setIsArleadyMounted(true);
   }, [openPageAccessories, status, isArleadyMounted]);
-
 };
 
 type ComparingRevisionIds = {
-  sourceRevisionId: string,
-  targetRevisionId: string,
-}
+  sourceRevisionId: string;
+  targetRevisionId: string;
+};
 
-export const useAutoComparingRevisionsByQueryParam = (): ComparingRevisionIds | null => {
-  const [isArleadyMounted, setIsArleadyMounted] = useState(false);
+export const useAutoComparingRevisionsByQueryParam =
+  (): ComparingRevisionIds | null => {
+    const [isArleadyMounted, setIsArleadyMounted] = useState(false);
 
-  const [sourceRevisionId, setSourceRevisionId] = useState<string>();
-  const [targetRevisionId, setTargetRevisionId] = useState<string>();
+    const [sourceRevisionId, setSourceRevisionId] = useState<string>();
+    const [targetRevisionId, setTargetRevisionId] = useState<string>();
 
-  useEffect(() => {
-    if (isArleadyMounted) {
-      return;
-    }
+    useEffect(() => {
+      if (isArleadyMounted) {
+        return;
+      }
 
-    const pageIdParams = getURLQueryParamValue('compare');
-    if (pageIdParams != null) {
-      const matches = pageIdParams.match(queryCompareFormat);
+      const pageIdParams = getURLQueryParamValue('compare');
+      if (pageIdParams != null) {
+        const matches = pageIdParams.match(queryCompareFormat);
 
-      if (matches != null) {
-        const [, source, target] = matches;
-        setSourceRevisionId(source);
-        setTargetRevisionId(target);
+        if (matches != null) {
+          const [, source, target] = matches;
+          setSourceRevisionId(source);
+          setTargetRevisionId(target);
+        }
       }
-    }
 
-    setIsArleadyMounted(true);
-  }, [isArleadyMounted]);
+      setIsArleadyMounted(true);
+    }, [isArleadyMounted]);
 
-  return sourceRevisionId != null && targetRevisionId != null
-    ? { sourceRevisionId, targetRevisionId }
-    : null;
-};
+    return sourceRevisionId != null && targetRevisionId != null
+      ? { sourceRevisionId, targetRevisionId }
+      : null;
+  };

+ 66 - 43
apps/app/src/client/components/PageComment/Comment.tsx

@@ -1,47 +1,49 @@
-import React, {
-  useEffect, useMemo, useState, type JSX,
-} from 'react';
-
-import { isPopulated, type IUser } from '@growi/core';
+import React, { type JSX, useEffect, useMemo, useState } from 'react';
+import Link from 'next/link';
+import { type IUser, isPopulated } from '@growi/core';
 import * as pathUtils from '@growi/core/dist/utils/path-utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format, parseISO } from 'date-fns';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 import { UncontrolledTooltip } from 'reactstrap';
 import urljoin from 'url-join';
 
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
-
 import RevisionRenderer from '../../../components/PageView/RevisionRenderer';
 import { Username } from '../../../components/User/Username';
 import type { ICommentHasId } from '../../../interfaces/comment';
 import FormattedDistanceDate from '../FormattedDistanceDate';
-
 import { CommentControl } from './CommentControl';
 import { CommentEditor } from './CommentEditor';
 
 import styles from './Comment.module.scss';
 
 type CommentProps = {
-  comment: ICommentHasId,
-  rendererOptions: RendererOptions,
-  revisionId: string,
-  revisionCreatedAt: Date,
-  currentUser: IUser,
-  isReadOnly: boolean,
-  pageId: string,
-  pagePath: string,
-  deleteBtnClicked: (comment: ICommentHasId) => void,
-  onComment: () => void,
-}
+  comment: ICommentHasId;
+  rendererOptions: RendererOptions;
+  revisionId: string;
+  revisionCreatedAt: Date;
+  currentUser: IUser;
+  isReadOnly: boolean;
+  pageId: string;
+  pagePath: string;
+  deleteBtnClicked: (comment: ICommentHasId) => void;
+  onComment: () => void;
+};
 
 export const Comment = (props: CommentProps): JSX.Element => {
-
   const {
-    comment, rendererOptions, revisionId, revisionCreatedAt, currentUser, isReadOnly,
-    pageId, pagePath, deleteBtnClicked, onComment,
+    comment,
+    rendererOptions,
+    revisionId,
+    revisionCreatedAt,
+    currentUser,
+    isReadOnly,
+    pageId,
+    pagePath,
+    deleteBtnClicked,
+    onComment,
   } = props;
 
   const { returnPathForURL } = pathUtils;
@@ -82,22 +84,24 @@ export const Comment = (props: CommentProps): JSX.Element => {
     let className = 'page-comment flex-column';
 
     // TODO: fix so that `comment.createdAt` to be type Date https://redmine.weseek.co.jp/issues/113876
-    const commentCreatedAtFixed = typeof comment.createdAt === 'string'
-      ? parseISO(comment.createdAt)
-      : comment.createdAt;
-    const revisionCreatedAtFixed = typeof revisionCreatedAt === 'string'
-      ? parseISO(revisionCreatedAt)
-      : revisionCreatedAt;
+    const commentCreatedAtFixed =
+      typeof comment.createdAt === 'string'
+        ? parseISO(comment.createdAt)
+        : comment.createdAt;
+    const revisionCreatedAtFixed =
+      typeof revisionCreatedAt === 'string'
+        ? parseISO(revisionCreatedAt)
+        : revisionCreatedAt;
 
     // Conditional for called from SearchResultContext
     if (revisionId != null && revisionCreatedAt != null) {
       if (comment.revision === revisionId) {
         className += ' page-comment-current';
-      }
-      else if (commentCreatedAtFixed.getTime() > revisionCreatedAtFixed.getTime()) {
+      } else if (
+        commentCreatedAtFixed.getTime() > revisionCreatedAtFixed.getTime()
+      ) {
         className += ' page-comment-newer';
-      }
-      else {
+      } else {
         className += ' page-comment-older';
       }
     }
@@ -130,11 +134,13 @@ export const Comment = (props: CommentProps): JSX.Element => {
   const rootClassName = getRootClassName(comment);
   const revHref = `?revisionId=${comment.revision}`;
   const editedDateId = `editedDate-${comment._id}`;
-  const editedDateFormatted = isEdited ? format(updatedAt, 'yyyy/MM/dd HH:mm') : null;
+  const editedDateFormatted = isEdited
+    ? format(updatedAt, 'yyyy/MM/dd HH:mm')
+    : null;
 
   return (
     <div className={`${styles['comment-styles']}`}>
-      { (isReEdit && !isReadOnly) ? (
+      {isReEdit && !isReadOnly ? (
         <CommentEditor
           pageId={comment._id}
           replyTo={undefined}
@@ -155,8 +161,15 @@ export const Comment = (props: CommentProps): JSX.Element => {
               <div className="small fw-bold me-3">
                 <Username user={creator} />
               </div>
-              <Link href={`#${commentId}`} prefetch={false} className="small page-comment-revision">
-                <FormattedDistanceDate id={commentId} date={comment.createdAt} />
+              <Link
+                href={`#${commentId}`}
+                prefetch={false}
+                className="small page-comment-revision"
+              >
+                <FormattedDistanceDate
+                  id={commentId}
+                  date={comment.createdAt}
+                />
               </Link>
               <span className="ms-2">
                 <Link
@@ -167,29 +180,39 @@ export const Comment = (props: CommentProps): JSX.Element => {
                 >
                   <span className="material-symbols-outlined">history</span>
                 </Link>
-                <UncontrolledTooltip placement="bottom" fade={false} target={`page-comment-revision-${commentId}`}>
+                <UncontrolledTooltip
+                  placement="bottom"
+                  fade={false}
+                  target={`page-comment-revision-${commentId}`}
+                >
                   {t('page_comment.display_the_page_when_posting_this_comment')}
                 </UncontrolledTooltip>
               </span>
             </div>
             <div className="page-comment-body">{commentBody}</div>
             <div className="page-comment-meta">
-              { isEdited && (
+              {isEdited && (
                 <>
                   <span id={editedDateId}>&nbsp;(edited)</span>
-                  <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
+                  <UncontrolledTooltip
+                    placement="bottom"
+                    fade={false}
+                    target={editedDateId}
+                  >
+                    {editedDateFormatted}
+                  </UncontrolledTooltip>
                 </>
-              ) }
+              )}
             </div>
-            { (isCurrentUserEqualsToAuthor() && !isReadOnly) && (
+            {isCurrentUserEqualsToAuthor() && !isReadOnly && (
               <CommentControl
                 onClickDeleteBtn={deleteBtnClickedHandler}
                 onClickEditBtn={() => setIsReEdit(true)}
               />
-            ) }
+            )}
           </div>
         </div>
-      ) }
+      )}
     </div>
   );
 };

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

@@ -3,12 +3,11 @@ import React, { type JSX } from 'react';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
 
 type CommentControlProps = {
-  onClickEditBtn: () => void,
-  onClickDeleteBtn: () => void,
-}
+  onClickEditBtn: () => void;
+  onClickDeleteBtn: () => void;
+};
 
 export const CommentControl = (props: CommentControlProps): JSX.Element => {
-
   const { onClickEditBtn, onClickDeleteBtn } = props;
 
   return (
@@ -16,7 +15,11 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
     <div className="page-comment-control">
       <NotAvailableIfReadOnlyUserNotAllowedToComment>
         <>
-          <button type="button" className="btn btn-link p-2 opacity-50" onClick={onClickEditBtn}>
+          <button
+            type="button"
+            className="btn btn-link p-2 opacity-50"
+            onClick={onClickEditBtn}
+          >
             <span className="material-symbols-outlined">edit</span>
           </button>
           <button
@@ -31,5 +34,4 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
       </NotAvailableIfReadOnlyUserNotAllowedToComment>
     </div>
   );
-
 };

+ 153 - 113
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -1,83 +1,90 @@
-import type { ReactNode, JSX } from 'react';
+import type { JSX, ReactNode } from 'react';
 import React, {
-  useCallback, useState, useEffect, useLayoutEffect,
+  useCallback,
+  useEffect,
+  useLayoutEffect,
   useMemo,
+  useState,
 } from 'react';
-
+import dynamic from 'next/dynamic';
 import { GlobalCodeMirrorEditorKey, useSetResolvedTheme } from '@growi/editor';
 import { CodeMirrorEditorComment } from '@growi/editor/dist/client/components/CodeMirrorEditorComment';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import {
-  TabContent, TabPane,
-} from 'reactstrap';
-
+import { TabContent, TabPane } from 'reactstrap';
 
 import { uploadAttachments } from '~/client/services/upload-attachments';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentPagePath } from '~/states/page';
-import { isSlackConfiguredAtom, useAcceptedUploadFileType } from '~/states/server-configurations';
+import {
+  isSlackConfiguredAtom,
+  useAcceptedUploadFileType,
+} from '~/states/server-configurations';
 import { useIsSlackEnabled } from '~/states/ui/editor';
 import { useCommentEditorsDirtyMap } from '~/states/ui/unsaved-warning';
-import { useNextThemes } from '~/stores-universal/use-next-themes';
 import { useSWRxPageComment } from '~/stores/comment';
-import { useSWRxSlackChannels, useEditorSettings } from '~/stores/editor';
+import { useEditorSettings, useSWRxSlackChannels } from '~/stores/editor';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
-
 import { CommentPreview } from './CommentPreview';
 import { SwitchingButtonGroup } from './SwitchingButtonGroup';
 
-
 import '@growi/editor/dist/style.css';
-import styles from './CommentEditor.module.scss';
 
+import styles from './CommentEditor.module.scss';
 
 const logger = loggerFactory('growi:components:CommentEditor');
 
+const SlackNotification = dynamic(
+  () => import('../SlackNotification').then((mod) => mod.SlackNotification),
+  { ssr: false },
+);
 
-const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
-
-
-const CommentEditorLayout = ({ children }: { children: ReactNode }): JSX.Element => {
+const CommentEditorLayout = ({
+  children,
+}: {
+  children: ReactNode;
+}): JSX.Element => {
   return (
     <div className={`${styles['comment-editor-styles']} form`}>
       <div className="comment-form">
-        <div className="bg-comment rounded">
-          {children}
-        </div>
+        <div className="bg-comment rounded">{children}</div>
       </div>
     </div>
   );
 };
 
-
 type CommentEditorProps = {
-  pageId: string,
-  replyTo?: string,
-  revisionId: string,
-  currentCommentId?: string,
-  commentBody?: string,
-  onCanceled?: () => void,
-  onCommented?: () => void,
-}
+  pageId: string;
+  replyTo?: string;
+  revisionId: string;
+  currentCommentId?: string;
+  commentBody?: string;
+  onCanceled?: () => void;
+  onCommented?: () => void;
+};
 
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
-
   const {
-    pageId, replyTo, revisionId,
-    currentCommentId, commentBody, onCanceled, onCommented,
+    pageId,
+    replyTo,
+    revisionId,
+    currentCommentId,
+    commentBody,
+    onCanceled,
+    onCommented,
   } = props;
 
   const currentUser = useCurrentUser();
   const currentPagePath = useCurrentPagePath();
-  const { update: updateComment, post: postComment } = useSWRxPageComment(pageId);
+  const { update: updateComment, post: postComment } =
+    useSWRxPageComment(pageId);
   const [isSlackEnabled, setIsSlackEnabled] = useIsSlackEnabled();
   const acceptedUploadFileType = useAcceptedUploadFileType();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
@@ -139,7 +146,6 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     setError(undefined);
 
     initializeSlackEnabled();
-
   }, [editorKey, markClean, initializeSlackEnabled]);
 
   const cancelButtonClickedHandler = useCallback(() => {
@@ -154,8 +160,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       if (currentCommentId != null) {
         // update current comment
         await updateComment(commentBodyToPost, revisionId, currentCommentId);
-      }
-      else {
+      } else {
         // post new comment
         const postCommentArgs = {
           commentForm: {
@@ -177,39 +182,55 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
       // Insert empty string as new comment editor is opened after comment
       codeMirrorEditor?.initDoc('');
-    }
-    catch (err) {
-      const errorMessage = err.message || 'An unknown error occured when posting comment';
+    } catch (err) {
+      const errorMessage =
+        err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
     }
     // eslint-disable-next-line max-len
-  }, [currentCommentId, initializeEditor, onCommented, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
+  }, [
+    currentCommentId,
+    initializeEditor,
+    onCommented,
+    codeMirrorEditor,
+    updateComment,
+    revisionId,
+    replyTo,
+    isSlackEnabled,
+    slackChannels,
+    postComment,
+  ]);
 
   // the upload event handler
-  const uploadHandler = useCallback((files: File[]) => {
-    uploadAttachments(pageId, files, {
-      onUploaded: (attachment) => {
-        const fileName = attachment.originalName;
-
-        const prefix = attachment.fileFormat.startsWith('image/')
-          ? '!' // use "![fileName](url)" syntax when image
-          : '';
-        const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
-
-        codeMirrorEditor?.insertText(insertText);
-      },
-      onError: (error) => {
-        toastError(error);
-      },
-    });
-  }, [codeMirrorEditor, pageId]);
-
-  const cmProps = useMemo(() => ({
-    onChange: (value: string) => {
-      markDirty(editorKey, value);
+  const uploadHandler = useCallback(
+    (files: File[]) => {
+      uploadAttachments(pageId, files, {
+        onUploaded: (attachment) => {
+          const fileName = attachment.originalName;
+
+          const prefix = attachment.fileFormat.startsWith('image/')
+            ? '!' // use "![fileName](url)" syntax when image
+            : '';
+          const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
+
+          codeMirrorEditor?.insertText(insertText);
+        },
+        onError: (error) => {
+          toastError(error);
+        },
+      });
     },
-  }), [editorKey, markDirty]);
+    [codeMirrorEditor, pageId],
+  );
 
+  const cmProps = useMemo(
+    () => ({
+      onChange: (value: string) => {
+        markDirty(editorKey, value);
+      },
+    }),
+    [editorKey, markDirty],
+  );
 
   // initialize CodeMirrorEditor
   useEffect(() => {
@@ -225,16 +246,22 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     codeMirrorEditor?.focus();
   }, [codeMirrorEditor, showPreview]);
 
-  const errorMessage = useMemo(() => <span className="text-danger text-end me-2">{error}</span>, [error]);
-  const cancelButton = useMemo(() => (
-    <button
-      type="button"
-      className="btn btn-outline-neutral-secondary"
-      onClick={cancelButtonClickedHandler}
-    >
-      {t('Cancel')}
-    </button>
-  ), [cancelButtonClickedHandler, t]);
+  const errorMessage = useMemo(
+    () => <span className="text-danger text-end me-2">{error}</span>,
+    [error],
+  );
+  const cancelButton = useMemo(
+    () => (
+      <button
+        type="button"
+        className="btn btn-outline-neutral-secondary"
+        onClick={cancelButtonClickedHandler}
+      >
+        {t('Cancel')}
+      </button>
+    ),
+    [cancelButtonClickedHandler, t],
+  );
   const submitButton = useMemo(() => {
     return (
       <button
@@ -256,9 +283,14 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
             <UserPicture user={currentUser} noLink noTooltip />
             <p className="ms-2 mb-0">{t('page_comment.add_a_comment')}</p>
           </div>
-          <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
+          <SwitchingButtonGroup
+            showPreview={showPreview}
+            onSelected={handleSelect}
+          />
         </div>
-        <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
+        <TabContent
+          activeTab={showPreview ? 'comment_preview' : 'comment_editor'}
+        >
           <TabPane tabId="comment_editor">
             <CodeMirrorEditorComment
               editorKey={editorKey}
@@ -271,7 +303,9 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
           </TabPane>
           <TabPane tabId="comment_preview">
             <div className="comment-preview-container">
-              <CommentPreview markdown={codeMirrorEditor?.getDocString() ?? ''} />
+              <CommentPreview
+                markdown={codeMirrorEditor?.getDocString() ?? ''}
+              />
             </div>
           </TabPane>
         </TabContent>
@@ -280,40 +314,39 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       <div className="comment-submit px-4 pb-3 mb-2">
         <div className="d-flex">
           <span className="flex-grow-1" />
-          <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
-
-          {isSlackConfigured && isSlackEnabled != null
-            && (
-              <div className="align-self-center me-md-3">
-                <SlackNotification
-                  isSlackEnabled={isSlackEnabled}
-                  slackChannels={slackChannels}
-                  onEnabledFlagChange={isSlackEnabledToggleHandler}
-                  onChannelChange={slackChannelsChangedHandler}
-                  id="idForComment"
-                />
-              </div>
-            )
-          }
+          <span className="d-none d-sm-inline">
+            {errorMessage && errorMessage}
+          </span>
+
+          {isSlackConfigured && isSlackEnabled != null && (
+            <div className="align-self-center me-md-3">
+              <SlackNotification
+                isSlackEnabled={isSlackEnabled}
+                slackChannels={slackChannels}
+                onEnabledFlagChange={isSlackEnabledToggleHandler}
+                onChannelChange={slackChannelsChangedHandler}
+                id="idForComment"
+              />
+            </div>
+          )}
           <div className="d-none d-sm-block">
-            <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
+            <span className="me-2">{cancelButton}</span>
+            <span>{submitButton}</span>
           </div>
         </div>
         <div className="d-block d-sm-none mt-2">
           <div className="d-flex justify-content-end">
             {error && errorMessage}
-            <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
+            <span className="me-2">{cancelButton}</span>
+            <span>{submitButton}</span>
           </div>
         </div>
       </div>
     </CommentEditorLayout>
   );
-
 };
 
-
 export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
-
   const { onCommented, onCanceled, ...rest } = props;
 
   const currentUser = useCurrentUser();
@@ -338,8 +371,15 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
             >
-              <UserPicture user={currentUser} noLink noTooltip className="me-3" />
-              <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
+              <UserPicture
+                user={currentUser}
+                noLink
+                noTooltip
+                className="me-3"
+              />
+              <span className="material-symbols-outlined me-1 fs-5">
+                add_comment
+              </span>
               <small>{t('page_comment.add_a_comment')}...</small>
             </button>
           </NotAvailableIfReadOnlyUserNotAllowedToComment>
@@ -348,19 +388,19 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
     );
   }, [currentUser, t]);
 
-  return isReadyToUse
-    ? (
-      <CommentEditor
-        onCommented={() => {
-          onCommented?.();
-          setIsReadyToUse(false);
-        }}
-        onCanceled={() => {
-          onCanceled?.();
-          setIsReadyToUse(false);
-        }}
-        {...rest}
-      />
-    )
-    : render();
+  return isReadyToUse ? (
+    <CommentEditor
+      onCommented={() => {
+        onCommented?.();
+        setIsReadyToUse(false);
+      }}
+      onCanceled={() => {
+        onCanceled?.();
+        setIsReadyToUse(false);
+      }}
+      {...rest}
+    />
+  ) : (
+    render()
+  );
 };

+ 2 - 7
apps/app/src/client/components/PageComment/CommentPreview.tsx

@@ -4,19 +4,15 @@ import { useCommentPreviewOptions } from '~/stores/renderer';
 
 import RevisionRenderer from '../../../components/PageView/RevisionRenderer';
 
-
 import styles from './CommentPreview.module.scss';
 
-
 const moduleClass = styles['grw-comment-preview'] ?? '';
 
-
 type CommentPreviewPorps = {
-  markdown: string,
-}
+  markdown: string;
+};
 
 export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
-
   const { markdown } = props;
 
   const { data: rendererOptions } = useCommentPreviewOptions();
@@ -34,5 +30,4 @@ export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
       />
     </div>
   );
-
 };

+ 74 - 56
apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.tsx

@@ -1,41 +1,38 @@
-import React, { useMemo } from 'react';
-
+import type React from 'react';
+import { useMemo } from 'react';
 import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { Username } from '~/components/User/Username';
 import type { ICommentHasId } from '~/interfaces/comment';
 
 import styles from './DeleteCommentModal.module.scss';
 
-
 export type DeleteCommentModalProps = {
-  isShown: boolean,
-  comment: ICommentHasId | null,
-  errorMessage: string,
-  cancelToDelete: () => void,
-  confirmToDelete: () => void,
-}
+  isShown: boolean;
+  comment: ICommentHasId | null;
+  errorMessage: string;
+  cancelToDelete: () => void;
+  confirmToDelete: () => void;
+};
 
 /**
  * DeleteCommentModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
 type DeleteCommentModalSubstanceProps = {
-  comment: ICommentHasId,
-  errorMessage: string,
-  cancelToDelete: () => void,
-  confirmToDelete: () => void,
-}
+  comment: ICommentHasId;
+  errorMessage: string;
+  cancelToDelete: () => void;
+  confirmToDelete: () => void;
+};
 
-const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): React.JSX.Element => {
-  const {
-    comment, errorMessage, cancelToDelete, confirmToDelete,
-  } = props;
+const DeleteCommentModalSubstance = (
+  props: DeleteCommentModalSubstanceProps,
+): React.JSX.Element => {
+  const { comment, errorMessage, cancelToDelete, confirmToDelete } = props;
 
   const { t } = useTranslation();
 
@@ -63,46 +60,61 @@ const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): R
   }, [comment]);
 
   // Memoize header content
-  const headerContent = useMemo(() => (
-    <span>
-      <span className="material-symbols-outlined">delete_forever</span>
-      {t('page_comment.delete_comment')}
-    </span>
-  ), [t]);
+  const headerContent = useMemo(
+    () => (
+      <span>
+        <span className="material-symbols-outlined">delete_forever</span>
+        {t('page_comment.delete_comment')}
+      </span>
+    ),
+    [t],
+  );
 
   // Memoize body content
-  const bodyContent = useMemo(() => (
-    <>
-      <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
-      <div className="card mt-2">
-        <div className="card-body comment-body px-3 py-2">{commentBodyElement}</div>
-      </div>
-    </>
-  ), [creator, commentDate, commentBodyElement]);
+  const bodyContent = useMemo(
+    () => (
+      <>
+        <UserPicture user={creator} size="xs" />{' '}
+        <strong className="me-2">
+          <Username user={creator}></Username>
+        </strong>
+        {commentDate}:
+        <div className="card mt-2">
+          <div className="card-body comment-body px-3 py-2">
+            {commentBodyElement}
+          </div>
+        </div>
+      </>
+    ),
+    [creator, commentDate, commentBodyElement],
+  );
 
   // Memoize footer content
-  const footerContent = useMemo(() => (
-    <>
-      <span className="text-danger">{errorMessage}</span>&nbsp;
-      <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
-      <Button data-testid="delete-comment-button" color="danger" onClick={confirmToDelete}>
-        <span className="material-symbols-outlined">delete_forever</span>
-        {t('Delete')}
-      </Button>
-    </>
-  ), [errorMessage, cancelToDelete, confirmToDelete, t]);
+  const footerContent = useMemo(
+    () => (
+      <>
+        <span className="text-danger">{errorMessage}</span>&nbsp;
+        <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
+        <Button
+          data-testid="delete-comment-button"
+          color="danger"
+          onClick={confirmToDelete}
+        >
+          <span className="material-symbols-outlined">delete_forever</span>
+          {t('Delete')}
+        </Button>
+      </>
+    ),
+    [errorMessage, cancelToDelete, confirmToDelete, t],
+  );
 
   return (
     <>
       <ModalHeader tag="h4" toggle={cancelToDelete} className="text-danger">
         {headerContent}
       </ModalHeader>
-      <ModalBody>
-        {bodyContent}
-      </ModalBody>
-      <ModalFooter>
-        {footerContent}
-      </ModalFooter>
+      <ModalBody>{bodyContent}</ModalBody>
+      <ModalFooter>{footerContent}</ModalFooter>
     </>
   );
 };
@@ -110,13 +122,19 @@ const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): R
 /**
  * DeleteCommentModal - Container component (lightweight, always rendered)
  */
-export const DeleteCommentModal = (props: DeleteCommentModalProps): React.JSX.Element => {
-  const {
-    isShown, comment, errorMessage, cancelToDelete, confirmToDelete,
-  } = props;
+export const DeleteCommentModal = (
+  props: DeleteCommentModalProps,
+): React.JSX.Element => {
+  const { isShown, comment, errorMessage, cancelToDelete, confirmToDelete } =
+    props;
 
   return (
-    <Modal data-testid="page-comment-delete-modal" isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
+    <Modal
+      data-testid="page-comment-delete-modal"
+      isOpen={isShown}
+      toggle={cancelToDelete}
+      className={`${styles['page-comment-delete-modal']}`}
+    >
       {isShown && comment != null && (
         <DeleteCommentModalSubstance
           comment={comment}

+ 7 - 3
apps/app/src/client/components/PageComment/DeleteCommentModal/dynamic.tsx

@@ -1,13 +1,17 @@
 import type { JSX } from 'react';
 
 import { useLazyLoader } from '../../../../components/utils/use-lazy-loader';
-
 import type { DeleteCommentModalProps } from './DeleteCommentModal';
 
-export const DeleteCommentModalLazyLoaded = (props: DeleteCommentModalProps): JSX.Element => {
+export const DeleteCommentModalLazyLoaded = (
+  props: DeleteCommentModalProps,
+): JSX.Element => {
   const DeleteCommentModal = useLazyLoader<DeleteCommentModalProps>(
     'delete-comment-modal',
-    () => import('./DeleteCommentModal').then(mod => ({ default: mod.DeleteCommentModal })),
+    () =>
+      import('./DeleteCommentModal').then((mod) => ({
+        default: mod.DeleteCommentModal,
+      })),
     props.isShown,
   );
 

+ 35 - 23
apps/app/src/client/components/PageComment/ReplyComments.tsx

@@ -1,6 +1,4 @@
-
-import React, { useState, type JSX } from 'react';
-
+import React, { type JSX, useState } from 'react';
 import type { IUser } from '@growi/core';
 import { useAtomValue } from 'jotai';
 import { Collapse } from 'reactstrap';
@@ -9,30 +7,35 @@ import type { ICommentHasId, ICommentHasIdList } from '~/interfaces/comment';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import { isAllReplyShownAtom } from '~/states/server-configurations';
 
-
 import { Comment } from './Comment';
 
 import styles from './ReplyComments.module.scss';
 
-
 type ReplycommentsProps = {
-  rendererOptions: RendererOptions,
-  isReadOnly: boolean,
-  revisionId: string,
-  revisionCreatedAt: Date,
-  currentUser: IUser,
-  replyList: ICommentHasIdList,
-  pageId: string,
-  pagePath: string,
-  deleteBtnClicked: (comment: ICommentHasId) => void,
-  onComment: () => void,
-}
+  rendererOptions: RendererOptions;
+  isReadOnly: boolean;
+  revisionId: string;
+  revisionCreatedAt: Date;
+  currentUser: IUser;
+  replyList: ICommentHasIdList;
+  pageId: string;
+  pagePath: string;
+  deleteBtnClicked: (comment: ICommentHasId) => void;
+  onComment: () => void;
+};
 
 export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
-
   const {
-    rendererOptions, isReadOnly, revisionId, revisionCreatedAt, currentUser, replyList,
-    pageId, pagePath, deleteBtnClicked, onComment,
+    rendererOptions,
+    isReadOnly,
+    revisionId,
+    revisionCreatedAt,
+    currentUser,
+    replyList,
+    pageId,
+    pagePath,
+    deleteBtnClicked,
+    onComment,
   } = props;
 
   const isAllReplyShown = useAtomValue(isAllReplyShownAtom);
@@ -41,7 +44,10 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const renderReply = (reply: ICommentHasId) => {
     return (
-      <div key={reply._id} className={`${styles['page-comment-reply']} mt-2 ms-4 ms-sm-5`}>
+      <div
+        key={reply._id}
+        className={`${styles['page-comment-reply']} mt-2 ms-4 ms-sm-5`}
+      >
         <Comment
           rendererOptions={rendererOptions}
           comment={reply}
@@ -68,9 +74,15 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
     );
   }
 
-  const areThereHiddenReplies = (replyList.length > 2);
-  const toggleButtonIconName = isOlderRepliesShown ? 'expand_less' : 'more_vert';
-  const toggleButtonIcon = <span className="material-symbols-outlined me-1">{toggleButtonIconName}</span>;
+  const areThereHiddenReplies = replyList.length > 2;
+  const toggleButtonIconName = isOlderRepliesShown
+    ? 'expand_less'
+    : 'more_vert';
+  const toggleButtonIcon = (
+    <span className="material-symbols-outlined me-1">
+      {toggleButtonIconName}
+    </span>
+  );
   const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
   const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
   const hiddenReplies = replyList.slice(0, replyList.length - 2);

+ 12 - 21
apps/app/src/client/components/PageComment/SwitchingButtonGroup.tsx

@@ -1,21 +1,20 @@
 import type { ButtonHTMLAttributes, DetailedHTMLProps, JSX } from 'react';
 import { memo } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import styles from './SwitchingButtonGroup.module.scss';
 
 const moduleClass = styles['btn-group-switching'] ?? '';
 
-
-type SwitchingButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
-    active?: boolean,
-}
+type SwitchingButtonProps = DetailedHTMLProps<
+  ButtonHTMLAttributes<HTMLButtonElement>,
+  HTMLButtonElement
+> & {
+  active?: boolean;
+};
 
 const SwitchingButton = memo((props: SwitchingButtonProps) => {
-  const {
-    active, className, children, onClick, ...rest
-  } = props;
+  const { active, className, children, onClick, ...rest } = props;
 
   return (
     <button
@@ -32,25 +31,18 @@ const SwitchingButton = memo((props: SwitchingButtonProps) => {
   );
 });
 
-
 type Props = {
-  showPreview: boolean,
-  onSelected?: (showPreview: boolean) => void,
+  showPreview: boolean;
+  onSelected?: (showPreview: boolean) => void;
 };
 
 export const SwitchingButtonGroup = (props: Props): JSX.Element => {
-
   const { t } = useTranslation();
 
-  const {
-    showPreview, onSelected,
-  } = props;
+  const { showPreview, onSelected } = props;
 
   return (
-    <div
-      className={`btn-group ${moduleClass}`}
-      role="group"
-    >
+    <fieldset className={`btn-group ${moduleClass}`} aria-label="Comment view">
       <SwitchingButton
         active={showPreview}
         className="ps-2 pe-3"
@@ -67,7 +59,6 @@ export const SwitchingButtonGroup = (props: Props): JSX.Element => {
         <span className="material-symbols-outlined me-1">edit_square</span>
         <span className="d-none d-sm-inline">{t('page_comment.write')}</span>
       </SwitchingButton>
-    </div>
+    </fieldset>
   );
-
 };

+ 44 - 31
apps/app/src/client/components/PageControls/BookmarkButtons.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import DropdownToggle from 'reactstrap/esm/DropdownToggle';
@@ -18,38 +17,37 @@ import styles from './BookmarkButtons.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 
 interface Props {
-  pageId: string,
-  isBookmarked?: boolean,
-  bookmarkCount: number,
+  pageId: string;
+  isBookmarked?: boolean;
+  bookmarkCount: number;
 }
 
 export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
-  const {
-    pageId, isBookmarked, bookmarkCount,
-  } = props;
+  const { pageId, isBookmarked, bookmarkCount } = props;
 
   const [isBookmarkFolderMenuOpen, setBookmarkFolderMenuOpen] = useState(false);
-  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] = useState(false);
+  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] =
+    useState(false);
 
   const isGuestUser = useIsGuestUser();
 
-  const { data: bookmarkedUsers, isLoading: isLoadingBookmarkedUsers } = useSWRxBookmarkedUsers(isBookmarkUsersPopoverOpen ? pageId : null);
+  const { data: bookmarkedUsers, isLoading: isLoadingBookmarkedUsers } =
+    useSWRxBookmarkedUsers(isBookmarkUsersPopoverOpen ? pageId : null);
 
   const unbookmarkHandler = () => {
     setBookmarkFolderMenuOpen(false);
   };
 
   const toggleBookmarkFolderMenuHandler = () => {
-    setBookmarkFolderMenuOpen(v => !v);
+    setBookmarkFolderMenuOpen((v) => !v);
   };
 
   const toggleBookmarkUsersPopover = () => {
-    setBookmarkUsersPopoverOpen(v => !v);
+    setBookmarkUsersPopoverOpen((v) => !v);
   };
 
   const getTooltipMessage = useCallback(() => {
-
     if (isGuestUser) {
       return 'Not available for guest';
     }
@@ -61,8 +59,10 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
   }
 
   return (
-    <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-
+    <fieldset
+      className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`}
+      aria-label="Bookmark buttons"
+    >
       <BookmarkFolderMenu
         isOpen={isBookmarkFolderMenuOpen}
         pageId={pageId}
@@ -76,12 +76,18 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
           className={`btn btn-bookmark rounded-end-0
           ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
         >
-          <span className={`material-symbols-outlined ${isBookmarked ? 'fill' : ''}`}>
+          <span
+            className={`material-symbols-outlined ${isBookmarked ? 'fill' : ''}`}
+          >
             bookmark
           </span>
         </DropdownToggle>
       </BookmarkFolderMenu>
-      <UncontrolledTooltip data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
+      <UncontrolledTooltip
+        data-testid="bookmark-button-tooltip"
+        target="bookmark-dropdown-btn"
+        fade={false}
+      >
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 
@@ -93,23 +99,30 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
       >
         {bookmarkCount}
       </button>
-      <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
-        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
-          { isLoadingBookmarkedUsers && <LoadingSpinner /> }
-          { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
+      <Popover
+        placement="bottom"
+        isOpen={isBookmarkUsersPopoverOpen}
+        target="po-total-bookmarks"
+        toggle={toggleBookmarkUsersPopover}
+        trigger="legacy"
+      >
+        <PopoverBody
+          className={`user-list-popover ${popoverStyles['user-list-popover']}`}
+        >
+          {isLoadingBookmarkedUsers && <LoadingSpinner />}
+          {!isLoadingBookmarkedUsers && bookmarkedUsers != null && (
             <>
-              { bookmarkedUsers.length > 0
-                ? (
-                  <div className="px-2 text-end user-list-content text-truncate text-muted">
-                    <UserPictureList users={bookmarkedUsers} />
-                  </div>
-                )
-                : t('No users have bookmarked yet')
-              }
+              {bookmarkedUsers.length > 0 ? (
+                <div className="px-2 text-end user-list-content text-truncate text-muted">
+                  <UserPictureList users={bookmarkedUsers} />
+                </div>
+              ) : (
+                t('No users have bookmarked yet')
+              )}
             </>
-          ) }
+          )}
         </PopoverBody>
       </Popover>
-    </div>
+    </fieldset>
   );
 };

+ 38 - 23
apps/app/src/client/components/PageControls/LikeButtons.tsx

@@ -1,10 +1,8 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
+import React, { useCallback, useState } from 'react';
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
-
+import { Popover, PopoverBody, UncontrolledTooltip } from 'reactstrap';
 
 import UserPictureList from '../Common/UserPictureList';
 
@@ -12,14 +10,13 @@ import styles from './LikeButtons.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 
 type LikeButtonsProps = {
+  sumOfLikers: number;
+  likers: IUser[];
 
-  sumOfLikers: number,
-  likers: IUser[],
-
-  isGuestUser?: boolean,
-  isLiked?: boolean,
-  onLikeClicked?: ()=>void,
-}
+  isGuestUser?: boolean;
+  isLiked?: boolean;
+  onLikeClicked?: () => void;
+};
 
 const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   const { t } = useTranslation();
@@ -30,12 +27,9 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
     setIsPopoverOpen(!isPopoverOpen);
   };
 
-  const {
-    isGuestUser, isLiked, sumOfLikers, onLikeClicked,
-  } = props;
+  const { isGuestUser, isLiked, sumOfLikers, onLikeClicked } = props;
 
   const getTooltipMessage = useCallback(() => {
-
     if (isLiked) {
       return 'tooltip.cancel_like';
     }
@@ -43,7 +37,10 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
   }, [isLiked]);
 
   return (
-    <div className={`btn-group btn-group-like ${styles['btn-group-like']}`} role="group" aria-label="Like buttons">
+    <fieldset
+      className={`btn-group btn-group-like ${styles['btn-group-like']}`}
+      aria-label="Like buttons"
+    >
       <button
         type="button"
         id="like-button"
@@ -51,10 +48,17 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
         className={`btn btn-like
             ${isLiked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <span className={`material-symbols-outlined ${isLiked ? 'fill' : ''}`}>favorite</span>
+        <span className={`material-symbols-outlined ${isLiked ? 'fill' : ''}`}>
+          favorite
+        </span>
       </button>
 
-      <UncontrolledTooltip data-testid="like-button-tooltip" target="like-button" autohide={false} fade={false}>
+      <UncontrolledTooltip
+        data-testid="like-button-tooltip"
+        target="like-button"
+        autohide={false}
+        fade={false}
+      >
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
 
@@ -66,16 +70,27 @@ const LikeButtons: FC<LikeButtonsProps> = (props: LikeButtonsProps) => {
       >
         {sumOfLikers}
       </button>
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-likes" toggle={togglePopover} trigger="legacy">
-        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
+      <Popover
+        placement="bottom"
+        isOpen={isPopoverOpen}
+        target="po-total-likes"
+        toggle={togglePopover}
+        trigger="legacy"
+      >
+        <PopoverBody
+          className={`user-list-popover ${popoverStyles['user-list-popover']}`}
+        >
           <div className="px-2 text-end user-list-content text-truncate text-muted">
-            {props.likers?.length ? <UserPictureList users={props.likers} /> : t('No users have liked this yet.')}
+            {props.likers?.length ? (
+              <UserPictureList users={props.likers} />
+            ) : (
+              t('No users have liked this yet.')
+            )}
           </div>
         </PopoverBody>
       </Popover>
-    </div>
+    </fieldset>
   );
-
 };
 
 export default LikeButtons;

+ 148 - 93
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -1,61 +1,65 @@
 import React, {
-  memo, useCallback, useEffect, useMemo, useRef, type JSX,
+  type JSX,
+  memo,
+  useCallback,
+  useEffect,
+  useId,
+  useMemo,
+  useRef,
 } from 'react';
-
 import type {
-  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo,
+  IPageToDeleteWithMeta,
+  IPageToRenameWithMeta,
 } from '@growi/core';
 import {
   isIPageInfoForEmpty,
-
-  isIPageInfoForEntity, isIPageInfoForOperation,
+  isIPageInfoForEntity,
+  isIPageInfoForOperation,
 } from '@growi/core';
-import { pagePathUtils } from '@growi/core/dist/utils';
 import { useRect } from '@growi/ui/dist/utils';
-import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
-import {
-  toggleLike, toggleSubscribe,
-} from '~/client/services/page-operation';
+import { toggleLike, toggleSubscribe } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/states/context';
+import {
+  useIsGuestUser,
+  useIsReadOnlyUser,
+  useIsSearchPage,
+} from '~/states/context';
 import { useCurrentPagePath } from '~/states/page';
-import { isUsersHomepageDeletionEnabledAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
-import {
-  EditorMode, useEditorMode,
-} from '~/states/ui/editor';
-import { type IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
+import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 import { useTagEditModalActions } from '~/states/ui/modal/tag-edit';
 import { useSetPageControlsX } from '~/states/ui/page';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../../stores/page';
 import { useSWRxUsersList } from '../../../stores/user';
-import type { AdditionalMenuItemsRendererProps, ForceHideMenuItems } from '../Common/Dropdown/PageItemControl';
+import type {
+  AdditionalMenuItemsRendererProps,
+  ForceHideMenuItems,
+} from '../Common/Dropdown/PageItemControl';
 import {
   MenuItemType,
   PageItemControl,
 } from '../Common/Dropdown/PageItemControl';
-
 import { BookmarkButtons } from './BookmarkButtons';
 import LikeButtons from './LikeButtons';
 import SearchButton from './SearchButton';
 import SeenUserInfo from './SeenUserInfo';
 import SubscribeButton from './SubscribeButton';
 
-
 import styles from './PageControls.module.scss';
 
 const logger = loggerFactory('growi:components/PageControls');
 
-
 type TagsProps = {
-  onClickEditTagsButton: () => void,
-}
+  onClickEditTagsButton: () => void;
+};
 
 const Tags = (props: TagsProps): JSX.Element => {
   const { onClickEditTagsButton } = props;
@@ -76,27 +80,31 @@ const Tags = (props: TagsProps): JSX.Element => {
 };
 
 type WideViewMenuItemProps = AdditionalMenuItemsRendererProps & {
-  onClick: () => void,
-  expandContentWidth?: boolean,
-}
+  onClick: () => void;
+  expandContentWidth?: boolean;
+};
 
 const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const {
-    onClick, expandContentWidth,
-  } = props;
+  const { onClick, expandContentWidth } = props;
+  const wideViewId = useId();
 
   return (
-    <DropdownItem className="grw-page-control-dropdown-item dropdown-item" onClick={onClick} toggle={false}>
+    <DropdownItem
+      className="grw-page-control-dropdown-item dropdown-item"
+      onClick={onClick}
+      toggle={false}
+    >
       <div className="form-check form-switch ms-1">
         <input
           className="form-check-input pe-none"
           type="checkbox"
+          id={wideViewId}
           checked={expandContentWidth}
-          onChange={() => { }}
+          onChange={() => {}}
         />
-        <label className="form-check-label pe-none">
+        <label className="form-check-label pe-none" htmlFor={wideViewId}>
           {t('wide_view')}
         </label>
       </div>
@@ -104,35 +112,50 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
   );
 };
 
-
 type CommonProps = {
-  pageId?: string,
-  shareLinkId?: string | null,
-  revisionId?: string | null,
-  path?: string | null,
-  expandContentWidth?: boolean,
-  disableSeenUserInfoPopover?: boolean,
-  hideSubControls?: boolean,
-  showPageControlDropdown?: boolean,
-  forceHideMenuItems?: ForceHideMenuItems,
-  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
-  onClickDuplicateMenuItem?: (pageToDuplicate: IPageForPageDuplicateModal) => void,
-  onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void,
-  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void,
-  onClickSwitchContentWidth?: (pageId: string, value: boolean) => void,
-}
+  pageId?: string;
+  shareLinkId?: string | null;
+  revisionId?: string | null;
+  path?: string | null;
+  expandContentWidth?: boolean;
+  disableSeenUserInfoPopover?: boolean;
+  hideSubControls?: boolean;
+  showPageControlDropdown?: boolean;
+  forceHideMenuItems?: ForceHideMenuItems;
+  additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>;
+  onClickDuplicateMenuItem?: (
+    pageToDuplicate: IPageForPageDuplicateModal,
+  ) => void;
+  onClickRenameMenuItem?: (pageToRename: IPageToRenameWithMeta) => void;
+  onClickDeleteMenuItem?: (pageToDelete: IPageToDeleteWithMeta) => void;
+  onClickSwitchContentWidth?: (pageId: string, value: boolean) => void;
+};
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfo | undefined,
-  onClickEditTagsButton: () => void,
-}
+  pageInfo: IPageInfo | undefined;
+  onClickEditTagsButton: () => void;
+};
 
-const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element => {
+const PageControlsSubstance = (
+  props: PageControlsSubstanceProps,
+): JSX.Element => {
   const {
     pageInfo,
-    pageId, revisionId, path, shareLinkId, expandContentWidth,
-    disableSeenUserInfoPopover, hideSubControls, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
-    onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    pageId,
+    revisionId,
+    path,
+    shareLinkId,
+    expandContentWidth,
+    disableSeenUserInfoPopover,
+    hideSubControls,
+    showPageControlDropdown,
+    forceHideMenuItems,
+    additionalMenuItemRenderer,
+    onClickEditTagsButton,
+    onClickDuplicateMenuItem,
+    onClickRenameMenuItem,
+    onClickDeleteMenuItem,
+    onClickSwitchContentWidth,
   } = props;
 
   const isGuestUser = useIsGuestUser();
@@ -140,15 +163,16 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { editorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const isSearchPage = useIsSearchPage();
-  const isUsersHomepageDeletionEnabled = useAtomValue(isUsersHomepageDeletionEnabledAtom);
   const currentPagePath = useCurrentPagePath();
 
-  const isUsersHomepage = currentPagePath == null ? false : pagePathUtils.isUsersHomepage(currentPagePath);
-
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
-  const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
-  const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
+  const likerIds = isIPageInfoForEntity(pageInfo)
+    ? (pageInfo.likerIds ?? []).slice(0, 15)
+    : [];
+  const seenUserIds = isIPageInfoForEntity(pageInfo)
+    ? (pageInfo.seenUserIds ?? []).slice(0, 15)
+    : [];
 
   const setPageControlsX = useSetPageControlsX();
 
@@ -162,11 +186,16 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     setPageControlsX(pageControlsRect.x);
   }, [pageControlsRect?.x, setPageControlsX]);
 
-
   // Put in a mixture of seenUserIds and likerIds data to make the cache work
   const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
-  const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
-  const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
+  const likers =
+    usersList != null
+      ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15)
+      : [];
+  const seenUsers =
+    usersList != null
+      ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15)
+      : [];
 
   const subscribeClickhandler = useCallback(async () => {
     if (isGuestUser) {
@@ -198,7 +227,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
-      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
+      logger.warn(
+        'Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null',
+      );
       return;
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -208,7 +239,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickRenameMenuItem == null || pageId == null || path == null) {
-      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
+      logger.warn(
+        'Cannot rename the page because onClickRenameMenuItem, pageId or path is null',
+      );
       return;
     }
 
@@ -226,7 +259,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
     if (onClickDeleteMenuItem == null || pageId == null || path == null) {
-      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
+      logger.warn(
+        'Cannot delete the page because onClickDeleteMenuItem, pageId or path is null',
+      );
       return;
     }
 
@@ -249,7 +284,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
 
     if (onClickSwitchContentWidth == null || pageId == null) {
-      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
+      logger.warn(
+        'Cannot switch content width because onClickSwitchContentWidth or pageId is null',
+      );
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -260,11 +297,17 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     try {
       const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
-  }, [expandContentWidth, isGuestUser, isReadOnlyUser, onClickSwitchContentWidth, pageId, pageInfo]);
+  }, [
+    expandContentWidth,
+    isGuestUser,
+    isReadOnlyUser,
+    onClickSwitchContentWidth,
+    pageId,
+    pageInfo,
+  ]);
 
   const isEnableActions = useMemo(() => {
     if (isGuestUser) {
@@ -275,12 +318,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       return false;
     }
 
-    if (isUsersHomepage && !isUsersHomepageDeletionEnabled) {
-      return false;
-    }
-
     return true;
-  }, [currentPagePath, isGuestUser, isUsersHomepage, isUsersHomepageDeletionEnabled]);
+  }, [currentPagePath, isGuestUser]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {
@@ -291,10 +330,21 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     }
 
     const wideviewMenuItemRenderer = (props: WideViewMenuItemProps) => {
-      return <WideViewMenuItem {...props} onClick={switchContentWidthClickHandler} expandContentWidth={expandContentWidth} />;
+      return (
+        <WideViewMenuItem
+          {...props}
+          onClick={switchContentWidthClickHandler}
+          expandContentWidth={expandContentWidth}
+        />
+      );
     };
     return wideviewMenuItemRenderer;
-  }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
+  }, [
+    pageInfo,
+    expandContentWidth,
+    onClickSwitchContentWidth,
+    switchContentWidthClickHandler,
+  ]);
 
   const forceHideMenuItemsWithAdditions = [
     ...(forceHideMenuItems ?? []),
@@ -305,7 +355,10 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const isViewMode = editorMode === EditorMode.View;
 
   return (
-    <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
+    <div
+      className={`${styles['grw-page-controls']} hstack gap-2`}
+      ref={pageControlsRef}
+    >
       {isViewMode && isDeviceLargerThanMd && !isSearchPage && !isSearchPage && (
         <>
           <SearchButton />
@@ -314,9 +367,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       )}
 
       {revisionId != null && !isViewMode && (
-        <Tags
-          onClickEditTagsButton={onClickEditTagsButton}
-        />
+        <Tags onClickEditTagsButton={onClickEditTagsButton} />
       )}
 
       {!hideSubControls && (
@@ -335,13 +386,15 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
               likers={likers}
             />
           )}
-          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
-            <BookmarkButtons
-              pageId={pageId}
-              isBookmarked={pageInfo.isBookmarked}
-              bookmarkCount={pageInfo.bookmarkCount}
-            />
-          )}
+          {(isIPageInfoForOperation(pageInfo) ||
+            isIPageInfoForEmpty(pageInfo)) &&
+            pageId != null && (
+              <BookmarkButtons
+                pageId={pageId}
+                isBookmarked={pageInfo.isBookmarked}
+                bookmarkCount={pageInfo.bookmarkCount}
+              />
+            )}
           {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
@@ -359,7 +412,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
           isEnableActions={isEnableActions}
           isReadOnlyUser={!!isReadOnlyUser}
           forceHideMenuItems={forceHideMenuItemsWithAdditions}
-          additionalMenuItemOnTopRenderer={!isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined}
+          additionalMenuItemOnTopRenderer={
+            !isReadOnlyUser ? additionalMenuItemOnTopRenderer : undefined
+          }
           additionalMenuItemRenderer={additionalMenuItemRenderer}
           onClickRenameMenuItem={renameMenuItemClickHandler}
           onClickDuplicateMenuItem={duplicateMenuItemClickHandler}
@@ -373,12 +428,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 type PageControlsProps = CommonProps;
 
 export const PageControls = memo((props: PageControlsProps): JSX.Element => {
-  const {
-    pageId, revisionId, shareLinkId,
-    ...rest
-  } = props;
+  const { pageId, revisionId, shareLinkId, ...rest } = props;
 
-  const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
+  const { data: pageInfo, error } = useSWRxPageInfo(
+    pageId ?? null,
+    shareLinkId,
+  );
   const { data: tagsInfoData } = useSWRxTagsInfo(pageId);
   const { open: openTagEditModal } = useTagEditModalActions();
 

+ 1 - 4
apps/app/src/client/components/PageControls/SearchButton.tsx

@@ -1,19 +1,16 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { type JSX, useCallback } from 'react';
 
 import { useSearchModalActions } from '~/features/search/client/states/modal/search';
 
 import styles from './SearchButton.module.scss';
 
-
 const SearchButton = (): JSX.Element => {
-
   const { open: openSearchModal } = useSearchModalActions();
 
   const searchButtonClickHandler = useCallback(() => {
     openSearchModal();
   }, [openSearchModal]);
 
-
   return (
     <button
       type="button"

+ 28 - 12
apps/app/src/client/components/PageControls/SeenUserInfo.tsx

@@ -1,21 +1,18 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
-
 import type { IUser } from '@growi/core';
 import { useTranslation } from 'next-i18next';
-import { UncontrolledTooltip, Popover, PopoverBody } from 'reactstrap';
+import { Popover, PopoverBody, UncontrolledTooltip } from 'reactstrap';
 
 import UserPictureList from '../Common/UserPictureList';
 
-
 import styles from './SeenUserInfo.module.scss';
 import popoverStyles from './user-list-popover.module.scss';
 
-
 interface Props {
-  seenUsers: IUser[],
-  sumOfSeenUsers?: number,
-  disabled?: boolean,
+  seenUsers: IUser[];
+  sumOfSeenUsers?: number;
+  disabled?: boolean;
 }
 
 const SeenUserInfo: FC<Props> = (props: Props) => {
@@ -28,18 +25,37 @@ const SeenUserInfo: FC<Props> = (props: Props) => {
 
   return (
     <div className={`grw-seen-user-info ${styles['grw-seen-user-info']}`}>
-      <button type="button" id="btn-seen-user" className="shadow-none btn btn-seen-user border-0 d-flex align-items-center">
+      <button
+        type="button"
+        id="btn-seen-user"
+        className="shadow-none btn btn-seen-user border-0 d-flex align-items-center"
+      >
         <span className="material-symbols-outlined me-1">footprint</span>
-        <span className="total-counts">{sumOfSeenUsers || seenUsers.length}</span>
+        <span className="total-counts">
+          {sumOfSeenUsers || seenUsers.length}
+        </span>
       </button>
-      <Popover placement="bottom" isOpen={isPopoverOpen} target="btn-seen-user" toggle={togglePopover} trigger="legacy" disabled={disabled}>
-        <PopoverBody className={`user-list-popover ${popoverStyles['user-list-popover']}`}>
+      <Popover
+        placement="bottom"
+        isOpen={isPopoverOpen}
+        target="btn-seen-user"
+        toggle={togglePopover}
+        trigger="legacy"
+        disabled={disabled}
+      >
+        <PopoverBody
+          className={`user-list-popover ${popoverStyles['user-list-popover']}`}
+        >
           <div className="px-2 text-end user-list-content text-truncate text-muted">
             <UserPictureList users={seenUsers} />
           </div>
         </PopoverBody>
       </Popover>
-      <UncontrolledTooltip data-testid="seen-user-info-tooltip" target="btn-seen-user" fade={false}>
+      <UncontrolledTooltip
+        data-testid="seen-user-info-tooltip"
+        target="btn-seen-user"
+        fade={false}
+      >
         {t('tooltip.footprints')}
       </UncontrolledTooltip>
     </div>

+ 11 - 9
apps/app/src/client/components/PageControls/SubscribeButton.tsx

@@ -1,17 +1,15 @@
 import type { FC } from 'react';
 import React, { useCallback } from 'react';
-
 import { SubscriptionStatusType } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import styles from './SubscribeButton.module.scss';
 
-
 type Props = {
-  isGuestUser?: boolean,
-  status?: SubscriptionStatusType,
-  onClick?: () => Promise<void>,
+  isGuestUser?: boolean;
+  status?: SubscriptionStatusType;
+  onClick?: () => Promise<void>;
 };
 
 const SubscribeButton: FC<Props> = (props: Props) => {
@@ -21,7 +19,6 @@ const SubscribeButton: FC<Props> = (props: Props) => {
   const isSubscribing = status === SubscriptionStatusType.SUBSCRIBE;
 
   const getTooltipMessage = useCallback(() => {
-
     if (isSubscribing) {
       return 'tooltip.stop_notification';
     }
@@ -37,17 +34,22 @@ const SubscribeButton: FC<Props> = (props: Props) => {
         className={`shadow-none btn btn-subscribe ${styles['btn-subscribe']} border-0
           ${isSubscribing ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}
       >
-        <span className={`material-symbols-outlined ${isSubscribing ? 'fill' : ''}`}>
+        <span
+          className={`material-symbols-outlined ${isSubscribing ? 'fill' : ''}`}
+        >
           {isSubscribing ? 'notifications' : 'notifications_off'}
         </span>
       </button>
 
-      <UncontrolledTooltip data-testid="subscribe-button-tooltip" target="subscribe-button" fade={false}>
+      <UncontrolledTooltip
+        data-testid="subscribe-button-tooltip"
+        target="subscribe-button"
+        fade={false}
+      >
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
     </>
   );
-
 };
 
 export default SubscribeButton;

+ 23 - 15
apps/app/src/client/components/PageEditor/Cheatsheet.tsx

@@ -1,7 +1,6 @@
 /* eslint-disable max-len */
 
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
@@ -10,8 +9,8 @@ export const Cheatsheet = (): JSX.Element => {
   const { t } = useTranslation();
 
   /*
-  * Each Element
-  */
+   * Each Element
+   */
   // Left Side
   const codeStr = `# ${t('sandbox.header_x', { index: '1' })}\n## ${t('sandbox.header_x', { index: '2' })}\n### ${t('sandbox.header_x', { index: '3' })}`;
   const codeBlockStr = 'text\n\ntext';
@@ -28,10 +27,10 @@ export const Cheatsheet = (): JSX.Element => {
   const taskStr = `- [ ] ${t('sandbox.task')}(${t('sandbox.task_unchecked')})\n- [x] ${t('sandbox.task')}(${t('sandbox.task_checked')})`;
   const quoteStr = `> ${t('sandbox.quote1')}\n> ${t('sandbox.quote2')}`;
   const nestedQuoteStr = `>> ${t('sandbox.quote_nested')}\n>>> ${t('sandbox.quote_nested')}\n>>>> ${t('sandbox.quote_nested')}`;
-  const tableStr = '|Left       |    Mid    |      Right|\n|:----------|:---------:|----------:|\n|col 1      |   col 2   |      col 3|\n|col 1      |   col 2   |      col 3|';
+  const tableStr =
+    '|Left       |    Mid    |      Right|\n|:----------|:---------:|----------:|\n|col 1      |   col 2   |      col 3|\n|col 1      |   col 2   |      col 3|';
   const imageStr = '![ex](https://example.com/image.png)';
 
-
   const renderCheetSheetElm = (CheetSheetElm: string) => {
     return (
       <PrismAsyncLight
@@ -45,26 +44,28 @@ export const Cheatsheet = (): JSX.Element => {
     );
   };
 
-
   return (
     <div className="row small">
       <div className="col-sm-6">
-
         {/* Header */}
         <h4>{t('sandbox.header')}</h4>
         {renderCheetSheetElm(codeStr)}
 
         {/* Block */}
         <h4>{t('sandbox.block')}</h4>
-        <p className="mb-1"><code>[{t('sandbox.empty_line')}]</code>{t('sandbox.block_detail')}</p>
+        <p className="mb-1">
+          <code>[{t('sandbox.empty_line')}]</code>
+          {t('sandbox.block_detail')}
+        </p>
         {renderCheetSheetElm(codeBlockStr)}
 
         {/* Line Break */}
         <h4>{t('sandbox.line_break')}</h4>
-        <p className="mb-1"><code>[ ][ ]</code> {t('sandbox.line_break_detail')}</p>
+        <p className="mb-1">
+          <code>[ ][ ]</code> {t('sandbox.line_break_detail')}
+        </p>
         {renderCheetSheetElm(lineBlockStr)}
 
-
         {/* Typography */}
         <h4>{t('sandbox.typography')}</h4>
         {renderCheetSheetElm(typographyStr)}
@@ -93,22 +94,29 @@ export const Cheatsheet = (): JSX.Element => {
 
         {renderCheetSheetElm(nestedQuoteStr)}
 
-
         {/* Table */}
         <h4>{t('sandbox.table')}</h4>
         {renderCheetSheetElm(tableStr)}
 
         {/* Image */}
         <h4>{t('sandbox.image')}</h4>
-        <p className="mb-1"><code> ![{t('sandbox.alt_text')}](URL)</code> {t('sandbox.insert_image')}</p>
+        <p className="mb-1">
+          <code> ![{t('sandbox.alt_text')}](URL)</code>{' '}
+          {t('sandbox.insert_image')}
+        </p>
         {renderCheetSheetElm(imageStr)}
 
         <hr />
-        <a href="/Sandbox" className="btn btn-info" target="_blank">
-          <span className="growi-custom-icons">external_link</span> {t('sandbox.open_sandbox')}
+        <a
+          href="/Sandbox"
+          className="btn btn-info"
+          target="_blank"
+          rel="noopener"
+        >
+          <span className="growi-custom-icons">external_link</span>{' '}
+          {t('sandbox.open_sandbox')}
         </a>
       </div>
     </div>
   );
-
 };

+ 131 - 73
apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx

@@ -1,7 +1,5 @@
-import React, {
-  useState, useEffect, useCallback, useMemo,
-} from 'react';
-
+import type React from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 import type { IUser } from '@growi/core';
 import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { CodeMirrorEditorDiff } from '@growi/editor/dist/client/components/diff/CodeMirrorEditorDiff';
@@ -10,10 +8,7 @@ import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/co
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
+import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 import { useCurrentUser } from '~/states/global';
 import {
@@ -22,58 +17,73 @@ import {
   useRemoteRevisionLastUpdatedAt,
   useRemoteRevisionLastUpdateUser,
 } from '~/states/page';
-import { useConflictDiffModalActions, useConflictDiffModalStatus } from '~/states/ui/modal/conflict-diff';
+import {
+  useConflictDiffModalActions,
+  useConflictDiffModalStatus,
+} from '~/states/ui/modal/conflict-diff';
 
 import styles from './ConflictDiffModal.module.scss';
 
 type IRevisionOnConflict = {
-  revisionBody: string
-  createdAt: Date
-  user: IUser
-}
+  revisionBody: string;
+  createdAt: Date;
+  user: IUser;
+};
 
 /**
  * ConflictDiffModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
  */
 type ConflictDiffModalSubstanceProps = {
-  request: IRevisionOnConflict
-  latest: IRevisionOnConflict
-  isModalExpanded: boolean
-  setIsModalExpanded: React.Dispatch<React.SetStateAction<boolean>>
+  request: IRevisionOnConflict;
+  latest: IRevisionOnConflict;
+  isModalExpanded: boolean;
+  setIsModalExpanded: React.Dispatch<React.SetStateAction<boolean>>;
 };
 
 const formatedDate = (date: Date): string => {
   return format(date, 'yyyy/MM/dd HH:mm:ss');
 };
 
-const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): React.JSX.Element => {
-  const {
-    request, latest, isModalExpanded, setIsModalExpanded,
-  } = props;
+const ConflictDiffModalSubstance = (
+  props: ConflictDiffModalSubstanceProps,
+): React.JSX.Element => {
+  const { request, latest, isModalExpanded, setIsModalExpanded } = props;
 
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
-  const [revisionSelectedToggler, setRevisionSelectedToggler] = useState<boolean>(false);
+  const [revisionSelectedToggler, setRevisionSelectedToggler] =
+    useState<boolean>(false);
 
   const { t } = useTranslation();
   const conflictDiffModalStatus = useConflictDiffModalStatus();
   const { close: closeConflictDiffModal } = useConflictDiffModalActions();
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.DIFF);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(
+    GlobalCodeMirrorEditorKey.DIFF,
+  );
 
   // Memoize formatted dates
-  const requestFormattedDate = useMemo(() => formatedDate(request.createdAt), [request.createdAt]);
-  const latestFormattedDate = useMemo(() => formatedDate(latest.createdAt), [latest.createdAt]);
+  const requestFormattedDate = useMemo(
+    () => formatedDate(request.createdAt),
+    [request.createdAt],
+  );
+  const latestFormattedDate = useMemo(
+    () => formatedDate(latest.createdAt),
+    [latest.createdAt],
+  );
 
-  const selectRevisionHandler = useCallback((selectedRevision: string) => {
-    setResolvedRevision(selectedRevision);
-    setRevisionSelectedToggler(prev => !prev);
+  const selectRevisionHandler = useCallback(
+    (selectedRevision: string) => {
+      setResolvedRevision(selectedRevision);
+      setRevisionSelectedToggler((prev) => !prev);
 
-    if (!isRevisionselected) {
-      setIsRevisionSelected(true);
-    }
-  }, [isRevisionselected]);
+      if (!isRevisionselected) {
+        setIsRevisionSelected(true);
+      }
+    },
+    [isRevisionselected],
+  );
 
-  const resolveConflictHandler = useCallback(async() => {
+  const resolveConflictHandler = useCallback(async () => {
     const newBody = codeMirrorEditor?.getDocString();
     if (newBody == null) {
       return;
@@ -82,56 +92,82 @@ const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): Rea
     await conflictDiffModalStatus?.onResolve?.(newBody);
   }, [codeMirrorEditor, conflictDiffModalStatus]);
 
+  // biome-ignore lint/correctness/useExhaustiveDependencies: ignore
   useEffect(() => {
     codeMirrorEditor?.initDoc(resolvedRevision);
     // Enable selecting the same revision after editing by including revisionSelectedToggler in the dependency array of useEffect
   }, [codeMirrorEditor, resolvedRevision, revisionSelectedToggler]);
 
-  const headerButtons = useMemo(() => (
-    <div className="d-flex align-items-center">
-      <button type="button" className="btn" onClick={() => setIsModalExpanded(prev => !prev)}>
-        <span className="material-symbols-outlined">{isModalExpanded ? 'close_fullscreen' : 'open_in_full'}</span>
-      </button>
-      <button type="button" className="btn" onClick={closeConflictDiffModal} aria-label="Close">
-        <span className="material-symbols-outlined">close</span>
-      </button>
-    </div>
-  ), [closeConflictDiffModal, isModalExpanded, setIsModalExpanded]);
+  const headerButtons = useMemo(
+    () => (
+      <div className="d-flex align-items-center">
+        <button
+          type="button"
+          className="btn"
+          onClick={() => setIsModalExpanded((prev) => !prev)}
+        >
+          <span className="material-symbols-outlined">
+            {isModalExpanded ? 'close_fullscreen' : 'open_in_full'}
+          </span>
+        </button>
+        <button
+          type="button"
+          className="btn"
+          onClick={closeConflictDiffModal}
+          aria-label="Close"
+        >
+          <span className="material-symbols-outlined">close</span>
+        </button>
+      </div>
+    ),
+    [closeConflictDiffModal, isModalExpanded, setIsModalExpanded],
+  );
 
   return (
     <>
-      <ModalHeader tag="h4" className="d-flex align-items-center" close={headerButtons}>
-        <span className="material-symbols-outlined me-1">error</span>{t('modal_resolve_conflict.resolve_conflict')}
+      <ModalHeader
+        tag="h4"
+        className="d-flex align-items-center"
+        close={headerButtons}
+      >
+        <span className="material-symbols-outlined me-1">error</span>
+        {t('modal_resolve_conflict.resolve_conflict')}
       </ModalHeader>
 
       <ModalBody className="mx-4 my-1">
         <div className="row">
           <div className="col-12 text-center mt-2 mb-4">
-            <h3 className="fw-bold text-muted">{t('modal_resolve_conflict.resolve_conflict_message')}</h3>
+            <h3 className="fw-bold text-muted">
+              {t('modal_resolve_conflict.resolve_conflict_message')}
+            </h3>
           </div>
 
           <div className="col-6">
-            <h4 className="fw-bold my-2 text-muted">{t('modal_resolve_conflict.requested_revision')}</h4>
+            <h4 className="fw-bold my-2 text-muted">
+              {t('modal_resolve_conflict.requested_revision')}
+            </h4>
             <div className="d-flex align-items-center my-3">
               <div>
                 <UserPicture user={request.user} size="lg" noLink noTooltip />
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {request.user.username}</p>
-                <p className="my-0">{ requestFormattedDate }</p>
+                <p className="my-0">{requestFormattedDate}</p>
               </div>
             </div>
           </div>
 
           <div className="col-6">
-            <h4 className="fw-bold my-2 text-muted">{t('modal_resolve_conflict.latest_revision')}</h4>
+            <h4 className="fw-bold my-2 text-muted">
+              {t('modal_resolve_conflict.latest_revision')}
+            </h4>
             <div className="d-flex align-items-center my-3">
               <div>
                 <UserPicture user={latest.user} size="lg" noLink noTooltip />
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {latest.user.username}</p>
-                <p className="my-0">{ latestFormattedDate }</p>
+                <p className="my-0">{latestFormattedDate}</p>
               </div>
             </div>
           </div>
@@ -146,10 +182,16 @@ const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): Rea
               <button
                 type="button"
                 className="btn btn-outline-primary"
-                onClick={() => { selectRevisionHandler(request.revisionBody) }}
+                onClick={() => {
+                  selectRevisionHandler(request.revisionBody);
+                }}
               >
-                <span className="material-symbols-outlined me-1">arrow_circle_down</span>
-                {t('modal_resolve_conflict.select_revision', { revision: 'mine' })}
+                <span className="material-symbols-outlined me-1">
+                  arrow_circle_down
+                </span>
+                {t('modal_resolve_conflict.select_revision', {
+                  revision: 'mine',
+                })}
               </button>
             </div>
           </div>
@@ -159,17 +201,25 @@ const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): Rea
               <button
                 type="button"
                 className="btn btn-outline-primary"
-                onClick={() => { selectRevisionHandler(latest.revisionBody) }}
+                onClick={() => {
+                  selectRevisionHandler(latest.revisionBody);
+                }}
               >
-                <span className="material-symbols-outlined me-1">arrow_circle_down</span>
-                {t('modal_resolve_conflict.select_revision', { revision: 'theirs' })}
+                <span className="material-symbols-outlined me-1">
+                  arrow_circle_down
+                </span>
+                {t('modal_resolve_conflict.select_revision', {
+                  revision: 'theirs',
+                })}
               </button>
             </div>
           </div>
 
           <div className="col-12">
             <div className="border border-dark">
-              <h4 className="fw-bold my-2 mx-2 text-muted">{t('modal_resolve_conflict.selected_editable_revision')}</h4>
+              <h4 className="fw-bold my-2 mx-2 text-muted">
+                {t('modal_resolve_conflict.selected_editable_revision')}
+              </h4>
               <CodeMirrorEditorDiff />
             </div>
           </div>
@@ -210,29 +260,37 @@ export const ConflictDiffModal = (): React.JSX.Element => {
   const remoteRevisionLastUpdateUser = useRemoteRevisionLastUpdateUser();
   const remoteRevisionLastUpdatedAt = useRemoteRevisionLastUpdatedAt();
 
-  const isRemotePageDataInappropriate = remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
+  const isRemotePageDataInappropriate =
+    remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
 
   const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
 
   // Check if all required data is available
-  const isDataReady = conflictDiffModalStatus?.isOpened
-    && currentUser != null
-    && currentPage != null
-    && !isRemotePageDataInappropriate;
+  const isDataReady =
+    conflictDiffModalStatus?.isOpened &&
+    currentUser != null &&
+    currentPage != null &&
+    !isRemotePageDataInappropriate;
 
   // Prepare data for Substance
   const currentTime: Date = new Date();
-  const request: IRevisionOnConflict | null = isDataReady ? {
-    revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
-    createdAt: currentTime,
-    user: currentUser,
-  } : null;
-
-  const latest: IRevisionOnConflict | null = isDataReady ? {
-    revisionBody: remoteRevisionBody,
-    createdAt: new Date(remoteRevisionLastUpdatedAt ?? currentTime.toString()),
-    user: remoteRevisionLastUpdateUser,
-  } : null;
+  const request: IRevisionOnConflict | null = isDataReady
+    ? {
+        revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
+        createdAt: currentTime,
+        user: currentUser,
+      }
+    : null;
+
+  const latest: IRevisionOnConflict | null = isDataReady
+    ? {
+        revisionBody: remoteRevisionBody,
+        createdAt: new Date(
+          remoteRevisionLastUpdatedAt ?? currentTime.toString(),
+        ),
+        user: remoteRevisionLastUpdateUser,
+      }
+    : null;
 
   return (
     <Modal

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

@@ -10,7 +10,10 @@ export const ConflictDiffModalLazyLoaded = (): JSX.Element => {
 
   const ConflictDiffModal = useLazyLoader<ConflictDiffModalProps>(
     'conflict-diff-modal',
-    () => import('./ConflictDiffModal').then(mod => ({ default: mod.ConflictDiffModal })),
+    () =>
+      import('./ConflictDiffModal').then((mod) => ({
+        default: mod.ConflictDiffModal,
+      })),
     status?.isOpened ?? false,
   );
 

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor