Przeglądaj źródła

Merge branch 'dev/7.4.x' into support/156162-176211/app-some-client-components-biome-1

Futa Arai 3 miesięcy temu
rodzic
commit
b9a69364e9
87 zmienionych plików z 2196 dodań i 1242 usunięć
  1. 1 0
      apps/app/.eslintrc.js
  2. 34 0
      apps/app/public/images/customize-settings/collapsed-dark.svg
  3. 4 1
      apps/app/public/images/customize-settings/collapsed-light.svg
  4. 0 31
      apps/app/public/images/customize-settings/drawer-dark.svg
  5. 3 4
      apps/app/public/static/locales/en_US/admin.json
  6. 3 4
      apps/app/public/static/locales/fr_FR/admin.json
  7. 3 4
      apps/app/public/static/locales/ja_JP/admin.json
  8. 3 4
      apps/app/public/static/locales/ko_KR/admin.json
  9. 3 4
      apps/app/public/static/locales/zh_CN/admin.json
  10. 23 0
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  11. 5 40
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  12. 1 0
      apps/app/src/client/components/LoginForm/LoginForm.tsx
  13. 1 11
      apps/app/src/client/components/PageControls/PageControls.tsx
  14. 55 49
      apps/app/src/client/components/Sidebar/AppTitle/AppTitle.tsx
  15. 2 6
      apps/app/src/client/components/Sidebar/Bookmarks.tsx
  16. 20 22
      apps/app/src/client/components/Sidebar/Bookmarks/BookmarkContents.tsx
  17. 14 6
      apps/app/src/client/components/Sidebar/Custom/CustomSidebar.tsx
  18. 15 7
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarNotFound.tsx
  19. 11 12
      apps/app/src/client/components/Sidebar/Custom/CustomSidebarSubstance.tsx
  20. 18 10
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotification.tsx
  21. 38 28
      apps/app/src/client/components/Sidebar/InAppNotification/InAppNotificationSubstance.tsx
  22. 51 39
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  23. 7 3
      apps/app/src/client/components/Sidebar/PageCreateButton/CreateButton.tsx
  24. 63 64
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendMenu.tsx
  25. 3 4
      apps/app/src/client/components/Sidebar/PageCreateButton/DropendToggle.tsx
  26. 20 14
      apps/app/src/client/components/Sidebar/PageCreateButton/Hexagon.tsx
  27. 22 12
      apps/app/src/client/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  28. 4 6
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-new-page.ts
  29. 12 15
      apps/app/src/client/components/Sidebar/PageCreateButton/hooks/use-create-todays-memo.tsx
  30. 5 6
      apps/app/src/client/components/Sidebar/PageTree/PageTree.tsx
  31. 133 115
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  32. 3 4
      apps/app/src/client/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  33. 4 9
      apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  34. 5 3
      apps/app/src/client/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx
  35. 92 62
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  36. 61 41
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  37. 15 7
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChanges.tsx
  38. 12 6
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesContentSkeleton.tsx
  39. 169 110
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  40. 4 0
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.module.scss
  41. 63 54
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.tsx
  42. 7 15
      apps/app/src/client/components/Sidebar/ResizableArea/ResizableAreaFallback.tsx
  43. 9 9
      apps/app/src/client/components/Sidebar/ResizableArea/props.d.ts
  44. 196 147
      apps/app/src/client/components/Sidebar/Sidebar.tsx
  45. 15 5
      apps/app/src/client/components/Sidebar/SidebarBrandLogo.tsx
  46. 9 4
      apps/app/src/client/components/Sidebar/SidebarContents.tsx
  47. 4 6
      apps/app/src/client/components/Sidebar/SidebarHead/SidebarHead.tsx
  48. 8 8
      apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx
  49. 6 3
      apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx
  50. 38 20
      apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx
  51. 52 34
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx
  52. 49 12
      apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx
  53. 25 15
      apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx
  54. 6 5
      apps/app/src/client/components/Sidebar/SidebarNav/SidebarNav.tsx
  55. 0 1
      apps/app/src/client/components/Sidebar/SidebarNav/SkeletonItem.tsx
  56. 12 5
      apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkeleton.tsx
  57. 3 2
      apps/app/src/client/components/Sidebar/Skeleton/TagContentSkeleton.tsx
  58. 29 27
      apps/app/src/client/components/Sidebar/Tag.tsx
  59. 10 0
      apps/app/src/client/services/AdminAppContainer.js
  60. 10 2
      apps/app/src/features/page-tree/components/ItemsTree.tsx
  61. 35 2
      apps/app/src/features/page-tree/hooks/_inner/use-tree-item-handlers.tsx
  62. 0 1
      apps/app/src/interfaces/sidebar-config.ts
  63. 4 1
      apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-8998.js
  64. 0 3
      apps/app/src/pages/basic-layout-page/get-server-side-props/sidebar-configurations.ts
  65. 5 0
      apps/app/src/server/models/user.js
  66. 7 0
      apps/app/src/server/routes/apiv3/app-settings/index.ts
  67. 4 18
      apps/app/src/server/routes/apiv3/customize-setting.js
  68. 1 1
      apps/app/src/server/routes/apiv3/forgot-password.js
  69. 28 3
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  70. 1 1
      apps/app/src/server/routes/apiv3/page/index.ts
  71. 1 1
      apps/app/src/server/routes/apiv3/page/update-page.ts
  72. 7 26
      apps/app/src/server/routes/apiv3/revisions.js
  73. 1 1
      apps/app/src/server/routes/attachment/api.js
  74. 4 4
      apps/app/src/server/routes/comment.js
  75. 4 2
      apps/app/src/server/routes/tag.js
  76. 5 5
      apps/app/src/server/service/config-manager/config-definition.ts
  77. 123 0
      apps/app/src/server/service/growi-bridge/index.spec.ts
  78. 9 5
      apps/app/src/server/service/growi-bridge/index.ts
  79. 20 8
      apps/app/src/server/service/page/index.ts
  80. 117 2
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts
  81. 87 1
      apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts
  82. 1 1
      apps/app/src/server/service/yjs/sync-ydoc.ts
  83. 1 1
      apps/app/src/server/service/yjs/yjs.ts
  84. 169 0
      apps/app/src/server/util/safe-path-utils.spec.ts
  85. 69 0
      apps/app/src/server/util/safe-path-utils.ts
  86. 0 17
      apps/app/src/stores/admin/sidebar-config.tsx
  87. 0 1
      biome.json

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

@@ -45,6 +45,7 @@ module.exports = {
     'src/client/components/Hotkeys/**',
     'src/client/components/Navbar/**',
     'src/client/components/PageHeader/**',
+    'src/client/components/Sidebar/**',
     'src/services/**',
     'src/states/**',
     'src/stores/**',

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

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

@@ -35,12 +35,14 @@ const AppSetting = (props) => {
       globalLang: adminAppContainer.state.globalLang || 'en-US',
       // Convert boolean to string for radio button value
       isEmailPublishedForNewUser: String(adminAppContainer.state.isEmailPublishedForNewUser ?? true),
+      isReadOnlyForNewUser: adminAppContainer.state.isReadOnlyForNewUser ?? false,
     });
   }, [
     adminAppContainer.state.title,
     adminAppContainer.state.confidential,
     adminAppContainer.state.globalLang,
     adminAppContainer.state.isEmailPublishedForNewUser,
+    adminAppContainer.state.isReadOnlyForNewUser,
     reset,
   ]);
 
@@ -55,6 +57,7 @@ const AppSetting = (props) => {
       // Convert string 'true'/'false' to boolean
       const isEmailPublished = data.isEmailPublishedForNewUser === 'true' || data.isEmailPublishedForNewUser === true;
       await adminAppContainer.changeIsEmailPublishedForNewUserShow(isEmailPublished);
+      await adminAppContainer.changeIsReadOnlyForNewUserShow(data.isReadOnlyForNewUser);
 
       await adminAppContainer.updateAppSettingHandler();
       toastSuccess(t('commons:toaster.update_successed', { target: t('commons:headers.app_settings') }));
@@ -160,6 +163,26 @@ 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} />
     </form>
   );

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

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

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

@@ -10,9 +10,7 @@ import {
 
   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';
 
@@ -23,7 +21,6 @@ import { toastError } from '~/client/util/toastr';
 import OpenDefaultAiAssistantButton from '~/features/openai/client/components/AiAssistant/OpenDefaultAiAssistantButton';
 import { useIsGuestUser, useIsReadOnlyUser, useIsSearchPage } from '~/states/context';
 import { useCurrentPagePath } from '~/states/page';
-import { isUsersHomepageDeletionEnabledAtom } from '~/states/server-configurations';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import {
   EditorMode, useEditorMode,
@@ -140,11 +137,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { editorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const isSearchPage = useIsSearchPage();
-  const isUsersHomepageDeletionEnabled = useAtomValue(isUsersHomepageDeletionEnabledAtom);
   const currentPagePath = useCurrentPagePath();
 
-  const isUsersHomepage = currentPagePath == null ? false : pagePathUtils.isUsersHomepage(currentPagePath);
-
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
@@ -275,12 +269,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
       return false;
     }
 
-    if (isUsersHomepage && !isUsersHomepageDeletionEnabled) {
-      return false;
-    }
-
     return true;
-  }, [currentPagePath, isGuestUser, isUsersHomepage, isUsersHomepageDeletionEnabled]);
+  }, [currentPagePath, isGuestUser]);
 
   const additionalMenuItemOnTopRenderer = useMemo(() => {
     if (!isIPageInfoForEntity(pageInfo)) {

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 5 - 3
apps/app/src/client/components/Sidebar/PageTreeItem/CreatingNewPageSpinner.tsx

@@ -1,9 +1,11 @@
 import type { JSX } from 'react';
-
 import { LoadingSpinner } from '@growi/ui/dist/components';
 
-
-export const CreatingNewPageSpinner = ({ show }: { show?: boolean }): JSX.Element => {
+export const CreatingNewPageSpinner = ({
+  show,
+}: {
+  show?: boolean;
+}): JSX.Element => {
   if (!show) {
     return <></>;
   }

+ 92 - 62
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -1,21 +1,21 @@
 import type { FC } from 'react';
 import { useCallback } from 'react';
-
-import path from 'path';
-
+import { useRouter } from 'next/router';
 import type { IPageToDeleteWithMeta } from '@growi/core/dist/interfaces';
 import { getIdStringForRef } from '@growi/core/dist/interfaces';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
+import path from 'path';
 
 import { toastSuccess } from '~/client/util/toastr';
 import type { TreeItemProps } from '~/features/page-tree';
 import {
-  usePageTreeInformationUpdate, usePageRename, usePageCreate,
+  usePageCreate,
+  usePageRename,
+  usePageTreeInformationUpdate,
   usePlaceholderRenameEffect,
 } from '~/features/page-tree';
-import { TreeNameInput, TreeItemLayout } from '~/features/page-tree/components';
+import { TreeItemLayout, TreeNameInput } from '~/features/page-tree/components';
 import type { IPageForItem } from '~/interfaces/page';
 import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
 import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
@@ -23,10 +23,9 @@ import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
 import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
 import { mutateAllPageInfo } from '~/stores/page';
-import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+import { mutatePageList, mutatePageTree } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
 
-
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
 import { usePageItemControl } from './use-page-item-control';
 
@@ -34,10 +33,8 @@ import styles from './PageTreeItem.module.scss';
 
 const moduleClass = styles['page-tree-item'] ?? '';
 
-
 export const pageTreeItemSize = 40; // in px
 
-
 export const PageTreeItem: FC<TreeItemProps> = ({
   item,
   targetPath,
@@ -59,52 +56,81 @@ export const PageTreeItem: FC<TreeItemProps> = ({
   const { open: openDeleteModal } = usePageDeleteModalActions();
   const { notifyUpdateItems } = usePageTreeInformationUpdate();
 
-  const onClickDuplicateMenuItem = useCallback((page: IPageForPageDuplicateModal) => {
-    const duplicatedHandler: OnDuplicatedFunction = (fromPath) => {
-      toastSuccess(t('duplicated_pages', { fromPath }));
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-
-      // Notify headless-tree update
-      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
-      notifyUpdateItems(parentIds);
-    };
-
-    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal, t, notifyUpdateItems, itemData.parent]);
-
-  const onClickDeleteMenuItem = useCallback((page: IPageToDeleteWithMeta) => {
-    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
-
-      if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
-      }
-      else {
-        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
-      }
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-      mutateAllPageInfo();
-
-      if (currentPagePath === pathOrPathsToDelete) {
-        fetchCurrentPage({ force: true });
-        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
-      }
-
-      // Notify headless-tree update
-      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
-      notifyUpdateItems(parentIds);
-    };
-
-    openDeleteModal([page], { onDeleted: onDeletedHandler });
-  }, [openDeleteModal, t, currentPagePath, fetchCurrentPage, router, itemData.parent, notifyUpdateItems]);
+  const onClickDuplicateMenuItem = useCallback(
+    (page: IPageForPageDuplicateModal) => {
+      const duplicatedHandler: OnDuplicatedFunction = (fromPath) => {
+        toastSuccess(t('duplicated_pages', { fromPath }));
+
+        mutatePageTree();
+        mutateSearching();
+        mutatePageList();
+
+        // Notify headless-tree update
+        const parentIds =
+          itemData.parent != null
+            ? [getIdStringForRef(itemData.parent)]
+            : undefined;
+        notifyUpdateItems(parentIds);
+      };
+
+      openDuplicateModal(page, { onDuplicated: duplicatedHandler });
+    },
+    [openDuplicateModal, t, notifyUpdateItems, itemData.parent],
+  );
+
+  const onClickDeleteMenuItem = useCallback(
+    (page: IPageToDeleteWithMeta) => {
+      const onDeletedHandler: OnDeletedFunction = (
+        pathOrPathsToDelete,
+        isRecursively,
+        isCompletely,
+      ) => {
+        if (typeof pathOrPathsToDelete !== 'string') {
+          return;
+        }
+
+        if (isCompletely) {
+          toastSuccess(
+            t('deleted_pages_completely', { path: pathOrPathsToDelete }),
+          );
+        } else {
+          toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
+        }
+
+        mutatePageTree();
+        mutateSearching();
+        mutatePageList();
+        mutateAllPageInfo();
+
+        if (currentPagePath === pathOrPathsToDelete) {
+          fetchCurrentPage({ force: true });
+          router.push(
+            isCompletely
+              ? path.dirname(pathOrPathsToDelete)
+              : `/trash${pathOrPathsToDelete}`,
+          );
+        }
+
+        // Notify headless-tree update
+        const parentIds =
+          itemData.parent != null
+            ? [getIdStringForRef(itemData.parent)]
+            : undefined;
+        notifyUpdateItems(parentIds);
+      };
+
+      openDeleteModal([page], { onDeleted: onDeletedHandler });
+    },
+    [
+      openDeleteModal,
+      t,
+      currentPagePath,
+      fetchCurrentPage,
+      router,
+      itemData.parent,
+      notifyUpdateItems,
+    ],
+  );
 
   const { Control } = usePageItemControl();
 
@@ -112,7 +138,8 @@ export const PageTreeItem: FC<TreeItemProps> = ({
   const { isRenaming } = usePageRename();
 
   // Page create feature
-  const { cancelCreating, CreateButton, isCreatingPlaceholder } = usePageCreate();
+  const { cancelCreating, CreateButton, isCreatingPlaceholder } =
+    usePageCreate();
 
   // Manage placeholder renaming mode (auto-start, track, and cancel on Esc)
   usePlaceholderRenameEffect({
@@ -120,12 +147,15 @@ export const PageTreeItem: FC<TreeItemProps> = ({
     onCancelCreate: cancelCreating,
   });
 
-  const itemSelectedHandler = useCallback((page: IPageForItem) => {
-    if (page.path == null || page._id == null) return;
+  const itemSelectedHandler = useCallback(
+    (page: IPageForItem) => {
+      if (page.path == null || page._id == null) return;
 
-    const link = pathUtils.returnPathForURL(page.path, page._id);
-    router.push(link);
-  }, [router]);
+      const link = pathUtils.returnPathForURL(page.path, page._id);
+      router.push(link);
+    },
+    [router],
+  );
 
   const itemSelectedByWheelClickHandler = useCallback((page: IPageForItem) => {
     if (page.path == null || page._id == null) return;

+ 61 - 41
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -1,13 +1,16 @@
 import type { FC } from 'react';
 import React, { useCallback } from 'react';
-
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { DropdownToggle } from 'reactstrap';
 
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
-import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
+import {
+  bookmark,
+  resumeRenameOperation,
+  unbookmark,
+} from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
@@ -15,33 +18,36 @@ import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
 
-
 type UsePageItemControl = {
-  Control: FC<TreeItemToolProps>,
-}
+  Control: FC<TreeItemToolProps>;
+};
 
 export const usePageItemControl = (): UsePageItemControl => {
   const { t } = useTranslation();
 
-
   const Control: FC<TreeItemToolProps> = (props) => {
     const {
       item,
       isEnableActions,
       isReadOnlyUser,
-      onClickDuplicateMenuItem, onClickDeleteMenuItem,
+      onClickDuplicateMenuItem,
+      onClickDeleteMenuItem,
     } = props;
     const page = item.getItemData();
 
-    const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+    const { trigger: mutateCurrentUserBookmarks } =
+      useSWRMUTxCurrentUserBookmarks();
     const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
 
-    const bookmarkMenuItemClickHandler = useCallback(async(_pageId: string, _newValue: boolean): Promise<void> => {
-      const bookmarkOperation = _newValue ? bookmark : unbookmark;
-      await bookmarkOperation(_pageId);
-      mutateCurrentUserBookmarks();
-      mutatePageInfo();
-    }, [mutateCurrentUserBookmarks, mutatePageInfo]);
+    const bookmarkMenuItemClickHandler = useCallback(
+      async (_pageId: string, _newValue: boolean): Promise<void> => {
+        const bookmarkOperation = _newValue ? bookmark : unbookmark;
+        await bookmarkOperation(_pageId);
+        mutateCurrentUserBookmarks();
+        mutatePageInfo();
+      },
+      [mutateCurrentUserBookmarks, mutatePageInfo],
+    );
 
     const duplicateMenuItemClickHandler = useCallback((): void => {
       if (onClickDuplicateMenuItem == null) {
@@ -64,33 +70,41 @@ export const usePageItemControl = (): UsePageItemControl => {
       item.startRenaming();
     }, [item]);
 
-    const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-      if (onClickDeleteMenuItem == null) {
-        return;
-      }
-
-      if (page._id == null || page.path == null) {
-        throw Error('_id and path must not be null.');
-      }
-
-      const pageToDelete: IPageToDeleteWithMeta = {
-        data: {
-          _id: page._id,
-          revision: page.revision != null ? getIdStringForRef(page.revision) : null,
-          path: page.path,
-        },
-        meta: pageInfo,
-      };
-
-      onClickDeleteMenuItem(pageToDelete);
-    }, [onClickDeleteMenuItem, page]);
+    const deleteMenuItemClickHandler = useCallback(
+      async (
+        _pageId: string,
+        pageInfo: IPageInfoExt | undefined,
+      ): Promise<void> => {
+        if (onClickDeleteMenuItem == null) {
+          return;
+        }
+
+        if (page._id == null || page.path == null) {
+          throw Error('_id and path must not be null.');
+        }
+
+        const pageToDelete: IPageToDeleteWithMeta = {
+          data: {
+            _id: page._id,
+            revision:
+              page.revision != null ? getIdStringForRef(page.revision) : null,
+            path: page.path,
+          },
+          meta: pageInfo,
+        };
+
+        onClickDeleteMenuItem(pageToDelete);
+      },
+      [onClickDeleteMenuItem, page],
+    );
 
-    const pathRecoveryMenuItemClickHandler = async(pageId: string): Promise<void> => {
+    const pathRecoveryMenuItemClickHandler = async (
+      pageId: string,
+    ): Promise<void> => {
       try {
         await resumeRenameOperation(pageId);
         toastSuccess(t('page_operation.paths_recovered'));
-      }
-      catch {
+      } catch {
         toastError(t('page_operation.path_recovery_failed'));
       }
     };
@@ -112,8 +126,16 @@ export const usePageItemControl = (): UsePageItemControl => {
             operationProcessData={page.processData}
           >
             {/* pass the color property to reactstrap dropdownToggle props. https://6-4-0--reactstrap.netlify.app/components/dropdowns/  */}
-            <DropdownToggle color="transparent" className="border-0 rounded btn-page-item-control p-0 mr-1">
-              <span id="option-button-in-page-tree" className="material-symbols-outlined p-1">more_vert</span>
+            <DropdownToggle
+              color="transparent"
+              className="border-0 rounded btn-page-item-control p-0 mr-1"
+            >
+              <span
+                id="option-button-in-page-tree"
+                className="material-symbols-outlined p-1"
+              >
+                more_vert
+              </span>
             </DropdownToggle>
           </PageItemControl>
         </div>
@@ -121,9 +143,7 @@ export const usePageItemControl = (): UsePageItemControl => {
     );
   };
 
-
   return {
     Control,
   };
-
 };

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

@@ -1,17 +1,20 @@
-import { Suspense, useState, type JSX } from 'react';
-
+import { type JSX, Suspense, useState } from 'react';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
 import RecentChangesContentSkeleton from './RecentChangesContentSkeleton';
 
-const RecentChangesHeader = dynamic(() => import('./RecentChangesSubstance').then(mod => mod.RecentChangesHeader), { ssr: false });
+const RecentChangesHeader = dynamic(
+  () =>
+    import('./RecentChangesSubstance').then((mod) => mod.RecentChangesHeader),
+  { ssr: false },
+);
 const RecentChangesContent = dynamic(
-  () => import('./RecentChangesSubstance').then(mod => mod.RecentChangesContent),
+  () =>
+    import('./RecentChangesSubstance').then((mod) => mod.RecentChangesContent),
   { ssr: false, loading: RecentChangesContentSkeleton },
 );
 
-
 export const RecentChanges = (): JSX.Element => {
   const { t } = useTranslation();
 
@@ -27,13 +30,18 @@ export const RecentChanges = (): JSX.Element => {
             isSmall={isSmall}
             onSizeChange={setIsSmall}
             isWipPageShown={isWipPageShown}
-            onWipPageShownChange={() => { setIsWipPageShown(!isWipPageShown) }}
+            onWipPageShownChange={() => {
+              setIsWipPageShown(!isWipPageShown);
+            }}
           />
         </Suspense>
       </div>
 
       <Suspense fallback={<RecentChangesContentSkeleton />}>
-        <RecentChangesContent isWipPageShown={isWipPageShown} isSmall={isSmall} />
+        <RecentChangesContent
+          isWipPageShown={isWipPageShown}
+          isSmall={isSmall}
+        />
       </Suspense>
     </div>
   );

+ 12 - 6
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesContentSkeleton.tsx

@@ -5,18 +5,25 @@ import { Skeleton } from '~/client/components/Skeleton';
 import styles from './RecentChangesSubstance.module.scss';
 
 const SkeletonItem = () => {
-
   const isSmall = window.localStorage.isRecentChangesSidebarSmall === 'true';
 
   return (
-    <li className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}>
+    <li
+      className={`list-group-item ${styles['list-group-item']} ${isSmall ? 'py-2' : 'py-3'} px-0`}
+    >
       <div className="d-flex w-100">
         <Skeleton additionalClass="rounded-circle picture" roundedPill />
         <div className="flex-grow-1 ms-2">
-          <Skeleton additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`} />
-          <Skeleton additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`} />
+          <Skeleton
+            additionalClass={`grw-recent-changes-skeleton-small ${styles['grw-recent-changes-skeleton-small']}`}
+          />
+          <Skeleton
+            additionalClass={`grw-recent-changes-skeleton-h5 ${styles['grw-recent-changes-skeleton-h5']} ${isSmall ? 'my-0' : 'my-2'}`}
+          />
           <div className="d-flex justify-content-end grw-recent-changes-item-lower pt-1">
-            <Skeleton additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`} />
+            <Skeleton
+              additionalClass={`grw-recent-changes-skeleton-date ${styles['grw-recent-changes-skeleton-date']}`}
+            />
           </div>
         </div>
       </div>
@@ -25,7 +32,6 @@ const SkeletonItem = () => {
 };
 
 const RecentChangesContentSkeleton = (): JSX.Element => {
-
   return (
     <div className="grw-recent-changes py-3">
       <ul className="list-group list-group-flush">

+ 169 - 110
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -1,10 +1,5 @@
-import React, {
-  memo, useCallback, useEffect, type JSX,
-} from 'react';
-
-import {
-  isPopulated, type IPageHasId,
-} from '@growi/core';
+import React, { type JSX, memo, useCallback, useEffect } from 'react';
+import { type IPageHasId, isPopulated } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'react-i18next';
@@ -28,17 +23,19 @@ const pageItemLowerClass = styles['grw-recent-changes-item-lower'];
 const logger = loggerFactory('growi:History');
 
 type PageItemLowerProps = {
-  page: IPageHasId,
-}
+  page: IPageHasId;
+};
 
 type PageItemProps = PageItemLowerProps & {
-  isSmall: boolean,
-  onClickTag?: (tagName: string) => void,
-}
+  isSmall: boolean;
+  onClickTag?: (tagName: string) => void;
+};
 
 const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
   return (
-    <div className={`${pageItemLowerClass} d-flex justify-content-between grw-recent-changes-item-lower`}>
+    <div
+      className={`${pageItemLowerClass} d-flex justify-content-between grw-recent-changes-item-lower`}
+    >
       <div className="d-flex align-items-center">
         <div className="">
           <span className="material-symbols-outlined p-0">footprint</span>
@@ -49,7 +46,10 @@ const PageItemLower = memo(({ page }: PageItemLowerProps): JSX.Element => {
           <span className="grw-list-counts ms-1">{page.commentCount}</span>
         </div>
       </div>
-      <div className="grw-formatted-distance-date mt-auto" data-vrt-blackout-datetime>
+      <div
+        className="grw-formatted-distance-date mt-auto"
+        data-vrt-blackout-datetime
+      >
         <FormattedDistanceDate id={page._id} date={page.updatedAt} />
       </div>
     </div>
@@ -61,104 +61,124 @@ type PageTagsProps = PageItemProps;
 const PageTags = memo((props: PageTagsProps): JSX.Element => {
   const { page, isSmall, onClickTag } = props;
 
-  if (isSmall || (page.tags.length === 0)) {
+  if (isSmall || page.tags.length === 0) {
     return <></>;
   }
 
   return (
     <>
-      { page.tags.map((tag) => {
+      {page.tags.map((tag) => {
         if (!isPopulated(tag)) {
           return <></>;
         }
         return (
-          <a
+          <button
             key={tag.name}
             type="button"
             className="grw-tag badge me-2"
             onClick={() => onClickTag?.(tag.name)}
           >
             {tag.name}
-          </a>
+          </button>
         );
-      }) }
+      })}
     </>
   );
 });
 PageTags.displayName = 'PageTags';
 
-const PageItem = memo(({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
-  const dPagePath = new DevidedPagePath(page.path, false, true);
-  const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
-  const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
-  const FormerLink = () => (
-    <div className={`${formerLinkClass} ${isSmall ? 'text-truncate small' : ''}`}>
-      <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
-    </div>
-  );
-
-  let locked;
-  if (page.grant !== 1) {
-    locked = <span className="material-symbols-outlined ms-2 fs-6">lock</span>;
-  }
-
-  const isTagElementsRendered = !(isSmall || (page.tags.length === 0));
-
-  return (
-    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
-      <div className="d-flex w-100">
-
-        <div>
-          <UserPicture user={page.lastUpdateUser} size="md" className="d-inline-block" />
-        </div>
+const PageItem = memo(
+  ({ page, isSmall, onClickTag }: PageItemProps): JSX.Element => {
+    const dPagePath = new DevidedPagePath(page.path, false, true);
+    const linkedPagePathFormer = new LinkedPagePath(dPagePath.former);
+    const linkedPagePathLatter = new LinkedPagePath(dPagePath.latter);
+    const formerLink = (
+      <div
+        className={`${formerLinkClass} ${isSmall ? 'text-truncate small' : ''}`}
+      >
+        <PagePathHierarchicalLink linkedPagePath={linkedPagePathFormer} />
+      </div>
+    );
 
-        <div className="flex-grow-1 ms-2">
-          <div className={`row ${isSmall ? 'gy-0' : 'gy-1'}`}>
+    let locked: JSX.Element | null = null;
+    if (page.grant !== 1) {
+      locked = (
+        <span className="material-symbols-outlined ms-2 fs-6">lock</span>
+      );
+    }
 
-            <div className="col-12">
-              { !dPagePath.isRoot && <FormerLink /> }
-            </div>
+    const isTagElementsRendered = !(isSmall || page.tags.length === 0);
+
+    return (
+      <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
+        <div className="d-flex w-100">
+          <div>
+            <UserPicture
+              user={page.lastUpdateUser}
+              size="md"
+              className="d-inline-block"
+            />
+          </div>
 
-            <h6 className={`col-12 d-flex align-items-center ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}>
-              <PagePathHierarchicalLink linkedPagePath={linkedPagePathLatter} basePath={dPagePath.isRoot ? undefined : dPagePath.former} />
-              { page.wip && (
-                <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">WIP</span>
-              ) }
-              {locked}
-            </h6>
+          <div className="flex-grow-1 ms-2">
+            <div className={`row ${isSmall ? 'gy-0' : 'gy-1'}`}>
+              <div className="col-12">{!dPagePath.isRoot && formerLink}</div>
+
+              <h6
+                className={`col-12 d-flex align-items-center ${isSmall ? 'mb-0 text-truncate' : 'mb-0'}`}
+              >
+                <PagePathHierarchicalLink
+                  linkedPagePath={linkedPagePathLatter}
+                  basePath={dPagePath.isRoot ? undefined : dPagePath.former}
+                />
+                {page.wip && (
+                  <span className="wip-page-badge badge rounded-pill text-bg-secondary ms-2">
+                    WIP
+                  </span>
+                )}
+                {locked}
+              </h6>
+
+              {isTagElementsRendered && (
+                <div className="col-12">
+                  <PageTags
+                    isSmall={isSmall}
+                    page={page}
+                    onClickTag={onClickTag}
+                  />
+                </div>
+              )}
 
-            { isTagElementsRendered && (
               <div className="col-12">
-                <PageTags isSmall={isSmall} page={page} onClickTag={onClickTag} />
+                <PageItemLower page={page} />
               </div>
-            ) }
-
-            <div className="col-12">
-              <PageItemLower page={page} />
             </div>
-
           </div>
         </div>
-      </div>
-    </li>
-  );
-});
+      </li>
+    );
+  },
+);
 PageItem.displayName = 'PageItem';
 
-
 type HeaderProps = {
-  isSmall: boolean,
-  onSizeChange: (isSmall: boolean) => void,
-  isWipPageShown: boolean,
-  onWipPageShownChange: () => void,
-}
+  isSmall: boolean;
+  onSizeChange: (isSmall: boolean) => void;
+  isWipPageShown: boolean;
+  onWipPageShownChange: () => void;
+};
 
 export const RecentChangesHeader = ({
-  isSmall, onSizeChange, isWipPageShown, onWipPageShownChange,
+  isSmall,
+  onSizeChange,
+  isWipPageShown,
+  onWipPageShownChange,
 }: HeaderProps): JSX.Element => {
   const { t } = useTranslation();
 
-  const { mutate } = useSWRINFxRecentlyUpdated(isWipPageShown, { suspense: true });
+  const { mutate } = useSWRINFxRecentlyUpdated(isWipPageShown, {
+    suspense: true,
+  });
 
   const retrieveSizePreferenceFromLocalStorage = useCallback(() => {
     if (window.localStorage.isRecentChangesSidebarSmall === 'true') {
@@ -193,33 +213,55 @@ export const RecentChangesHeader = ({
         </button>
 
         <ul className="dropdown-menu">
-          <li className="dropdown-item" onClick={changeSizeHandler}>
-            <div className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}>
-              <input
-                id="recentChangesResize"
-                className="form-check-input pe-none"
-                type="checkbox"
-                checked={isSmall}
-                onChange={() => {}}
-              />
-              <label className="form-check-label pe-none" aria-disabled="true">
-                {t('sidebar_header.compact_view')}
-              </label>
-            </div>
+          <li>
+            <button
+              type="button"
+              className="dropdown-item"
+              onClick={changeSizeHandler}
+            >
+              <div
+                className={`${styles['grw-recent-changes-resize-button']} form-check form-switch mb-0`}
+              >
+                <input
+                  id="recent-changes-resize-toggle"
+                  className="form-check-input pe-none"
+                  type="checkbox"
+                  checked={isSmall}
+                  onChange={() => {}}
+                />
+                <label
+                  className="form-check-label pe-none"
+                  htmlFor="recent-changes-resize-toggle"
+                  aria-disabled="true"
+                >
+                  {t('sidebar_header.compact_view')}
+                </label>
+              </div>
+            </button>
           </li>
 
-          <li className="dropdown-item" onClick={onWipPageShownChange}>
-            <div className="form-check form-switch mb-0">
-              <input
-                id="wipPageVisibility"
-                className="form-check-input"
-                type="checkbox"
-                checked={isWipPageShown}
-              />
-              <label className="form-check-label pe-none">
-                {t('sidebar_header.show_wip_page')}
-              </label>
-            </div>
+          <li>
+            <button
+              type="button"
+              className="dropdown-item"
+              onClick={onWipPageShownChange}
+            >
+              <div className="form-check form-switch mb-0">
+                <input
+                  id="recent-changes-wip-toggle"
+                  className="form-check-input"
+                  type="checkbox"
+                  checked={isWipPageShown}
+                  onChange={() => {}}
+                />
+                <label
+                  className="form-check-label pe-none"
+                  htmlFor="recent-changes-wip-toggle"
+                >
+                  {t('sidebar_header.show_wip_page')}
+                </label>
+              </div>
+            </button>
           </li>
         </ul>
       </div>
@@ -228,18 +270,29 @@ export const RecentChangesHeader = ({
 };
 
 type ContentProps = {
-  isSmall: boolean,
-  isWipPageShown: boolean,
-}
+  isSmall: boolean;
+  isWipPageShown: boolean;
+};
 
-export const RecentChangesContent = ({ isSmall, isWipPageShown }: ContentProps): JSX.Element => {
-  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(isWipPageShown, { suspense: true });
+export const RecentChangesContent = ({
+  isSmall,
+  isWipPageShown,
+}: ContentProps): JSX.Element => {
+  const swrInifinitexRecentlyUpdated = useSWRINFxRecentlyUpdated(
+    isWipPageShown,
+    { suspense: true },
+  );
   const { data } = swrInifinitexRecentlyUpdated;
 
   const setSearchKeyword = useSetSearchKeyword();
   const isEmpty = data?.[0]?.pages.length === 0;
   const lastPageIndex = data?.length ? data.length - 1 : 0;
-  const isReachingEnd = isEmpty || (data != null && lastPageIndex > 0 && data[lastPageIndex]?.pages.length < data[lastPageIndex - 1]?.pages.length);
+  const isReachingEnd =
+    isEmpty ||
+    (data != null &&
+      lastPageIndex > 0 &&
+      data[lastPageIndex]?.pages.length <
+        data[lastPageIndex - 1]?.pages.length);
   return (
     <div className="grw-recent-changes">
       <ul className="list-group list-group-flush">
@@ -247,11 +300,17 @@ export const RecentChangesContent = ({ isSmall, isWipPageShown }: ContentProps):
           swrInifiniteResponse={swrInifinitexRecentlyUpdated}
           isReachingEnd={isReachingEnd}
         >
-          { data != null && data.map(apiResult => apiResult.pages).flat()
-            .map(page => (
-              <PageItem key={page._id} page={page} isSmall={isSmall} onClickTag={tagName => setSearchKeyword(`tag:${tagName}`)} />
-            ))
-          }
+          {data != null &&
+            data
+              .flatMap((apiResult) => apiResult.pages)
+              .map((page) => (
+                <PageItem
+                  key={page._id}
+                  page={page}
+                  isSmall={isSmall}
+                  onClickTag={(tagName) => setSearchKeyword(`tag:${tagName}`)}
+                />
+              ))}
         </InfiniteScroll>
       </ul>
     </div>

+ 4 - 0
apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.module.scss

@@ -18,7 +18,11 @@
     left: -4px;
     width: 24px;
     height: 100%;
+    padding: 0;
+    appearance: none;
     cursor: ew-resize;
+    background: transparent;
+    border: 0;
   }
   .grw-navigation-draggable-line {
     position: absolute;

+ 63 - 54
apps/app/src/client/components/Sidebar/ResizableArea/ResizableArea.tsx

@@ -1,70 +1,77 @@
-import {
-  memo, useCallback, useRef, type JSX,
-} from 'react';
+import { type JSX, memo, useCallback, useRef } from 'react';
 
 import type { ResizableAreaProps } from './props';
 
 import styles from './ResizableArea.module.scss';
 
-
 export const ResizableArea = memo((props: ResizableAreaProps): JSX.Element => {
   const {
     className,
-    width, minWidth = 0,
-    disabled, children,
-    onResize, onResizeDone, onCollapsed,
+    width,
+    minWidth = 0,
+    disabled,
+    children,
+    onResize,
+    onResizeDone,
+    onCollapsed,
   } = props;
 
   const resizableContainer = useRef<HTMLDivElement>(null);
 
-  const draggableAreaMoveHandler = useCallback((event: MouseEvent) => {
-    event.preventDefault();
-
-    const widthByMousePos = event.pageX;
-
-    const newWidth = Math.max(widthByMousePos, minWidth);
-    onResize?.(newWidth);
-    resizableContainer.current?.classList.add('dragging');
-  }, [minWidth, onResize]);
-
-  const dragableAreaMouseUpHandler = useCallback((event: MouseEvent) => {
-    if (resizableContainer.current == null) {
-      return;
-    }
-
-    const widthByMousePos = event.pageX;
-
-    if (widthByMousePos < minWidth / 2) {
-      // force collapsed
-      onCollapsed?.();
-    }
-    else {
-      const newWidth = resizableContainer.current.clientWidth;
-      onResizeDone?.(newWidth);
-    }
+  const draggableAreaMoveHandler = useCallback(
+    (event: MouseEvent) => {
+      event.preventDefault();
 
-    resizableContainer.current.classList.remove('dragging');
+      const widthByMousePos = event.pageX;
 
-  }, [minWidth, onCollapsed, onResizeDone]);
-
-  const dragableAreaMouseDownHandler = useCallback((event: React.MouseEvent) => {
-    if (disabled) {
-      return;
-    }
-
-    event.preventDefault();
-
-    const removeEventListeners = () => {
-      document.removeEventListener('mousemove', draggableAreaMoveHandler);
-      document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
-      document.removeEventListener('mouseup', removeEventListeners);
-    };
+      const newWidth = Math.max(widthByMousePos, minWidth);
+      onResize?.(newWidth);
+      resizableContainer.current?.classList.add('dragging');
+    },
+    [minWidth, onResize],
+  );
 
-    document.addEventListener('mousemove', draggableAreaMoveHandler);
-    document.addEventListener('mouseup', dragableAreaMouseUpHandler);
-    document.addEventListener('mouseup', removeEventListeners);
+  const dragableAreaMouseUpHandler = useCallback(
+    (event: MouseEvent) => {
+      if (resizableContainer.current == null) {
+        return;
+      }
+
+      const widthByMousePos = event.pageX;
+
+      if (widthByMousePos < minWidth / 2) {
+        // force collapsed
+        onCollapsed?.();
+      } else {
+        const newWidth = resizableContainer.current.clientWidth;
+        onResizeDone?.(newWidth);
+      }
+
+      resizableContainer.current.classList.remove('dragging');
+    },
+    [minWidth, onCollapsed, onResizeDone],
+  );
 
-  }, [dragableAreaMouseUpHandler, draggableAreaMoveHandler, disabled]);
+  const dragableAreaMouseDownHandler = useCallback(
+    (event: React.MouseEvent) => {
+      if (disabled) {
+        return;
+      }
+
+      event.preventDefault();
+
+      const removeEventListeners = () => {
+        document.removeEventListener('mousemove', draggableAreaMoveHandler);
+        document.removeEventListener('mouseup', dragableAreaMouseUpHandler);
+        document.removeEventListener('mouseup', removeEventListeners);
+      };
+
+      document.addEventListener('mousemove', draggableAreaMoveHandler);
+      document.addEventListener('mouseup', dragableAreaMouseUpHandler);
+      document.addEventListener('mouseup', removeEventListeners);
+    },
+    [dragableAreaMouseUpHandler, draggableAreaMoveHandler, disabled],
+  );
 
   return (
     <>
@@ -76,15 +83,17 @@ export const ResizableArea = memo((props: ResizableAreaProps): JSX.Element => {
         {children}
       </div>
       <div className={styles['grw-navigation-draggable']}>
-        { !disabled && (
+        {!disabled && (
           <>
-            <div
+            <button
+              type="button"
               className="grw-navigation-draggable-hitarea"
+              aria-label="Resize sidebar"
               onMouseDown={dragableAreaMouseDownHandler}
             />
             <div className="grw-navigation-draggable-line"></div>
           </>
-        ) }
+        )}
       </div>
     </>
   );

+ 7 - 15
apps/app/src/client/components/Sidebar/ResizableArea/ResizableAreaFallback.tsx

@@ -1,24 +1,16 @@
-import { memo, type JSX } from 'react';
-
+import { type JSX, memo } from 'react';
 
 type Props = {
-  className?: string,
-  width?: number,
-  children?: React.ReactNode,
-}
+  className?: string;
+  width?: number;
+  children?: React.ReactNode;
+};
 
 export const ResizableAreaFallback = memo((props: Props): JSX.Element => {
-  const {
-    className = '',
-    width,
-    children,
-  } = props;
+  const { className = '', width, children } = props;
 
   return (
-    <div
-      className={className}
-      style={{ width }}
-    >
+    <div className={className} style={{ width }}>
       {children}
     </div>
   );

+ 9 - 9
apps/app/src/client/components/Sidebar/ResizableArea/props.d.ts

@@ -1,10 +1,10 @@
 export type ResizableAreaProps = {
-  className?: string,
-  width?: number,
-  minWidth?: number,
-  disabled?: boolean,
-  children?: React.ReactNode,
-  onResize?: (newWidth: number) => void,
-  onResizeDone?: (newWidth: number) => void,
-  onCollapsed?: () => void,
-}
+  className?: string;
+  width?: number;
+  minWidth?: number;
+  disabled?: boolean;
+  children?: React.ReactNode;
+  onResize?: (newWidth: number) => void;
+  onResizeDone?: (newWidth: number) => void;
+  onCollapsed?: () => void;
+};

+ 196 - 147
apps/app/src/client/components/Sidebar/Sidebar.tsx

@@ -1,55 +1,73 @@
 import {
-  type FC, memo, useCallback, useEffect, useState, useRef, type JSX,
+  type FC,
+  type JSX,
+  memo,
+  useCallback,
+  useEffect,
+  useRef,
+  useState,
 } from 'react';
-
-import withLoadingProps from 'next-dynamic-loading-props';
 import dynamic from 'next/dynamic';
+import withLoadingProps from 'next-dynamic-loading-props';
 import SimpleBar from 'simplebar-react';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsSearchPage } from '~/states/context';
-import { useDeviceLargerThanXl, useDeviceLargerThanMd } from '~/states/ui/device';
+import {
+  useDeviceLargerThanMd,
+  useDeviceLargerThanXl,
+} from '~/states/ui/device';
 import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import {
-  useDrawerOpened,
-  useSetPreferCollapsedMode,
-  useSidebarMode,
   useCollapsedContentsOpened,
   useCurrentProductNavWidth,
+  useDrawerOpened,
+  useSetPreferCollapsedMode,
   useSetSidebarScrollerRef,
+  useSidebarMode,
 } from '~/states/ui/sidebar';
 
 import { DrawerToggler } from '../Common/DrawerToggler';
-
-import { AppTitleOnSidebarHead, AppTitleOnEditorSidebarHead, AppTitleOnSubnavigation } from './AppTitle/AppTitle';
-import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
+import {
+  AppTitleOnEditorSidebarHead,
+  AppTitleOnSidebarHead,
+  AppTitleOnSubnavigation,
+} from './AppTitle/AppTitle';
 import type { ResizableAreaProps } from './ResizableArea/props';
+import { ResizableAreaFallback } from './ResizableArea/ResizableAreaFallback';
 import { SidebarHead } from './SidebarHead';
 import { SidebarNav, type SidebarNavProps } from './SidebarNav';
 
 import 'simplebar-react/dist/simplebar.min.css';
-import styles from './Sidebar.module.scss';
 
+import styles from './Sidebar.module.scss';
 
-const SidebarContents = dynamic(() => import('./SidebarContents').then(mod => mod.SidebarContents), { ssr: false });
-const ResizableArea = withLoadingProps<ResizableAreaProps>(useLoadingProps => dynamic(
-  () => import('./ResizableArea').then(mod => mod.ResizableArea),
-  {
+const SidebarContents = dynamic(
+  () => import('./SidebarContents').then((mod) => mod.SidebarContents),
+  { ssr: false },
+);
+const ResizableArea = withLoadingProps<ResizableAreaProps>((useLoadingProps) =>
+  dynamic(() => import('./ResizableArea').then((mod) => mod.ResizableArea), {
     ssr: false,
     loading: () => {
       // eslint-disable-next-line react-hooks/rules-of-hooks
       const { children, ...rest } = useLoadingProps();
-      return <ResizableAreaFallback {...rest}>{children}</ResizableAreaFallback>;
+      return (
+        <ResizableAreaFallback {...rest}>{children}</ResizableAreaFallback>
+      );
     },
-  },
-));
-
+  }),
+);
 
 const resizableAreaMinWidth = 348;
 const sidebarNavCollapsedWidth = 48;
 
-const getWidthByMode = (isDrawerMode: boolean, isCollapsedMode: boolean, currentProductNavWidth: number | undefined): number | undefined => {
+const getWidthByMode = (
+  isDrawerMode: boolean,
+  isCollapsedMode: boolean,
+  currentProductNavWidth: number | undefined,
+): number | undefined => {
   if (isDrawerMode) {
     return undefined;
   }
@@ -59,59 +77,73 @@ const getWidthByMode = (isDrawerMode: boolean, isCollapsedMode: boolean, current
   return currentProductNavWidth;
 };
 
-
 type ResizableContainerProps = {
-  children?: React.ReactNode,
-}
-
-const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element => {
-
-  const { children } = props;
-
-  const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
-  const [, setIsDrawerOpened] = useDrawerOpened();
-  const [currentProductNavWidth, setCurrentProductNavWidth] = useCurrentProductNavWidth();
-  const setPreferCollapsedMode = useSetPreferCollapsedMode();
-  const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
-
-  const [isClient, setClient] = useState(false);
-  const [resizableAreaWidth, setResizableAreaWidth] = useState<number | undefined>(
-    getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth),
-  );
-
-  const resizeHandler = useCallback((newWidth: number) => {
-    setResizableAreaWidth(newWidth);
-  }, []);
-
-  const resizeDoneHandler = useCallback((newWidth: number) => {
-    setCurrentProductNavWidth(newWidth);
-  }, [setCurrentProductNavWidth]);
+  children?: React.ReactNode;
+};
 
-  const collapsedByResizableAreaHandler = useCallback(() => {
-    setPreferCollapsedMode(true);
-    setCollapsedContentsOpened(false);
-  }, [setCollapsedContentsOpened, setPreferCollapsedMode]);
+const ResizableContainer = memo(
+  (props: ResizableContainerProps): JSX.Element => {
+    const { children } = props;
+
+    const { isDrawerMode, isCollapsedMode, isDockMode } = useSidebarMode();
+    const [, setIsDrawerOpened] = useDrawerOpened();
+    const [currentProductNavWidth, setCurrentProductNavWidth] =
+      useCurrentProductNavWidth();
+    const setPreferCollapsedMode = useSetPreferCollapsedMode();
+    const [, setCollapsedContentsOpened] = useCollapsedContentsOpened();
+
+    const [isClient, setClient] = useState(false);
+    const [resizableAreaWidth, setResizableAreaWidth] = useState<
+      number | undefined
+    >(
+      getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth),
+    );
 
-  useIsomorphicLayoutEffect(() => {
-    setClient(true);
-  }, []);
+    const resizeHandler = useCallback((newWidth: number) => {
+      setResizableAreaWidth(newWidth);
+    }, []);
 
-  // open/close resizable container when drawer mode
-  useEffect(() => {
-    setResizableAreaWidth(getWidthByMode(isDrawerMode(), isCollapsedMode(), currentProductNavWidth));
-    setIsDrawerOpened(false);
-  }, [currentProductNavWidth, isCollapsedMode, isDrawerMode, setIsDrawerOpened]);
+    const resizeDoneHandler = useCallback(
+      (newWidth: number) => {
+        setCurrentProductNavWidth(newWidth);
+      },
+      [setCurrentProductNavWidth],
+    );
 
-  return !isClient
-    ? (
+    const collapsedByResizableAreaHandler = useCallback(() => {
+      setPreferCollapsedMode(true);
+      setCollapsedContentsOpened(false);
+    }, [setCollapsedContentsOpened, setPreferCollapsedMode]);
+
+    useIsomorphicLayoutEffect(() => {
+      setClient(true);
+    }, []);
+
+    // open/close resizable container when drawer mode
+    useEffect(() => {
+      setResizableAreaWidth(
+        getWidthByMode(
+          isDrawerMode(),
+          isCollapsedMode(),
+          currentProductNavWidth,
+        ),
+      );
+      setIsDrawerOpened(false);
+    }, [
+      currentProductNavWidth,
+      isCollapsedMode,
+      isDrawerMode,
+      setIsDrawerOpened,
+    ]);
+
+    return !isClient ? (
       <ResizableAreaFallback
         className="flex-expand-vert"
         width={resizableAreaWidth}
       >
         {children}
       </ResizableAreaFallback>
-    )
-    : (
+    ) : (
       <ResizableArea
         className="flex-expand-vert"
         width={resizableAreaWidth}
@@ -124,89 +156,97 @@ const ResizableContainer = memo((props: ResizableContainerProps): JSX.Element =>
         {children}
       </ResizableArea>
     );
-
-});
-
+  },
+);
 
 type CollapsibleContainerProps = {
-  Nav: FC<SidebarNavProps>,
-  className?: string,
-  children?: React.ReactNode,
-}
-
-const CollapsibleContainer = memo((props: CollapsibleContainerProps): JSX.Element => {
-
-  const { Nav, className, children } = props;
-
-  const { isCollapsedMode } = useSidebarMode();
-  const [currentProductNavWidth] = useCurrentProductNavWidth();
-  const [isCollapsedContentsOpened, setCollapsedContentsOpened] = useCollapsedContentsOpened();
-
-  const sidebarScrollerRef = useRef<HTMLDivElement>(null);
-  const setSidebarScrollerRef = useSetSidebarScrollerRef();
-
-  // Set the ref once on mount
-  useEffect(() => {
-    setSidebarScrollerRef(sidebarScrollerRef);
-  }, [setSidebarScrollerRef]);
-
-
-  // open menu when collapsed mode
-  const primaryItemHoverHandler = useCallback(() => {
-    // reject other than collapsed mode
-    if (!isCollapsedMode()) {
-      return;
-    }
-
-    setCollapsedContentsOpened(true);
-  }, [isCollapsedMode, setCollapsedContentsOpened]);
-
-  // close menu when collapsed mode
-  const mouseLeaveHandler = useCallback(() => {
-    // reject other than collapsed mode
-    if (!isCollapsedMode()) {
-      return;
-    }
-
-    setCollapsedContentsOpened(false);
-  }, [isCollapsedMode, setCollapsedContentsOpened]);
-
-  const closedClass = isCollapsedMode() && !isCollapsedContentsOpened ? 'd-none' : '';
-  const openedClass = isCollapsedMode() && isCollapsedContentsOpened ? 'open' : '';
-  const collapsibleContentsWidth = isCollapsedMode() ? currentProductNavWidth : undefined;
+  Nav: FC<SidebarNavProps>;
+  className?: string;
+  children?: React.ReactNode;
+};
 
-  return (
-    <div className={`flex-expand-horiz ${className}`} onMouseLeave={mouseLeaveHandler}>
-      <Nav onPrimaryItemHover={primaryItemHoverHandler} />
-      <div
-        className={`sidebar-contents-container flex-grow-1 overflow-hidden ${closedClass} ${openedClass}`}
+const CollapsibleContainer = memo(
+  (props: CollapsibleContainerProps): JSX.Element => {
+    const { Nav, className, children } = props;
+
+    const { isCollapsedMode } = useSidebarMode();
+    const [currentProductNavWidth] = useCurrentProductNavWidth();
+    const [isCollapsedContentsOpened, setCollapsedContentsOpened] =
+      useCollapsedContentsOpened();
+
+    const sidebarScrollerRef = useRef<HTMLDivElement>(null);
+    const setSidebarScrollerRef = useSetSidebarScrollerRef();
+
+    // Set the ref once on mount
+    useEffect(() => {
+      setSidebarScrollerRef(sidebarScrollerRef);
+    }, [setSidebarScrollerRef]);
+
+    // open menu when collapsed mode
+    const primaryItemHoverHandler = useCallback(() => {
+      // reject other than collapsed mode
+      if (!isCollapsedMode()) {
+        return;
+      }
+
+      setCollapsedContentsOpened(true);
+    }, [isCollapsedMode, setCollapsedContentsOpened]);
+
+    // close menu when collapsed mode
+    const mouseLeaveHandler = useCallback(() => {
+      // reject other than collapsed mode
+      if (!isCollapsedMode()) {
+        return;
+      }
+
+      setCollapsedContentsOpened(false);
+    }, [isCollapsedMode, setCollapsedContentsOpened]);
+
+    const closedClass =
+      isCollapsedMode() && !isCollapsedContentsOpened ? 'd-none' : '';
+    const openedClass =
+      isCollapsedMode() && isCollapsedContentsOpened ? 'open' : '';
+    const collapsibleContentsWidth = isCollapsedMode()
+      ? currentProductNavWidth
+      : undefined;
+
+    return (
+      <fieldset
+        className={`flex-expand-horiz border-0 p-0 m-0 ${className}`}
+        onMouseLeave={mouseLeaveHandler}
       >
-        <SimpleBar
-          scrollableNodeProps={{ ref: sidebarScrollerRef }}
-          className="simple-scrollbar h-100"
-          style={{ width: collapsibleContentsWidth }}
-          autoHide
+        <Nav onPrimaryItemHover={primaryItemHoverHandler} />
+        <div
+          className={`sidebar-contents-container flex-grow-1 overflow-hidden ${closedClass} ${openedClass}`}
         >
-          {children}
-        </SimpleBar>
-      </div>
-    </div>
-  );
-
-});
+          <SimpleBar
+            scrollableNodeProps={{ ref: sidebarScrollerRef }}
+            className="simple-scrollbar h-100"
+            style={{ width: collapsibleContentsWidth }}
+            autoHide
+          >
+            {children}
+          </SimpleBar>
+        </div>
+      </fieldset>
+    );
+  },
+);
 
 // for data-* attributes
 type HTMLElementProps = JSX.IntrinsicElements &
-  Record<keyof JSX.IntrinsicElements, { [p: `data-${string}`]: string | number }>;
+  Record<
+    keyof JSX.IntrinsicElements,
+    { [p: `data-${string}`]: string | number }
+  >;
 
 type DrawableContainerProps = {
-  divProps?: HTMLElementProps['div'],
-  className?: string,
-  children?: React.ReactNode,
-}
+  divProps?: HTMLElementProps['div'];
+  className?: string;
+  children?: React.ReactNode;
+};
 
 const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
-
   const { divProps, className, children } = props;
 
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
@@ -219,19 +259,19 @@ const DrawableContainer = memo((props: DrawableContainerProps): JSX.Element => {
         {children}
       </div>
       {isDrawerOpened && (
-        <div className="modal-backdrop fade show" onClick={() => setIsDrawerOpened(false)} />
+        <button
+          type="button"
+          className="modal-backdrop fade show"
+          onClick={() => setIsDrawerOpened(false)}
+        />
       )}
     </>
   );
 });
 
-
 export const Sidebar = (): JSX.Element => {
-
-  const {
-    sidebarMode,
-    isDrawerMode, isCollapsedMode, isDockMode,
-  } = useSidebarMode();
+  const { sidebarMode, isDrawerMode, isCollapsedMode, isDockMode } =
+    useSidebarMode();
 
   const isSearchPage = useIsSearchPage();
   const { editorMode } = useEditorMode();
@@ -240,13 +280,14 @@ export const Sidebar = (): JSX.Element => {
 
   const isEditorMode = editorMode === EditorMode.Editor;
   const shouldHideSiteName = isEditorMode && isXlSize;
-  const shouldHideSubnavAppTitle = isEditorMode && isMdSize && (isDrawerMode() || isCollapsedMode());
+  const shouldHideSubnavAppTitle =
+    isEditorMode && isMdSize && (isDrawerMode() || isCollapsedMode());
   const shouldShowEditorSidebarHead = isEditorMode && isXlSize;
 
   // css styles
   const grwSidebarClass = styles['grw-sidebar'];
   // eslint-disable-next-line no-nested-ternary
-  let modeClass;
+  let modeClass = '';
   switch (sidebarMode) {
     case SidebarMode.DRAWER:
       modeClass = 'grw-sidebar-drawer';
@@ -266,15 +307,23 @@ export const Sidebar = (): JSX.Element => {
           <span className="material-symbols-outlined">reorder</span>
         </DrawerToggler>
       )}
-      {sidebarMode != null && !isDockMode() && !isSearchPage && !shouldHideSubnavAppTitle && (
-        <AppTitleOnSubnavigation />
-      )}
-      <DrawableContainer className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`} divProps={{ 'data-testid': 'grw-sidebar' }}>
+      {sidebarMode != null &&
+        !isDockMode() &&
+        !isSearchPage &&
+        !shouldHideSubnavAppTitle && <AppTitleOnSubnavigation />}
+      <DrawableContainer
+        className={`${grwSidebarClass} ${modeClass} border-end flex-expand-vh-100`}
+        divProps={{ 'data-testid': 'grw-sidebar' }}
+      >
         <ResizableContainer>
           {sidebarMode != null && !isCollapsedMode() && (
             <AppTitleOnSidebarHead hideAppTitle={shouldHideSiteName} />
           )}
-          {shouldShowEditorSidebarHead ? <AppTitleOnEditorSidebarHead /> : <SidebarHead />}
+          {shouldShowEditorSidebarHead ? (
+            <AppTitleOnEditorSidebarHead />
+          ) : (
+            <SidebarHead />
+          )}
           <CollapsibleContainer Nav={SidebarNav} className="border-top">
             <SidebarContents />
           </CollapsibleContainer>

+ 15 - 5
apps/app/src/client/components/Sidebar/SidebarBrandLogo.tsx

@@ -3,16 +3,26 @@ import { memo } from 'react';
 import GrowiLogo from '../../../components/Common/GrowiLogo';
 
 type SidebarBrandLogoProps = {
-  isDefaultLogo?: boolean
-}
+  isDefaultLogo?: boolean;
+};
 
 export const SidebarBrandLogo = memo((props: SidebarBrandLogoProps) => {
   const { isDefaultLogo } = props;
 
-  return isDefaultLogo
-    ? <GrowiLogo />
+  return isDefaultLogo ? (
+    <GrowiLogo />
+  ) : (
     // eslint-disable-next-line @next/next/no-img-element
-    : (<div><img src="/attachment/brand-logo" alt="custom logo" width="48" className="p-1" id="settingBrandLogo" /></div>);
+    <div>
+      <img
+        src="/attachment/brand-logo"
+        alt="custom logo"
+        width="48"
+        className="p-1"
+        id="settingBrandLogo"
+      />
+    </div>
+  );
 });
 
 SidebarBrandLogo.displayName = 'SidebarBrandLogo';

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

@@ -1,12 +1,15 @@
 import React, { memo, useMemo } from 'react';
-
 import { useAtomValue } from 'jotai';
 
 import { AiAssistant } from '~/features/openai/client/components/AiAssistant/Sidebar/AiAssistant';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/states/context';
 import { aiEnabledAtom } from '~/states/server-configurations';
-import { useSidebarMode, useCollapsedContentsOpened, useCurrentSidebarContents } from '~/states/ui/sidebar';
+import {
+  useCollapsedContentsOpened,
+  useCurrentSidebarContents,
+  useSidebarMode,
+} from '~/states/ui/sidebar';
 
 import { Bookmarks } from './Bookmarks';
 import { CustomSidebar } from './Custom';
@@ -17,7 +20,6 @@ import Tag from './Tag';
 
 import styles from './SidebarContents.module.scss';
 
-
 export const SidebarContents = memo(() => {
   const { isCollapsedMode } = useSidebarMode();
   const isGuestUser = useIsGuestUser();
@@ -57,7 +59,10 @@ export const SidebarContents = memo(() => {
   const classToHide = isHidden ? 'd-none' : '';
 
   return (
-    <div className={`grw-sidebar-contents ${styles['grw-sidebar-contents']} ${classToHide}`} data-testid="grw-sidebar-contents">
+    <div
+      className={`grw-sidebar-contents ${styles['grw-sidebar-contents']} ${classToHide}`}
+      data-testid="grw-sidebar-contents"
+    >
       <Contents />
     </div>
   );

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

@@ -1,17 +1,15 @@
-import React, {
-  type FC, memo,
-} from 'react';
+import React, { type FC, memo } from 'react';
 
 import { ToggleCollapseButton } from './ToggleCollapseButton';
 
 import styles from './SidebarHead.module.scss';
 
-
 export const SidebarHead: FC = memo(() => {
   return (
-    <div className={`${styles['grw-sidebar-head']} d-flex justify-content-end w-100`}>
+    <div
+      className={`${styles['grw-sidebar-head']} d-flex justify-content-end w-100`}
+    >
       <ToggleCollapseButton />
     </div>
   );
-
 });

+ 8 - 8
apps/app/src/client/components/Sidebar/SidebarHead/ToggleCollapseButton.tsx

@@ -1,17 +1,15 @@
-import {
-  memo, useCallback, useMemo, type JSX,
-} from 'react';
+import { type JSX, memo, useCallback, useMemo } from 'react';
 
 import {
-  useDrawerOpened, useSetPreferCollapsedMode, useSidebarMode, useCollapsedContentsOpened,
+  useCollapsedContentsOpened,
+  useDrawerOpened,
+  useSetPreferCollapsedMode,
+  useSidebarMode,
 } from '~/states/ui/sidebar';
 
-
 import styles from './ToggleCollapseButton.module.scss';
 
-
 export const ToggleCollapseButton = memo((): JSX.Element => {
-
   const { isDrawerMode, isCollapsedMode } = useSidebarMode();
   const [isDrawerOpened, setIsDrawerOpened] = useDrawerOpened();
   const setPreferCollapsedMode = useSetPreferCollapsedMode();
@@ -41,7 +39,9 @@ export const ToggleCollapseButton = memo((): JSX.Element => {
       onClick={isDrawerMode() ? toggleDrawer : toggleCollapsed}
       data-testid="btn-toggle-collapse"
     >
-      <span className={`material-symbols-outlined fs-2 ${rotationClass}`}>{icon}</span>
+      <span className={`material-symbols-outlined fs-2 ${rotationClass}`}>
+        {icon}
+      </span>
     </button>
   );
 });

+ 6 - 3
apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -1,11 +1,14 @@
 type Props = {
-  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
+  onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
 };
 
 export const SidebarHeaderReloadButton = ({ onClick }: Props): JSX.Element => {
-
   return (
-    <button type="button" className="btn btn-sm ms-auto py-0 grw-btn-reload" onClick={onClick}>
+    <button
+      type="button"
+      className="btn btn-sm ms-auto py-0 grw-btn-reload"
+      onClick={onClick}
+    >
       <span className="material-symbols-outlined">refresh</span>
     </button>
   );

+ 38 - 20
apps/app/src/client/components/Sidebar/SidebarNav/PersonalDropdown.tsx

@@ -1,11 +1,13 @@
-import { type JSX } from 'react';
-
+import type { JSX } from 'react';
+import Link from 'next/link';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
 import {
-  UncontrolledDropdown, DropdownToggle, DropdownMenu, DropdownItem,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+  UncontrolledDropdown,
 } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
@@ -24,21 +26,18 @@ export const PersonalDropdown = (): JSX.Element => {
     return <SkeletonItem />;
   }
 
-  const logoutHandler = async() => {
+  const logoutHandler = async () => {
     try {
       await apiv3Post('/logout');
       window.location.reload();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
     }
   };
 
   return (
     <>
-      <UncontrolledDropdown
-        direction="end"
-      >
+      <UncontrolledDropdown direction="end">
         <DropdownToggle
           className={`btn btn-primary ${styles['btn-personal-dropdown']} opacity-100`}
           data-testid="personal-dropdown-button"
@@ -57,11 +56,15 @@ export const PersonalDropdown = (): JSX.Element => {
             </div>
             <div className="ms-1 fs-6">{currentUser.name}</div>
             <div className="d-flex align-items-center my-2">
-              <small className="material-symbols-outlined me-1 pb-0 fs-6">person</small>
+              <small className="material-symbols-outlined me-1 pb-0 fs-6">
+                person
+              </small>
               <span>{currentUser.username}</span>
             </div>
             <div className="d-flex align-items-center">
-              <span className="material-symbols-outlined me-1 pb-0 fs-6">mail</span>
+              <span className="material-symbols-outlined me-1 pb-0 fs-6">
+                mail
+              </span>
               <span className="item-text-email">{currentUser.email}</span>
             </div>
           </DropdownItem>
@@ -72,9 +75,13 @@ export const PersonalDropdown = (): JSX.Element => {
             href={pagePathUtils.userHomepagePath(currentUser)}
             data-testid="grw-personal-dropdown-menu-user-home"
           >
-            <DropdownItem className={`my-1 ${styles['personal-dropdown-item']}`}>
+            <DropdownItem
+              className={`my-1 ${styles['personal-dropdown-item']}`}
+            >
               <span className="d-flex align-items-center">
-                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">home</span>
+                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+                  home
+                </span>
                 <span className="item-text">{t('personal_dropdown.home')}</span>
               </span>
             </DropdownItem>
@@ -84,17 +91,29 @@ export const PersonalDropdown = (): JSX.Element => {
             href="/me"
             data-testid="grw-personal-dropdown-menu-user-settings"
           >
-            <DropdownItem className={`my-1 ${styles['personal-dropdown-item']}`}>
+            <DropdownItem
+              className={`my-1 ${styles['personal-dropdown-item']}`}
+            >
               <span className="d-flex align-items-center">
-                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">discover_tune</span>
-                <span className="item-text">{t('personal_dropdown.settings')}</span>
+                <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+                  discover_tune
+                </span>
+                <span className="item-text">
+                  {t('personal_dropdown.settings')}
+                </span>
               </span>
             </DropdownItem>
           </Link>
 
-          <DropdownItem data-testid="logout-button" onClick={logoutHandler} className={`my-1 ${styles['personal-dropdown-item']}`}>
+          <DropdownItem
+            data-testid="logout-button"
+            onClick={logoutHandler}
+            className={`my-1 ${styles['personal-dropdown-item']}`}
+          >
             <span className="d-flex align-items-center">
-              <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">logout</span>
+              <span className="item-icon material-symbols-outlined me-2 pb-0 fs-6">
+                logout
+              </span>
               <span className="item-text">{t('Sign out')}</span>
             </span>
           </DropdownItem>
@@ -102,5 +121,4 @@ export const PersonalDropdown = (): JSX.Element => {
       </UncontrolledDropdown>
     </>
   );
-
 };

+ 52 - 34
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItem.tsx

@@ -1,14 +1,19 @@
-import { useCallback, type JSX } from 'react';
-
+import { type JSX, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import type { SidebarContentsType } from '~/interfaces/ui';
 import { SidebarMode } from '~/interfaces/ui';
 import { useIsMobile } from '~/states/ui/device';
-import { useCollapsedContentsOpened, useCurrentSidebarContents } from '~/states/ui/sidebar';
+import {
+  useCollapsedContentsOpened,
+  useCurrentSidebarContents,
+} from '~/states/ui/sidebar';
 
-const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string => {
+const useIndicator = (
+  sidebarMode: SidebarMode,
+  isSelected: boolean,
+): string => {
   const [isCollapsedContentsOpened] = useCollapsedContentsOpened();
 
   if (sidebarMode === SidebarMode.COLLAPSED && !isCollapsedContentsOpened) {
@@ -19,25 +24,34 @@ const useIndicator = (sidebarMode: SidebarMode, isSelected: boolean): string =>
 };
 
 export type PrimaryItemProps = {
-  contents: SidebarContentsType,
-  label: string,
-  iconName: string,
-  sidebarMode: SidebarMode,
-  isCustomIcon?: boolean,
-  badgeContents?: number,
-  onHover?: (contents: SidebarContentsType) => void,
-  onClick?: () => void,
-}
+  contents: SidebarContentsType;
+  label: string;
+  iconName: string;
+  sidebarMode: SidebarMode;
+  isCustomIcon?: boolean;
+  badgeContents?: number;
+  onHover?: (contents: SidebarContentsType) => void;
+  onClick?: () => void;
+};
 
 export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
   const {
-    contents, label, iconName, sidebarMode, badgeContents, isCustomIcon,
-    onClick, onHover,
+    contents,
+    label,
+    iconName,
+    sidebarMode,
+    badgeContents,
+    isCustomIcon,
+    onClick,
+    onHover,
   } = props;
 
   const [currentContents, setCurrentContents] = useCurrentSidebarContents();
 
-  const indicatorClass = useIndicator(sidebarMode, contents === currentContents);
+  const indicatorClass = useIndicator(
+    sidebarMode,
+    contents === currentContents,
+  );
   const [isMobile] = useIsMobile();
   const { t } = useTranslation();
 
@@ -65,7 +79,6 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
     onHover?.(contents);
   }, [contents, onHover, selectThisItem, sidebarMode]);
 
-
   const labelForTestId = label.toLowerCase().replace(' ', '-');
 
   return (
@@ -80,26 +93,31 @@ export const PrimaryItem = (props: PrimaryItemProps): JSX.Element => {
       >
         <div className="position-relative">
           {badgeContents != null && (
-            <span className="position-absolute badge rounded-pill bg-primary">{badgeContents}</span>
+            <span className="position-absolute badge rounded-pill bg-primary">
+              {badgeContents}
+            </span>
+          )}
+          {isCustomIcon ? (
+            <span className="growi-custom-icons fs-4 align-middle">
+              {iconName}
+            </span>
+          ) : (
+            <span className="material-symbols-outlined">{iconName}</span>
           )}
-          {isCustomIcon
-            ? (<span className="growi-custom-icons fs-4 align-middle">{iconName}</span>)
-            : (<span className="material-symbols-outlined">{iconName}</span>)
-          }
         </div>
       </button>
-      {
-        isMobile === false ? (
-          <UncontrolledTooltip
-            autohide
-            placement="right"
-            target={labelForTestId}
-            fade={false}
-          >
-            {t(label)}
-          </UncontrolledTooltip>
-        ) : <></>
-      }
+      {isMobile === false ? (
+        <UncontrolledTooltip
+          autohide
+          placement="right"
+          target={labelForTestId}
+          fade={false}
+        >
+          {t(label)}
+        </UncontrolledTooltip>
+      ) : (
+        <></>
+      )}
     </>
   );
 };

+ 49 - 12
apps/app/src/client/components/Sidebar/SidebarNav/PrimaryItems.tsx

@@ -1,7 +1,6 @@
 import { memo } from 'react';
-
-import { useAtomValue } from 'jotai';
 import dynamic from 'next/dynamic';
+import { useAtomValue } from 'jotai';
 
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useIsGuestUser } from '~/states/context';
@@ -12,15 +11,18 @@ import { PrimaryItem } from './PrimaryItem';
 
 import styles from './PrimaryItems.module.scss';
 
-
 // Do not SSR Socket.io to make it work
 const PrimaryItemForNotification = dynamic(
-  () => import('../InAppNotification/PrimaryItemForNotification').then(mod => mod.PrimaryItemForNotification), { ssr: false },
+  () =>
+    import('../InAppNotification/PrimaryItemForNotification').then(
+      (mod) => mod.PrimaryItemForNotification,
+    ),
+  { ssr: false },
 );
 
 type Props = {
-  onItemHover?: (contents: SidebarContentsType) => void,
-}
+  onItemHover?: (contents: SidebarContentsType) => void;
+};
 
 export const PrimaryItems = memo((props: Props) => {
   const { onItemHover } = props;
@@ -35,12 +37,47 @@ export const PrimaryItems = memo((props: Props) => {
 
   return (
     <div className={styles['grw-primary-items']}>
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TREE} label="Page Tree" iconName="list" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.BOOKMARKS} label="Bookmarks" iconName="bookmarks" onHover={onItemHover} />
-      <PrimaryItem sidebarMode={sidebarMode} contents={SidebarContentsType.TAG} label="Tags" iconName="local_offer" onHover={onItemHover} />
-      {isGuestUser === false && <PrimaryItemForNotification sidebarMode={sidebarMode} onHover={onItemHover} />}
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.TREE}
+        label="Page Tree"
+        iconName="list"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.CUSTOM}
+        label="Custom Sidebar"
+        iconName="code"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.RECENT}
+        label="Recent Changes"
+        iconName="update"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.BOOKMARKS}
+        label="Bookmarks"
+        iconName="bookmarks"
+        onHover={onItemHover}
+      />
+      <PrimaryItem
+        sidebarMode={sidebarMode}
+        contents={SidebarContentsType.TAG}
+        label="Tags"
+        iconName="local_offer"
+        onHover={onItemHover}
+      />
+      {isGuestUser === false && (
+        <PrimaryItemForNotification
+          sidebarMode={sidebarMode}
+          onHover={onItemHover}
+        />
+      )}
       {isAiEnabled && (
         <PrimaryItem
           sidebarMode={sidebarMode}

+ 25 - 15
apps/app/src/client/components/Sidebar/SidebarNav/SecondaryItems.tsx

@@ -1,6 +1,5 @@
 import type { FC } from 'react';
 import { memo } from 'react';
-
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 
@@ -11,19 +10,20 @@ import { SkeletonItem } from './SkeletonItem';
 
 import styles from './SecondaryItems.module.scss';
 
-
-const PersonalDropdown = dynamic(() => import('./PersonalDropdown').then(mod => mod.PersonalDropdown), {
-  ssr: false,
-  loading: () => <SkeletonItem />,
-});
-
+const PersonalDropdown = dynamic(
+  () => import('./PersonalDropdown').then((mod) => mod.PersonalDropdown),
+  {
+    ssr: false,
+    loading: () => <SkeletonItem />,
+  },
+);
 
 type SecondaryItemProps = {
-  label: string,
-  href: string,
-  iconName: string,
-  isBlank?: boolean,
-}
+  label: string;
+  href: string;
+  iconName: string;
+  isBlank?: boolean;
+};
 
 const SecondaryItem: FC<SecondaryItemProps> = (props: SecondaryItemProps) => {
   const { iconName, href, isBlank } = props;
@@ -41,15 +41,25 @@ const SecondaryItem: FC<SecondaryItemProps> = (props: SecondaryItemProps) => {
 };
 
 export const SecondaryItems: FC = memo(() => {
-
   const isAdmin = useIsAdmin();
   const growiCloudUri = useGrowiCloudUri();
   const isGuestUser = useIsGuestUser();
 
   return (
     <div className={styles['grw-secondary-items']}>
-      <SecondaryItem label="Help" iconName="help" href={growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org'} isBlank />
-      {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
+      <SecondaryItem
+        label="Help"
+        iconName="help"
+        href={
+          growiCloudUri != null
+            ? 'https://growi.cloud/help/'
+            : 'https://docs.growi.org'
+        }
+        isBlank
+      />
+      {isAdmin && (
+        <SecondaryItem label="Admin" iconName="settings" href="/admin" />
+      )}
       <SecondaryItem label="Trash" href="/trash" iconName="delete" />
       {!isGuestUser && <PersonalDropdown />}
     </div>

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

@@ -5,15 +5,14 @@ import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 
 import { NotAvailableForReadOnlyUser } from '../../NotAvailableForReadOnlyUser';
 import { PageCreateButton } from '../PageCreateButton';
-
 import { PrimaryItems } from './PrimaryItems';
 import { SecondaryItems } from './SecondaryItems';
 
 import styles from './SidebarNav.module.scss';
 
 export type SidebarNavProps = {
-  onPrimaryItemHover?: (contents: SidebarContentsType) => void,
-}
+  onPrimaryItemHover?: (contents: SidebarContentsType) => void;
+};
 
 export const SidebarNav = memo((props: SidebarNavProps) => {
   const { onPrimaryItemHover } = props;
@@ -39,10 +38,12 @@ export const SidebarNav = memo((props: SidebarNavProps) => {
 
   return (
     <div className={`grw-sidebar-nav ${styles['grw-sidebar-nav']}`}>
-
       {renderedPageCreateButton}
 
-      <div className="grw-sidebar-nav-primary-container" data-vrt-blackout-sidebar-nav>
+      <div
+        className="grw-sidebar-nav-primary-container"
+        data-vrt-blackout-sidebar-nav
+      >
         <PrimaryItems onItemHover={onPrimaryItemHover} />
       </div>
 

+ 0 - 1
apps/app/src/client/components/Sidebar/SidebarNav/SkeletonItem.tsx

@@ -4,7 +4,6 @@ import { Skeleton } from '~/client/components/Skeleton';
 
 import styles from './SkeletonItem.module.scss';
 
-
 export const SkeletonItem = memo(() => {
   return <Skeleton additionalClass={styles['grw-skeleton-item']} roundedPill />;
 });

+ 12 - 5
apps/app/src/client/components/Sidebar/Skeleton/DefaultContentSkeleton.tsx

@@ -5,12 +5,19 @@ import { Skeleton } from '~/client/components/Skeleton';
 import styles from './DefaultContentSkelton.module.scss';
 
 const DefaultContentSkeleton = (): JSX.Element => {
-
   return (
-    <div className={`py-3 grw-default-content-skelton ${styles['grw-default-content-skelton']}`}>
-      <Skeleton additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`} />
-      <Skeleton additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`} />
-      <Skeleton additionalClass={`grw-skeleton-text ${styles['grw-skeleton-text']}`} />
+    <div
+      className={`py-3 grw-default-content-skelton ${styles['grw-default-content-skelton']}`}
+    >
+      <Skeleton
+        additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`}
+      />
+      <Skeleton
+        additionalClass={`grw-skeleton-text-full ${styles['grw-skeleton-text-full']}`}
+      />
+      <Skeleton
+        additionalClass={`grw-skeleton-text ${styles['grw-skeleton-text']}`}
+      />
     </div>
   );
 };

+ 3 - 2
apps/app/src/client/components/Sidebar/Skeleton/TagContentSkeleton.tsx

@@ -1,5 +1,4 @@
 import React, { type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 
 import { Skeleton } from '~/client/components/Skeleton';
@@ -8,7 +7,9 @@ import styles from '../Tag.module.scss';
 
 export const TagListSkeleton = (): JSX.Element => {
   return (
-    <Skeleton additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`} />
+    <Skeleton
+      additionalClass={`${styles['grw-tag-list-skeleton']} w-100 rounded overflow-hidden`}
+    />
   );
 };
 

+ 29 - 27
apps/app/src/client/components/Sidebar/Tag.tsx

@@ -1,28 +1,28 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } from 'react';
-
-import { useTranslation } from 'next-i18next';
+import React, { useCallback, useState } from 'react';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
 
 import type { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
 
 import TagCloudBox from '../TagCloudBox';
 import TagList from '../TagList';
-
 import { SidebarHeaderReloadButton } from './SidebarHeaderReloadButton';
 import { TagListSkeleton } from './Skeleton/TagContentSkeleton';
 
-
 const PAGING_LIMIT = 10;
 const TAG_CLOUD_LIMIT = 20;
 
 const Tag: FC = () => {
-
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
 
-  const { data: tagDataList, mutate: mutateTagDataList, error } = useSWRxTagsList(PAGING_LIMIT, offset);
+  const {
+    data: tagDataList,
+    mutate: mutateTagDataList,
+    error,
+  } = useSWRxTagsList(PAGING_LIMIT, offset);
   const tagData: IDataTagCount[] = tagDataList?.data || [];
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
@@ -43,7 +43,10 @@ const Tag: FC = () => {
 
   // todo: adjust design by XD
   return (
-    <div className="container-lg px-3 mb-5 pb-5" data-testid="grw-sidebar-content-tags">
+    <div
+      className="container-lg px-3 mb-5 pb-5"
+      data-testid="grw-sidebar-content-tags"
+    >
       <div className="grw-sidebar-content-header pt-4 pb-3 d-flex">
         <h3 className="fs-6 fw-bold mb-0">{t('Tags')}</h3>
         <SidebarHeaderReloadButton onClick={() => onReload()} />
@@ -51,24 +54,24 @@ const Tag: FC = () => {
 
       <h6 className="my-3 pb-1 border-bottom">{t('tag_list')}</h6>
 
-      { isLoading
-        ? (
-          <TagListSkeleton />
-        )
-        : (
-          <div data-testid="grw-tags-list">
-            <TagList
-              tagData={tagData}
-              totalTags={totalCount}
-              activePage={activePage}
-              onChangePage={setOffsetByPageNumber}
-              pagingLimit={PAGING_LIMIT}
-            />
-          </div>
-        )
-      }
-
-      <div className="d-flex justify-content-center my-5" data-testid="check-all-tags-button">
+      {isLoading ? (
+        <TagListSkeleton />
+      ) : (
+        <div data-testid="grw-tags-list">
+          <TagList
+            tagData={tagData}
+            totalTags={totalCount}
+            activePage={activePage}
+            onChangePage={setOffsetByPageNumber}
+            pagingLimit={PAGING_LIMIT}
+          />
+        </div>
+      )}
+
+      <div
+        className="d-flex justify-content-center my-5"
+        data-testid="check-all-tags-button"
+      >
         <Link
           href="/tags"
           className="btn btn-primary rounded px-4"
@@ -84,7 +87,6 @@ const Tag: FC = () => {
       <TagCloudBox tags={tagCloudData} />
     </div>
   );
-
 };
 
 export default Tag;

+ 10 - 0
apps/app/src/client/services/AdminAppContainer.js

@@ -21,6 +21,7 @@ export default class AdminAppContainer extends Container {
       confidential: '',
       globalLang: '',
       isEmailPublishedForNewUser: true,
+      isReadOnlyForNewUser: false,
 
       isV5Compatible: null,
       siteUrl: '',
@@ -61,6 +62,7 @@ export default class AdminAppContainer extends Container {
       confidential: appSettingsParams.confidential,
       globalLang: appSettingsParams.globalLang,
       isEmailPublishedForNewUser: appSettingsParams.isEmailPublishedForNewUser,
+      isReadOnlyForNewUser: appSettingsParams.isReadOnlyForNewUser,
       isV5Compatible: appSettingsParams.isV5Compatible,
       siteUrl: appSettingsParams.siteUrl,
       siteUrlUseOnlyEnvVars: appSettingsParams.siteUrlUseOnlyEnvVars,
@@ -108,6 +110,13 @@ export default class AdminAppContainer extends Container {
     this.setState({ isEmailPublishedForNewUser });
   }
 
+  /**
+   * Change isReadOnlyForNewUser
+   */
+  changeIsReadOnlyForNewUserShow(isReadOnlyForNewUser) {
+    this.setState({ isReadOnlyForNewUser });
+  }
+
   /**
    * Change site url
    */
@@ -189,6 +198,7 @@ export default class AdminAppContainer extends Container {
       confidential: this.state.confidential,
       globalLang: this.state.globalLang,
       isEmailPublishedForNewUser: this.state.isEmailPublishedForNewUser,
+      isReadOnlyForNewUser: this.state.isReadOnlyForNewUser,
     });
     const { appSettingParams } = response.data;
     return appSettingParams;

+ 10 - 2
apps/app/src/features/page-tree/components/ItemsTree.tsx

@@ -78,8 +78,13 @@ export const ItemsTree: FC<Props> = (props: Props) => {
   const dataLoader = useDataLoader(rootPageId, allPagesCount);
 
   // Tree item handlers (rename, create, etc.) with stable callbacks for headless-tree
-  const { getItemName, isItemFolder, handleRename, creatingParentId } =
-    useTreeItemHandlers(triggerTreeRebuild);
+  const {
+    getItemName,
+    isItemFolder,
+    handleRename,
+    creatingParentId,
+    completeRenamingHotkey,
+  } = useTreeItemHandlers(triggerTreeRebuild);
 
   // Configure tree features and get checkbox state and D&D handlers
   const { features, checkboxProperties, dndProperties } = useTreeFeatures({
@@ -137,6 +142,9 @@ export const ItemsTree: FC<Props> = (props: Props) => {
       onDrop: handleDrop,
       canDropInbetween: false,
     }),
+    hotkeys: {
+      completeRenaming: completeRenamingHotkey,
+    },
   });
 
   // Notify parent when checked items change

+ 35 - 2
apps/app/src/features/page-tree/hooks/_inner/use-tree-item-handlers.tsx

@@ -1,5 +1,9 @@
-import { useCallback, useRef } from 'react';
-import type { ItemInstance, TreeConfig } from '@headless-tree/core';
+import { useCallback, useMemo, useRef } from 'react';
+import type {
+  CustomHotkeysConfig,
+  ItemInstance,
+  TreeConfig,
+} from '@headless-tree/core';
 
 import type { IPageForTreeItem } from '~/interfaces/page';
 
@@ -7,6 +11,9 @@ import { useCreatingParentId } from '../../states/_inner';
 import { usePageCreate } from '../use-page-create';
 import { usePageRename } from '../use-page-rename';
 
+type completeRenamingHotkey =
+  CustomHotkeysConfig<IPageForTreeItem>['completeRenaming'];
+
 type UseTreeItemHandlersReturn = {
   /**
    * Stable callback for headless-tree getItemName config
@@ -28,6 +35,11 @@ type UseTreeItemHandlersReturn = {
    * Current creating parent ID (for tree expansion logic)
    */
   creatingParentId: string | null;
+
+  /**
+   * Hotkeys config to complete renaming
+   */
+  completeRenamingHotkey: completeRenamingHotkey;
 };
 
 /**
@@ -116,10 +128,31 @@ export const useTreeItemHandlers = (
     [],
   );
 
+  // When using IME (e.g., Japanese input), pressing Enter to confirm
+  // the conversion would also trigger this hotkey, completing the rename
+  // prematurely. We check `isComposing` to ignore Enter presses during
+  // IME composition.
+  const completeRenamingHotkey: completeRenamingHotkey = useMemo(
+    () => ({
+      hotkey: 'Enter',
+      allowWhenInputFocused: true,
+      isEnabled: (tree) => tree.isRenamingItem(),
+      handler: (e, tree) => {
+        // Disable rename during IME composition
+        if (e.isComposing) {
+          return;
+        }
+        tree.completeRenaming();
+      },
+    }),
+    [],
+  );
+
   return {
     getItemName,
     isItemFolder,
     handleRename,
     creatingParentId,
+    completeRenamingHotkey,
   };
 };

+ 0 - 1
apps/app/src/interfaces/sidebar-config.ts

@@ -1,4 +1,3 @@
 export interface ISidebarConfig {
   isSidebarCollapsedMode: boolean;
-  isSidebarClosedAtDockMode?: boolean;
 }

+ 4 - 1
apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js → apps/app/src/migrations/20211227060705-revision-path-to-page-id-schema-migration--fixed-8998.js

@@ -13,11 +13,14 @@ import {
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory(
-  'growi:migrate:revision-path-to-page-id-schema-migration--fixed-7549',
+  'growi:migrate:revision-path-to-page-id-schema-migration--fixed-8998',
 );
 
 const LIMIT = 300;
 
+/**
+ * @see https://dev.growi.org/69301054963f68dfcf2b7111
+ */
 module.exports = {
   // path => pageId
   async up(db, client) {

+ 0 - 3
apps/app/src/pages/basic-layout-page/get-server-side-props/sidebar-configurations.ts

@@ -17,9 +17,6 @@ export const getServerSideSidebarConfigProps: GetServerSideProps<
         isSidebarCollapsedMode: configManager.getConfig(
           'customize:isSidebarCollapsedMode',
         ),
-        isSidebarClosedAtDockMode: configManager.getConfig(
-          'customize:isSidebarClosedAtDockMode',
-        ),
       },
     },
   };

+ 5 - 0
apps/app/src/server/models/user.js

@@ -294,6 +294,7 @@ const factory = (crowi) => {
     this.isEmailPublished = configManager.getConfig(
       'customize:isEmailPublishedForNewUser',
     );
+    this.readOnly = configManager.getConfig('app:isReadOnlyForNewUser');
 
     this.save((err, userData) => {
       userEvent.emit('activated', userData);
@@ -613,6 +614,8 @@ const factory = (crowi) => {
       newUser.lang = globalLang;
     }
 
+    newUser.readOnly = configManager.getConfig('app:isReadOnlyForNewUser');
+
     try {
       const newUserData = await newUser.save();
       return {
@@ -703,6 +706,8 @@ const factory = (crowi) => {
       'customize:isEmailPublishedForNewUser',
     );
 
+    newUser.readOnly = configManager.getConfig('app:isReadOnlyForNewUser');
+
     const globalLang = configManager.getConfig('app:globalLang');
     if (globalLang != null) {
       newUser.lang = globalLang;

+ 7 - 0
apps/app/src/server/routes/apiv3/app-settings/index.ts

@@ -400,6 +400,9 @@ module.exports = (crowi) => {
         isEmailPublishedForNewUser: configManager.getConfig(
           'customize:isEmailPublishedForNewUser',
         ),
+        isReadOnlyForNewUser: configManager.getConfig(
+          'app:isReadOnlyForNewUser',
+        ),
         useOnlyEnvVarsForIsBulkExportPagesEnabled: configManager.getConfig(
           'env:useOnlyEnvVars:app:isBulkExportPagesEnabled',
         ),
@@ -562,6 +565,7 @@ module.exports = (crowi) => {
         'app:globalLang': req.body.globalLang,
         'customize:isEmailPublishedForNewUser':
           req.body.isEmailPublishedForNewUser,
+        'app:isReadOnlyForNewUser': req.body.isReadOnlyForNewUser,
       };
 
       try {
@@ -573,6 +577,9 @@ module.exports = (crowi) => {
           isEmailPublishedForNewUser: configManager.getConfig(
             'customize:isEmailPublishedForNewUser',
           ),
+          isReadOnlyForNewUser: configManager.getConfig(
+            'app:isReadOnlyForNewUser',
+          ),
         };
 
         const parameters = {

+ 4 - 18
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -173,9 +173,6 @@ const router = express.Router();
  *          isSidebarCollapsedMode:
  *            type: boolean
  *            description: The flag whether sidebar is collapsed mode or not.
- *          isSidebarClosedAtDockMode:
- *            type: boolean
- *            description: The flag whether sidebar is closed at dock mode or not.
  *      CustomizePresentation:
  *        description: Customize Presentation
  *        type: object
@@ -206,10 +203,7 @@ module.exports = (crowi) => {
   const validator = {
     layout: [body('isContainerFluid').isBoolean()],
     theme: [body('theme').isString()],
-    sidebar: [
-      body('isSidebarCollapsedMode').isBoolean(),
-      body('isSidebarClosedAtDockMode').optional().isBoolean(),
-    ],
+    sidebar: [body('isSidebarCollapsedMode').isBoolean()],
     function: [
       body('isEnabledTimeline').isBoolean(),
       body('isEnabledAttachTitleHeader').isBoolean(),
@@ -560,13 +554,10 @@ module.exports = (crowi) => {
     adminRequired,
     async (req, res) => {
       try {
-        const isSidebarCollapsedMode = await configManager.getConfig(
+        const isSidebarCollapsedMode = configManager.getConfig(
           'customize:isSidebarCollapsedMode',
         );
-        const isSidebarClosedAtDockMode = await configManager.getConfig(
-          'customize:isSidebarClosedAtDockMode',
-        );
-        return res.apiv3({ isSidebarCollapsedMode, isSidebarClosedAtDockMode });
+        return res.apiv3({ isSidebarCollapsedMode });
       } catch (err) {
         const msg = 'Error occurred in getting sidebar';
         logger.error('Error', err);
@@ -613,19 +604,14 @@ module.exports = (crowi) => {
     async (req, res) => {
       const requestParams = {
         'customize:isSidebarCollapsedMode': req.body.isSidebarCollapsedMode,
-        'customize:isSidebarClosedAtDockMode':
-          req.body.isSidebarClosedAtDockMode,
       };
 
       try {
         await configManager.updateConfigs(requestParams);
         const customizedParams = {
-          isSidebarCollapsedMode: await configManager.getConfig(
+          isSidebarCollapsedMode: configManager.getConfig(
             'customize:isSidebarCollapsedMode',
           ),
-          isSidebarClosedAtDockMode: await configManager.getConfig(
-            'customize:isSidebarClosedAtDockMode',
-          ),
         };
 
         activityEvent.emit('update', res.locals.activity._id, {

+ 1 - 1
apps/app/src/server/routes/apiv3/forgot-password.js

@@ -148,7 +148,7 @@ module.exports = (crowi) => {
       const appUrl = growiInfoService.getSiteUrl();
 
       try {
-        const user = await User.findOne({ email });
+        const user = await User.findOne({ email: { $eq: email } });
 
         // when the user is not found or active
         if (user == null || user.status !== 2) {

+ 28 - 3
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -1,11 +1,11 @@
+import { createReadStream } from 'node:fs';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { NextFunction, Request, Router } from 'express';
 import express from 'express';
 import { body } from 'express-validator';
-import { createReadStream } from 'fs';
 import multer from 'multer';
-import path from 'path';
+import path from 'pathe';
 
 import type { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
@@ -22,6 +22,7 @@ import { TransferKey } from '~/utils/vo/transfer-key';
 import type Crowi from '../../crowi';
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 import { Attachment } from '../../models/attachment';
+import { isPathWithinBase } from '../../util/safe-path-utils';
 import type { ApiV3Response } from './interfaces/apiv3-response';
 
 interface AuthorizedRequest extends Request {
@@ -511,7 +512,31 @@ module.exports = (crowi: Crowi): Router => {
         );
       }
 
-      const fileStream = createReadStream(file.path, {
+      // Validate file path to prevent path traversal attack
+      const importService = getImportService();
+      if (importService == null) {
+        return res.apiv3Err(
+          new ErrorV3(
+            'Import service is not available.',
+            'service_unavailable',
+          ),
+          500,
+        );
+      }
+      // Normalize the path to prevent path traversal attacks
+      const resolvedFilePath = path.resolve(file.path);
+      if (!isPathWithinBase(resolvedFilePath, importService.baseDir)) {
+        logger.error('Path traversal attack detected', {
+          filePath: resolvedFilePath,
+          baseDir: importService.baseDir,
+        });
+        return res.apiv3Err(
+          new ErrorV3('Invalid file path.', 'invalid_path'),
+          400,
+        );
+      }
+
+      const fileStream = createReadStream(resolvedFilePath, {
         flags: 'r',
         mode: 0o666,
         autoClose: true,

+ 1 - 1
apps/app/src/server/routes/apiv3/page/index.ts

@@ -1041,7 +1041,7 @@ module.exports = (crowi: Crowi) => {
         return res.apiv3Err(err, 500);
       }
 
-      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
       try {
         await normalizeLatestRevisionIfBroken(pageId);
       } catch (err) {

+ 1 - 1
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -238,7 +238,7 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
       }
 
       if (currentPage != null) {
-        // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+        // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
         try {
           await normalizeLatestRevisionIfBroken(pageId);
         } catch (err) {

+ 7 - 26
apps/app/src/server/routes/apiv3/revisions.js

@@ -2,11 +2,13 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import express from 'express';
-import { connection } from 'mongoose';
 
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { Revision } from '~/server/models/revision';
-import { normalizeLatestRevisionIfBroken } from '~/server/service/revision/normalize-latest-revision-if-broken';
+import {
+  getAppliedAtForRevisionFilter,
+  normalizeLatestRevisionIfBroken,
+} from '~/server/service/revision/normalize-latest-revision-if-broken';
 import loggerFactory from '~/utils/logger';
 
 import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
@@ -17,9 +19,6 @@ const { query, param } = require('express-validator');
 
 const router = express.Router();
 
-const MIGRATION_FILE_NAME =
-  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
-
 /**
  * @swagger
  * components:
@@ -85,24 +84,6 @@ module.exports = (crowi) => {
     ],
   };
 
-  let cachedAppliedAt = null;
-
-  const getAppliedAtOfTheMigrationFile = async () => {
-    if (cachedAppliedAt != null) {
-      return cachedAppliedAt;
-    }
-
-    const migrationCollection = connection.collection('migrations');
-    const migration = await migrationCollection.findOne({
-      fileName: { $regex: `^${MIGRATION_FILE_NAME}` },
-    });
-    const appliedAt = migration.appliedAt;
-
-    cachedAppliedAt = appliedAt;
-
-    return appliedAt;
-  };
-
   /**
    * @swagger
    *
@@ -176,7 +157,7 @@ module.exports = (crowi) => {
         );
       }
 
-      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+      // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
       try {
         await normalizeLatestRevisionIfBroken(pageId);
       } catch (err) {
@@ -186,7 +167,7 @@ module.exports = (crowi) => {
       try {
         const page = await Page.findOne({ _id: pageId });
 
-        const appliedAt = await getAppliedAtOfTheMigrationFile();
+        const appliedAt = await getAppliedAtForRevisionFilter();
 
         const queryOpts = {
           offset,
@@ -202,7 +183,7 @@ module.exports = (crowi) => {
 
         const queryCondition = {
           pageId: page._id,
-          createdAt: { $gt: appliedAt },
+          ...(appliedAt != null && { createdAt: { $gt: appliedAt } }),
         };
 
         // https://redmine.weseek.co.jp/issues/151652

+ 1 - 1
apps/app/src/server/routes/attachment/api.js

@@ -318,7 +318,7 @@ export const routesFactory = (crowi) => {
   api.remove = async (req, res) => {
     const id = req.body.attachment_id;
 
-    const attachment = await Attachment.findById(id);
+    const attachment = await Attachment.findOne({ _id: { $eq: id } });
 
     if (attachment == null) {
       return res.json(ApiResponse.error('attachment not found'));

+ 4 - 4
apps/app/src/server/routes/comment.js

@@ -279,7 +279,7 @@ module.exports = (crowi, app) => {
     }
     // update page
     const page = await Page.findOneAndUpdate(
-      { _id: pageId },
+      { _id: { $eq: pageId } },
       {
         lastUpdateUser: req.user,
         updatedAt: new Date(),
@@ -422,7 +422,7 @@ module.exports = (crowi, app) => {
 
     let updatedComment;
     try {
-      const comment = await Comment.findById(commentId).exec();
+      const comment = await Comment.findOne({ _id: { $eq: commentId } }).exec();
 
       if (comment == null) {
         throw new Error('This comment does not exist.');
@@ -442,7 +442,7 @@ module.exports = (crowi, app) => {
       }
 
       updatedComment = await Comment.findOneAndUpdate(
-        { _id: commentId },
+        { _id: { $eq: commentId } },
         { $set: { comment: commentStr, revision } },
       );
       commentEvent.emit(CommentEvent.UPDATE, updatedComment);
@@ -506,7 +506,7 @@ module.exports = (crowi, app) => {
 
     try {
       /** @type {import('mongoose').HydratedDocument<import('~/interfaces/comment').IComment>} */
-      const comment = await Comment.findById(commentId).exec();
+      const comment = await Comment.findOne({ _id: { $eq: commentId } }).exec();
 
       if (comment == null) {
         throw new Error('This comment does not exist.');

+ 4 - 2
apps/app/src/server/routes/tag.js

@@ -128,7 +128,7 @@ module.exports = (crowi, app) => {
     const result = {};
     try {
       // TODO GC-1921 consider permission
-      const page = await Page.findById(pageId);
+      const page = await Page.findOne({ _id: { $eq: pageId } });
       const user = await User.findById(userId);
 
       if (!(await Page.isAccessiblePageByViewer(page._id, user))) {
@@ -137,7 +137,9 @@ module.exports = (crowi, app) => {
         );
       }
 
-      const previousRevision = await Revision.findById(revisionId);
+      const previousRevision = await Revision.findOne({
+        _id: { $eq: revisionId },
+      });
       result.savedPage = await crowi.pageService.updatePage(
         page,
         previousRevision.body,

+ 5 - 5
apps/app/src/server/service/config-manager/config-definition.ts

@@ -77,6 +77,7 @@ export const CONFIG_KEYS = [
   'app:wipPageExpirationSeconds',
   'app:openaiThreadDeletionCronMaxMinutesUntilRequest',
   'app:openaiVectorStoreFileDeletionCronMaxMinutesUntilRequest',
+  'app:isReadOnlyForNewUser',
 
   // Security Settings
   'security:wikiMode',
@@ -223,7 +224,6 @@ export const CONFIG_KEYS = [
   'customize:showPageSideAuthors',
   'customize:isEnabledMarp',
   'customize:isSidebarCollapsedMode',
-  'customize:isSidebarClosedAtDockMode',
 
   // Markdown Settings
   'markdown:xss:tagWhitelist',
@@ -516,6 +516,10 @@ export const CONFIG_DEFINITIONS = {
         'OPENAI_VECTOR_STORE_FILE_DELETION_CRON_MAX_MINUTES_UNTIL_REQUEST',
       defaultValue: 30,
     }),
+  'app:isReadOnlyForNewUser': defineConfig<boolean>({
+    envVarName: 'DEFAULT_USER_READONLY',
+    defaultValue: false,
+  }),
 
   // Security Settings
   'security:wikiMode': defineConfig<string | undefined>({
@@ -1006,10 +1010,6 @@ export const CONFIG_DEFINITIONS = {
   'customize:isSidebarCollapsedMode': defineConfig<boolean>({
     defaultValue: false,
   }),
-  'customize:isSidebarClosedAtDockMode': defineConfig<boolean>({
-    defaultValue: false,
-  }),
-
   // Markdown Settings
   'markdown:xss:tagWhitelist': defineConfig<string[]>({
     defaultValue: [],

+ 123 - 0
apps/app/src/server/service/growi-bridge/index.spec.ts

@@ -0,0 +1,123 @@
+import fs from 'node:fs';
+import path from 'pathe';
+import { mock } from 'vitest-mock-extended';
+
+import type Crowi from '~/server/crowi';
+
+import { GrowiBridgeService } from './index';
+
+vi.mock('fs');
+
+describe('GrowiBridgeService', () => {
+  let growiBridgeService: GrowiBridgeService;
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    const crowiMock = mock<Crowi>();
+    growiBridgeService = new GrowiBridgeService(crowiMock);
+  });
+
+  describe('getFile', () => {
+    const baseDir = '/tmp/growi-export';
+
+    beforeEach(() => {
+      // Mock fs.accessSync to not throw (file exists)
+      vi.mocked(fs.accessSync).mockImplementation(() => undefined);
+    });
+
+    describe('valid file paths', () => {
+      test('should return resolved path for a simple filename', () => {
+        const fileName = 'test.json';
+        const result = growiBridgeService.getFile(fileName, baseDir);
+
+        expect(result).toBe(path.resolve(baseDir, fileName));
+        expect(fs.accessSync).toHaveBeenCalledWith(
+          path.resolve(baseDir, fileName),
+        );
+      });
+
+      test('should return resolved path for a filename in subdirectory', () => {
+        const fileName = 'subdir/test.json';
+        const result = growiBridgeService.getFile(fileName, baseDir);
+
+        expect(result).toBe(path.resolve(baseDir, fileName));
+      });
+
+      test('should handle baseDir with trailing slash', () => {
+        const fileName = 'test.json';
+        const baseDirWithSlash = '/tmp/growi-export/';
+        const result = growiBridgeService.getFile(fileName, baseDirWithSlash);
+
+        expect(result).toBe(path.resolve(baseDirWithSlash, fileName));
+      });
+    });
+
+    describe('path traversal attack prevention', () => {
+      test('should throw error for path traversal with ../', () => {
+        const fileName = '../etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for path traversal with multiple ../', () => {
+        const fileName = '../../etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for path traversal in middle of path', () => {
+        const fileName = 'subdir/../../../etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for absolute path outside baseDir', () => {
+        const fileName = '/etc/passwd';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw error for path traversal at the end of path', () => {
+        // e.g., trying to access sibling directory
+        const fileName = 'subdir/../../other-dir/file.json';
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+    });
+
+    describe('file access check', () => {
+      test('should throw error if file does not exist', () => {
+        const fileName = 'nonexistent.json';
+        vi.mocked(fs.accessSync).mockImplementation(() => {
+          throw new Error('ENOENT: no such file or directory');
+        });
+
+        expect(() => {
+          growiBridgeService.getFile(fileName, baseDir);
+        }).toThrow('ENOENT: no such file or directory');
+      });
+    });
+  });
+
+  describe('getEncoding', () => {
+    test('should return utf-8', () => {
+      expect(growiBridgeService.getEncoding()).toBe('utf-8');
+    });
+  });
+
+  describe('getMetaFileName', () => {
+    test('should return meta.json', () => {
+      expect(growiBridgeService.getMetaFileName()).toBe('meta.json');
+    });
+  });
+});

+ 9 - 5
apps/app/src/server/service/growi-bridge/index.ts

@@ -1,12 +1,13 @@
-import fs from 'fs';
-import path from 'path';
-import { pipeline } from 'stream';
-import { finished } from 'stream/promises';
+import fs from 'node:fs';
+import { pipeline } from 'node:stream';
+import { finished } from 'node:stream/promises';
+import path from 'pathe';
 import unzipStream, { type Entry } from 'unzip-stream';
 
 import type Crowi from '~/server/crowi';
 import loggerFactory from '~/utils/logger';
 
+import { assertFileNameSafeForBaseDir } from '../../util/safe-path-utils';
 import type { ZipFileStat } from '../interfaces/export';
 import { tapStreamDataByPromise } from './unzip-stream-utils';
 
@@ -55,7 +56,10 @@ export class GrowiBridgeService {
    * @memberOf GrowiBridgeService
    */
   getFile(fileName: string, baseDir: string): string {
-    const jsonFile = path.join(baseDir, fileName);
+    // Prevent path traversal attack
+    assertFileNameSafeForBaseDir(fileName, baseDir);
+
+    const jsonFile = path.resolve(baseDir, fileName);
 
     // throws err if the file does not exist
     fs.accessSync(jsonFile);

+ 20 - 8
apps/app/src/server/service/page/index.ts

@@ -617,14 +617,26 @@ class PageService implements IPageService {
     const userRelatedGroups =
       await this.pageGrantService.getUserRelatedGroups(user);
 
-    const isDeletable = this.canDelete(page, creatorId, user, false);
-    const isAbleToDeleteCompletely = this.canDeleteCompletely(
-      page,
-      creatorId,
-      user,
-      false,
-      userRelatedGroups,
-    ); // use normal delete config
+    const canDeleteUserHomepage = await (async () => {
+      // Not a user homepage
+      if (!pagePathUtils.isUsersHomepage(page.path)) {
+        return true;
+      }
+
+      if (!this.canDeleteUserHomepageByConfig()) {
+        return false;
+      }
+
+      return await this.isUsersHomepageOwnerAbsent(page.path);
+    })();
+
+    const isDeletable =
+      canDeleteUserHomepage && this.canDelete(page, creatorId, user, false);
+
+    const isAbleToDeleteCompletely =
+      canDeleteUserHomepage &&
+      this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
+
     const isBookmarked: boolean = isGuestUser
       ? false
       : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;

+ 117 - 2
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.integ.ts

@@ -1,16 +1,37 @@
 import { getIdStringForRef } from '@growi/core';
 import type { HydratedDocument } from 'mongoose';
-import mongoose, { Types } from 'mongoose';
+import mongoose, { connection, Types } from 'mongoose';
 
 import type { PageDocument, PageModel } from '~/server/models/page';
 import PageModelFactory from '~/server/models/page';
 import { Revision } from '~/server/models/revision';
 
-import { normalizeLatestRevisionIfBroken } from './normalize-latest-revision-if-broken';
+import {
+  __resetCacheForTesting,
+  getAppliedAtForRevisionFilter,
+  normalizeLatestRevisionIfBroken,
+} from './normalize-latest-revision-if-broken';
+
+const OLD_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
+const NEW_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-8998';
 
 describe('normalizeLatestRevisionIfBroken', () => {
   beforeAll(async () => {
     await PageModelFactory(null);
+    // Insert migration record to simulate affected instance
+    const migrationCollection = connection.collection('migrations');
+    await migrationCollection.insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt: new Date('2024-01-01'),
+    });
+  });
+
+  afterAll(async () => {
+    // Clean up migration record
+    const migrationCollection = connection.collection('migrations');
+    await migrationCollection.deleteOne({ fileName: OLD_MIGRATION_FILE_NAME });
   });
 
   test('should update the latest revision', async () => {
@@ -131,3 +152,97 @@ describe('normalizeLatestRevisionIfBroken', () => {
     });
   });
 });
+
+describe('getAppliedAtForRevisionFilter', () => {
+  const migrationCollection = () => connection.collection('migrations');
+
+  beforeEach(() => {
+    __resetCacheForTesting();
+  });
+
+  afterEach(async () => {
+    // Clean up all migration records
+    await migrationCollection().deleteMany({
+      fileName: { $in: [OLD_MIGRATION_FILE_NAME, NEW_MIGRATION_FILE_NAME] },
+    });
+  });
+
+  test('should return null when only new migration exists (fresh installation)', async () => {
+    // Arrange
+    await migrationCollection().insertOne({
+      fileName: NEW_MIGRATION_FILE_NAME,
+      appliedAt: new Date('2024-06-01'),
+    });
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toBeNull();
+  });
+
+  test('should return appliedAt when old migration exists (affected instance)', async () => {
+    // Arrange
+    const appliedAt = new Date('2024-01-01');
+    await migrationCollection().insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt,
+    });
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toEqual(appliedAt);
+  });
+
+  test('should return appliedAt when both migrations exist (upgraded instance)', async () => {
+    // Arrange
+    const oldAppliedAt = new Date('2024-01-01');
+    await migrationCollection().insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt: oldAppliedAt,
+    });
+    await migrationCollection().insertOne({
+      fileName: NEW_MIGRATION_FILE_NAME,
+      appliedAt: new Date('2024-06-01'),
+    });
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toEqual(oldAppliedAt);
+  });
+
+  test('should return null when neither migration exists', async () => {
+    // Arrange - no migrations inserted
+
+    // Act
+    const result = await getAppliedAtForRevisionFilter();
+
+    // Assert
+    expect(result).toBeNull();
+  });
+
+  test('should cache the result', async () => {
+    // Arrange
+    const appliedAt = new Date('2024-01-01');
+    await migrationCollection().insertOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+      appliedAt,
+    });
+
+    // Act - call twice
+    const result1 = await getAppliedAtForRevisionFilter();
+    // Remove the migration record
+    await migrationCollection().deleteOne({
+      fileName: OLD_MIGRATION_FILE_NAME,
+    });
+    const result2 = await getAppliedAtForRevisionFilter();
+
+    // Assert - both should return the same cached value
+    expect(result1).toEqual(appliedAt);
+    expect(result2).toEqual(appliedAt);
+  });
+});

+ 87 - 1
apps/app/src/server/service/revision/normalize-latest-revision-if-broken.ts

@@ -9,14 +9,100 @@ const logger = loggerFactory(
   'growi:service:revision:normalize-latest-revision',
 );
 
+const OLD_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549';
+const NEW_MIGRATION_FILE_NAME =
+  '20211227060705-revision-path-to-page-id-schema-migration--fixed-8998';
+
+let cachedAppliedAt: Date | null | undefined;
+let cachedIsAffected: boolean | undefined;
+
+/**
+ * Reset the cache for testing purposes.
+ * @internal This function is only for testing.
+ */
+export const __resetCacheForTesting = (): void => {
+  cachedAppliedAt = undefined;
+  cachedIsAffected = undefined;
+};
+
 /**
- * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+ * Check if this instance went through the problematic migration (v6.1.0 - v7.0.15).
+ *
+ * Condition logic:
+ * - If old migration (fixed-7549) does NOT exist AND new migration (fixed-8998) exists
+ *   → Return false (fresh installation, not affected)
+ * - If old migration (fixed-7549) exists
+ *   → Return true (went through problematic version, affected)
+ * - If neither migration exists
+ *   → Log warning and return false (not affected)
+ *
+ * @returns true if affected by the problematic migration, false otherwise
+ *
+ * @see https://dev.growi.org/69301054963f68dfcf2b7111
+ */
+const isAffectedByProblematicMigration = async (): Promise<boolean> => {
+  if (cachedIsAffected !== undefined) {
+    return cachedIsAffected;
+  }
+
+  const migrationCollection = mongoose.connection.collection('migrations');
+
+  const oldMigration = await migrationCollection.findOne({
+    fileName: { $regex: `^${OLD_MIGRATION_FILE_NAME}` },
+  });
+  const newMigration = await migrationCollection.findOne({
+    fileName: { $regex: `^${NEW_MIGRATION_FILE_NAME}` },
+  });
+
+  // Case: fresh installation (new migration only) → not affected
+  if (oldMigration == null && newMigration != null) {
+    cachedIsAffected = false;
+    cachedAppliedAt = null;
+    return false;
+  }
+
+  // Case: went through problematic version (old migration exists) → affected
+  if (oldMigration != null) {
+    cachedIsAffected = true;
+    cachedAppliedAt = oldMigration.appliedAt;
+    return true;
+  }
+
+  // Case: neither migration exists (unexpected, but handle gracefully) → not affected
+  logger.warn(
+    'Neither old nor new migration file found in migrations collection. This may indicate an incomplete migration state.',
+  );
+  cachedIsAffected = false;
+  cachedAppliedAt = null;
+  return false;
+};
+
+/**
+ * Get the appliedAt date for filtering revisions created before the problematic migration.
+ *
+ * @returns appliedAt date to filter revisions, or null if no filter is needed
+ *
+ * @see https://dev.growi.org/69301054963f68dfcf2b7111
+ */
+export const getAppliedAtForRevisionFilter = async (): Promise<Date | null> => {
+  await isAffectedByProblematicMigration();
+  return cachedAppliedAt ?? null;
+};
+
+/**
+ * Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
  *
  * @ref https://github.com/growilabs/growi/pull/8998
  */
 export const normalizeLatestRevisionIfBroken = async (
   pageId: string | Types.ObjectId,
 ): Promise<void> => {
+  // Skip if not affected by the problematic migration
+  if (!(await isAffectedByProblematicMigration())) {
+    return;
+  }
+
   if (await Revision.exists({ pageId: { $eq: pageId } })) {
     return;
   }

+ 1 - 1
apps/app/src/server/service/yjs/sync-ydoc.ts

@@ -27,7 +27,7 @@ export const syncYDoc = async (
 ): Promise<void> => {
   const pageId = doc.name;
 
-  // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+  // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
   await normalizeLatestRevisionIfBroken(pageId);
 
   const revision = await Revision.findOne(

+ 1 - 1
apps/app/src/server/service/yjs/yjs.ts

@@ -143,7 +143,7 @@ class YjsService implements IYjsService {
       );
     };
 
-    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js'
+    // Normalize the latest revision which was borken by the migration script '20211227060705-revision-path-to-page-id-schema-migration--fixed-7549.js' provided by v6.1.0 - v7.0.15
     await normalizeLatestRevisionIfBroken(pageId);
 
     // get the latest revision createdAt

+ 169 - 0
apps/app/src/server/util/safe-path-utils.spec.ts

@@ -0,0 +1,169 @@
+import path from 'pathe';
+
+import {
+  assertFileNameSafeForBaseDir,
+  isFileNameSafeForBaseDir,
+  isPathWithinBase,
+} from './safe-path-utils';
+
+describe('path-utils', () => {
+  describe('isPathWithinBase', () => {
+    const baseDir = '/tmp/growi-export';
+
+    describe('valid paths', () => {
+      test('should return true for file directly in baseDir', () => {
+        const filePath = '/tmp/growi-export/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(true);
+      });
+
+      test('should return true for file in subdirectory', () => {
+        const filePath = '/tmp/growi-export/subdir/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(true);
+      });
+
+      test('should return true for baseDir itself', () => {
+        expect(isPathWithinBase(baseDir, baseDir)).toBe(true);
+      });
+
+      test('should handle relative paths correctly', () => {
+        const filePath = path.join(baseDir, 'test.json');
+        expect(isPathWithinBase(filePath, baseDir)).toBe(true);
+      });
+    });
+
+    describe('invalid paths (path traversal attacks)', () => {
+      test('should return false for path outside baseDir', () => {
+        const filePath = '/etc/passwd';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+
+      test('should return false for path traversal with ../', () => {
+        const filePath = '/tmp/growi-export/../etc/passwd';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+
+      test('should return false for sibling directory', () => {
+        const filePath = '/tmp/other-dir/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+
+      test('should return false for directory with similar prefix', () => {
+        // /tmp/growi-export-evil should not match /tmp/growi-export
+        const filePath = '/tmp/growi-export-evil/test.json';
+        expect(isPathWithinBase(filePath, baseDir)).toBe(false);
+      });
+    });
+  });
+
+  describe('isFileNameSafeForBaseDir', () => {
+    const baseDir = '/tmp/growi-export';
+
+    describe('valid file names', () => {
+      test('should return true for simple filename', () => {
+        expect(isFileNameSafeForBaseDir('test.json', baseDir)).toBe(true);
+      });
+
+      test('should return true for filename in subdirectory', () => {
+        expect(isFileNameSafeForBaseDir('subdir/test.json', baseDir)).toBe(
+          true,
+        );
+      });
+
+      test('should return true for deeply nested file', () => {
+        expect(isFileNameSafeForBaseDir('a/b/c/d/test.json', baseDir)).toBe(
+          true,
+        );
+      });
+    });
+
+    describe('path traversal attacks', () => {
+      test('should return false for ../etc/passwd', () => {
+        expect(isFileNameSafeForBaseDir('../etc/passwd', baseDir)).toBe(false);
+      });
+
+      test('should return false for ../../etc/passwd', () => {
+        expect(isFileNameSafeForBaseDir('../../etc/passwd', baseDir)).toBe(
+          false,
+        );
+      });
+
+      test('should return false for subdir/../../../etc/passwd', () => {
+        expect(
+          isFileNameSafeForBaseDir('subdir/../../../etc/passwd', baseDir),
+        ).toBe(false);
+      });
+
+      test('should return false for absolute path outside baseDir', () => {
+        expect(isFileNameSafeForBaseDir('/etc/passwd', baseDir)).toBe(false);
+      });
+
+      test('should return false for path escaping to sibling directory', () => {
+        expect(
+          isFileNameSafeForBaseDir('subdir/../../other-dir/file.json', baseDir),
+        ).toBe(false);
+      });
+    });
+
+    describe('edge cases', () => {
+      test('should handle empty filename', () => {
+        // Empty filename resolves to baseDir itself, which is valid
+        expect(isFileNameSafeForBaseDir('', baseDir)).toBe(true);
+      });
+
+      test('should handle . (current directory)', () => {
+        expect(isFileNameSafeForBaseDir('.', baseDir)).toBe(true);
+      });
+
+      test('should handle ./filename', () => {
+        expect(isFileNameSafeForBaseDir('./test.json', baseDir)).toBe(true);
+      });
+    });
+  });
+
+  describe('assertFileNameSafeForBaseDir', () => {
+    const baseDir = '/tmp/growi-export';
+
+    describe('valid file names (should not throw)', () => {
+      test('should not throw for simple filename', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('test.json', baseDir);
+        }).not.toThrow();
+      });
+
+      test('should not throw for filename in subdirectory', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('subdir/test.json', baseDir);
+        }).not.toThrow();
+      });
+    });
+
+    describe('path traversal attacks (should throw)', () => {
+      test('should throw for ../etc/passwd', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('../etc/passwd', baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw for ../../etc/passwd', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('../../etc/passwd', baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw for absolute path outside baseDir', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir('/etc/passwd', baseDir);
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+
+      test('should throw for path escaping to sibling directory', () => {
+        expect(() => {
+          assertFileNameSafeForBaseDir(
+            'subdir/../../other-dir/file.json',
+            baseDir,
+          );
+        }).toThrow('Invalid file path: path traversal detected');
+      });
+    });
+  });
+});

+ 69 - 0
apps/app/src/server/util/safe-path-utils.ts

@@ -0,0 +1,69 @@
+import path from 'pathe';
+
+/**
+ * Validates that the given file path is within the base directory.
+ * This prevents path traversal attacks where an attacker could use sequences
+ * like '../' to access files outside the intended directory.
+ *
+ * @param filePath - The file path to validate
+ * @param baseDir - The base directory that the file path should be within
+ * @returns true if the path is valid, false otherwise
+ */
+export function isPathWithinBase(filePath: string, baseDir: string): boolean {
+  const resolvedBaseDir = path.resolve(baseDir);
+  const resolvedFilePath = path.resolve(filePath);
+
+  // Check if the resolved path starts with the base directory
+  // We add path.sep to ensure we're checking a directory boundary
+  // (e.g., /tmp/foo should not match /tmp/foobar)
+  return (
+    resolvedFilePath.startsWith(resolvedBaseDir + path.sep) ||
+    resolvedFilePath === resolvedBaseDir
+  );
+}
+
+/**
+ * Validates that joining baseDir with fileName results in a path within baseDir.
+ * This is useful for validating user-provided file names before using them.
+ *
+ * @param fileName - The file name to validate
+ * @param baseDir - The base directory
+ * @returns true if the resulting path is valid, false otherwise
+ * @throws Error if path traversal is detected
+ */
+export function assertFileNameSafeForBaseDir(
+  fileName: string,
+  baseDir: string,
+): void {
+  const resolvedBaseDir = path.resolve(baseDir);
+  const resolvedFilePath = path.resolve(baseDir, fileName);
+
+  const isValid =
+    resolvedFilePath.startsWith(resolvedBaseDir + path.sep) ||
+    resolvedFilePath === resolvedBaseDir;
+
+  if (!isValid) {
+    throw new Error('Invalid file path: path traversal detected');
+  }
+}
+
+/**
+ * Validates that joining baseDir with fileName results in a path within baseDir.
+ * This is useful for validating user-provided file names before using them.
+ *
+ * @param fileName - The file name to validate
+ * @param baseDir - The base directory
+ * @returns true if the resulting path is valid, false otherwise
+ */
+export function isFileNameSafeForBaseDir(
+  fileName: string,
+  baseDir: string,
+): boolean {
+  const resolvedBaseDir = path.resolve(baseDir);
+  const resolvedFilePath = path.resolve(baseDir, fileName);
+
+  return (
+    resolvedFilePath.startsWith(resolvedBaseDir + path.sep) ||
+    resolvedFilePath === resolvedBaseDir
+  );
+}

+ 0 - 17
apps/app/src/stores/admin/sidebar-config.tsx

@@ -7,11 +7,7 @@ import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 
 type SidebarConfigOption = {
   update: () => Promise<void>;
-
   setIsSidebarCollapsedMode: (isSidebarCollapsedMode: boolean) => void;
-  setIsSidebarClosedAtDockMode: (
-    isSidebarClosedAtDockMode: boolean | undefined,
-  ) => void;
 };
 
 export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> &
@@ -51,18 +47,5 @@ export const useSWRxSidebarConfig = (): SWRResponse<ISidebarConfig, Error> &
       },
       [mutate],
     ),
-
-    setIsSidebarClosedAtDockMode: useCallback(
-      (isSidebarClosedAtDockMode) => {
-        // update isSidebarClosedAtDockMode in cache, not revalidate
-        mutate((prevData) => {
-          if (prevData == null) {
-            return;
-          }
-          return { ...prevData, isSidebarClosedAtDockMode };
-        }, false);
-      },
-      [mutate],
-    ),
   };
 };

+ 0 - 1
biome.json

@@ -67,7 +67,6 @@
       "!apps/app/src/client/components/RecentCreated",
       "!apps/app/src/client/components/RevisionComparer",
       "!apps/app/src/client/components/ShortcutsModal",
-      "!apps/app/src/client/components/Sidebar",
       "!apps/app/src/client/components/StaffCredit",
       "!apps/app/src/client/components/TemplateModal"
     ]