Browse Source

Merge pull request #10586 from growilabs/master

Release v7.4.0
mergify[bot] 3 months ago
parent
commit
fb6b642dbc
100 changed files with 4032 additions and 1596 deletions
  1. 2 0
      .gitignore
  2. 104 0
      .serena/memories/apps-app-detailed-architecture.md
  3. 163 0
      .serena/memories/apps-app-development-patterns.md
  4. 192 0
      .serena/memories/apps-app-jotai-directory-structure.md
  5. 84 0
      .serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md
  6. 640 0
      .serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md
  7. 683 0
      .serena/memories/apps-app-page-tree-specification.md
  8. 0 186
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  9. 35 0
      .serena/memories/apps-app-technical-specs.md
  10. 0 45
      .serena/memories/development_environment.md
  11. 390 0
      .serena/memories/nextjs-pages-router-getLayout-pattern.md
  12. 441 0
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  13. 65 0
      .serena/memories/page-transition-and-rendering-flow.md
  14. 0 100
      .serena/memories/suggested_commands.md
  15. 41 42
      .serena/memories/tech_stack.md
  16. 95 0
      .serena/memories/vitest-testing-tips-and-best-practices.md
  17. 10 0
      .serena/serena_config.yml
  18. 3 1
      CLAUDE.md
  19. 42 12
      apps/app/.eslintrc.js
  20. 1 1
      apps/app/bin/github-actions/update-readme.sh
  21. 2 0
      apps/app/config/logger/config.dev.js
  22. 3 3
      apps/app/docker/README.md
  23. 40 0
      apps/app/docs/plan/README.md
  24. 4 1
      apps/app/next.config.js
  25. 11 4
      apps/app/package.json
  26. 11 0
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  27. 4 1
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  28. 6 2
      apps/app/playwright/23-editor/saving.spec.ts
  29. 0 2
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  30. 9 3
      apps/app/playwright/60-home/home.spec.ts
  31. 7 5
      apps/app/playwright/utils/CollapseSidebar.ts
  32. 34 0
      apps/app/public/images/customize-settings/collapsed-dark.svg
  33. 4 1
      apps/app/public/images/customize-settings/collapsed-light.svg
  34. 0 31
      apps/app/public/images/customize-settings/drawer-dark.svg
  35. 5 26
      apps/app/public/static/locales/en_US/admin.json
  36. 5 26
      apps/app/public/static/locales/fr_FR/admin.json
  37. 5 27
      apps/app/public/static/locales/ja_JP/admin.json
  38. 5 26
      apps/app/public/static/locales/ko_KR/admin.json
  39. 5 26
      apps/app/public/static/locales/zh_CN/admin.json
  40. 11 19
      apps/app/src/client/components/Admin/App/AppSetting.jsx
  41. 2 2
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  42. 4 7
      apps/app/src/client/components/Admin/App/MaintenanceMode.tsx
  43. 2 2
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  44. 4 5
      apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx
  45. 4 3
      apps/app/src/client/components/Admin/AuditLogManagement.tsx
  46. 17 14
      apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx
  47. 5 40
      apps/app/src/client/components/Admin/Customize/CustomizeSidebarSetting.tsx
  48. 4 4
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  49. 7 6
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  50. 2 2
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  51. 2 2
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  52. 4 4
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  53. 2 2
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  54. 2 264
      apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx
  55. 4 2
      apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx
  56. 12 18
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx
  57. 13 18
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx
  58. 4 4
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  59. 3 6
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  60. 3 6
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  61. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  62. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  63. 56 39
      apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx
  64. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  65. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  66. 75 60
      apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx
  67. 3 2
      apps/app/src/client/components/Admin/UserGroup/UserGroupPage.tsx
  68. 3 2
      apps/app/src/client/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  69. 5 5
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  70. 3 2
      apps/app/src/client/components/Admin/Users/PasswordResetModal.jsx
  71. 2 2
      apps/app/src/client/components/Admin/Users/RevokeAdminButton.tsx
  72. 2 2
      apps/app/src/client/components/Admin/Users/RevokeAdminMenuItem.tsx
  73. 2 2
      apps/app/src/client/components/Admin/Users/StatusSuspendMenuItem.tsx
  74. 3 3
      apps/app/src/client/components/Admin/Users/UserInviteModal.jsx
  75. 2 7
      apps/app/src/client/components/AlertSiteUrlUndefined.tsx
  76. 2 2
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  77. 2 2
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  78. 7 6
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx
  79. 55 29
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  80. 5 4
      apps/app/src/client/components/Comments.tsx
  81. 3 4
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx
  82. 54 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  83. 42 22
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  84. 65 61
      apps/app/src/client/components/Common/ImageCropModal.tsx
  85. 25 23
      apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx
  86. 19 0
      apps/app/src/client/components/CreateTemplateModal/dynamic.tsx
  87. 1 0
      apps/app/src/client/components/CreateTemplateModal/index.ts
  88. 2 2
      apps/app/src/client/components/DataTransferForm.tsx
  89. 0 71
      apps/app/src/client/components/DeleteBookmarkFolderModal.tsx
  90. 96 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx
  91. 18 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx
  92. 1 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/index.ts
  93. 4 4
      apps/app/src/client/components/DescendantsPageList.tsx
  94. 0 129
      apps/app/src/client/components/DescendantsPageListModal.tsx
  95. 0 0
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss
  96. 22 7
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx
  97. 164 0
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  98. 18 0
      apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx
  99. 1 0
      apps/app/src/client/components/DescendantsPageListModal/index.ts
  100. 0 93
      apps/app/src/client/components/EmptyTrashModal.tsx

+ 2 - 0
.gitignore

@@ -36,8 +36,10 @@ yarn-error.log*
 
 # IDE, dev #
 .idea
+.claude
 *.orig
 *.code-workspace
+*.timestamp-*.mjs
 
 # turborepo
 .turbo

+ 104 - 0
.serena/memories/apps-app-detailed-architecture.md

@@ -0,0 +1,104 @@
+# apps/app アーキテクチャ詳細ガイド
+
+## 概要
+`apps/app` は GROWI のメインアプリケーションで、Next.js ベースのフルスタック Web アプリケーションです。
+
+## エントリーポイント
+- **サーバーサイド**: `server/app.ts` - OpenTelemetry 初期化と Crowi サーバー起動を担当
+- **クライアントサイド**: `pages/_app.page.tsx` - Next.js アプリのルートコンポーネント
+
+## ディレクトリ構成の方針
+
+### フィーチャーベース(新しい方針)
+`features/` ディレクトリは機能ごとに整理され、各フィーチャーは以下の構造を持つ:
+- `interfaces/` - TypeScript 型定義
+- `server/` - サーバーサイドロジック(models, routes, services)
+- `client/` - クライアントサイドロジック(components, stores, services)
+- `utils/` - 共通ユーティリティ
+
+#### 主要フィーチャー
+- `openai/` - AI アシスタント機能(OpenAI 統合)
+- `external-user-group/` - 外部ユーザーグループ管理
+- `page-bulk-export/` - ページ一括エクスポート
+- `growi-plugin/` - プラグインシステム
+- `search/` - 検索機能
+- `mermaid/` - Mermaid 図表レンダリング
+- `plantuml/` - PlantUML 図表レンダリング
+- `callout/` - コールアウト(注意書き)機能
+- `comment/` - コメント機能
+- `templates/` - テンプレート機能
+- `rate-limiter/` - レート制限
+- `opentelemetry/` - テレメトリ・監視
+
+### レガシー構造(段階的移行予定)
+
+#### ユニバーサル(サーバー・クライアント共通)
+- `components/` - React コンポーネント(ページレベル、レイアウト、共通)
+- `interfaces/` - TypeScript インターフェース
+- `models/` - データモデル定義
+- `services/` - ビジネスロジック(レンダラーなど)
+- `stores-universal/` - ユニバーサル状態管理(SWR コンテキスト等)
+
+#### サーバーサイド専用
+- `server/` - サーバーサイドコード
+  - `models/` - Mongoose モデル
+  - `routes/` - Express ルート(API v3含む)
+  - `service/` - サーバーサイドサービス
+  - `middlewares/` - Express ミドルウェア
+  - `util/` - サーバーサイドユーティリティ
+  - `events/` - イベントエミッター
+  - `crowi/` - アプリケーション初期化
+
+#### クライアントサイド専用
+- `client/` - クライアントサイドコード
+  - `components/` - React コンポーネント
+  - `services/` - クライアントサイドサービス
+  - `util/` - クライアントサイドユーティリティ
+  - `interfaces/` - クライアント固有の型定義
+  - `models/` - クライアントサイドモデル
+
+#### Next.js Pages Router
+- `pages/` - Next.js ページルート
+  - `admin/` - 管理画面ページ
+  - `me/` - ユーザー設定ページ
+  - `[[...path]]/` - 動的ページルート(Catch-all)
+  - `share/` - 共有ページ
+  - `login/` - ログインページ
+
+#### 状態管理・UI
+- `states/` - Jotai 状態管理(ページ、UI、サーバー設定)
+- `stores/` - レガシー状態管理(段階的に states/ に移行)
+- `styles/` - SCSS スタイル
+
+#### その他
+- `utils/` - 汎用ユーティリティ
+- `migrations/` - データベースマイグレーション
+- `@types/` - TypeScript 型拡張
+
+## 開発指針
+
+### 新機能開発
+新しい機能は `features/` ディレクトリにフィーチャーベースで実装し、以下を含める:
+1. インターフェース定義
+2. サーバーサイド実装(必要に応じて)
+3. クライアントサイド実装(必要に応じて)
+4. 共通ユーティリティ
+
+### 既存機能の改修
+既存のレガシー構造は段階的に features/ に移行することが推奨される。
+
+### 重要な技術スタック
+- **フレームワーク**: Next.js (Pages Router)
+- **状態管理**: Jotai (新), SWR (データフェッチング)
+- **スタイル**: SCSS, CSS Modules
+- **サーバー**: Express.js
+- **データベース**: MongoDB (Mongoose)
+- **型システム**: TypeScript
+- **監視**: OpenTelemetry
+
+## 特記事項
+- AI 統合機能(OpenAI)は最も複雑なフィーチャーの一つ
+- プラグインシステムにより機能拡張可能
+- 多言語対応(i18next)
+- 複数の認証方式サポート
+- レート制限・セキュリティ機能内蔵

+ 163 - 0
.serena/memories/apps-app-development-patterns.md

@@ -0,0 +1,163 @@
+# apps/app 開発ワークフロー・パターン集
+
+## よくある開発パターン
+
+### 新しいページ作成
+1. `pages/` にページファイル作成(`.page.tsx`)
+2. 必要に応じてレイアウト定義
+3. サーバーサイドプロパティ設定 (`getServerSideProps`)
+4. 状態管理セットアップ
+5. スタイル追加
+
+### 新しい API エンドポイント
+1. `server/routes/apiv3/` にルートファイル作成
+2. バリデーション定義
+3. サービス層実装
+4. レスポンス形式定義
+5. OpenAPI 仕様更新
+
+### 新しいフィーチャー実装
+1. `features/新機能名/` ディレクトリ作成
+2. `interfaces/` で型定義
+3. `server/` でバックエンド実装
+4. `client/` でフロントエンド実装
+5. `utils/` で共通ロジック
+
+### コンポーネント作成
+1. 適切なディレクトリに配置
+2. TypeScript プロパティ定義
+3. CSS Modules でスタイル
+4. JSDoc コメント追加
+5. テストファイル作成
+
+## 重要な設計パターン
+
+### SWR データフェッチング
+```typescript
+const { data, error, mutate } = useSWR('/api/v3/pages', fetcher);
+```
+
+### Jotai 状態管理
+```typescript
+const pageAtom = atom(initialPageState);
+const [page, setPage] = useAtom(pageAtom);
+```
+
+### CSS Modules スタイリング
+```scss
+.componentName {
+  @extend %some-placeholder;
+  @include some-mixin;
+}
+```
+
+### API ルート実装
+```typescript
+export const getPageHandler = async(req: NextApiRequest, res: NextApiResponse) => {
+  // バリデーション
+  // ビジネスロジック
+  // レスポンス
+};
+```
+
+## ファイル構成のベストプラクティス
+
+### フィーチャーディレクトリ例
+```
+features/my-feature/
+├── interfaces/
+│   └── my-feature.ts
+├── server/
+│   ├── models/
+│   ├── routes/
+│   └── services/
+├── client/
+│   ├── components/
+│   ├── stores/
+│   └── services/
+└── utils/
+    └── common-logic.ts
+```
+
+### コンポーネントディレクトリ例
+```
+components/MyComponent/
+├── MyComponent.tsx
+├── MyComponent.module.scss
+├── MyComponent.spec.tsx
+├── index.ts
+└── sub-components/
+```
+
+## 開発時のチェックリスト
+
+### コード品質
+- [ ] TypeScript エラーなし
+- [ ] ESLint ルール準拠
+- [ ] テストケース作成
+- [ ] 型安全性確保
+- [ ] パフォーマンス影響確認
+
+### 機能要件
+- [ ] 国際化対応(i18n)
+- [ ] セキュリティチェック
+- [ ] アクセシビリティ対応
+- [ ] レスポンシブデザイン
+- [ ] エラーハンドリング
+
+### API 設計
+- [ ] RESTful 設計原則
+- [ ] 適切な HTTP ステータスコード
+- [ ] バリデーション実装
+- [ ] レート制限対応
+- [ ] ドキュメント更新
+
+## デバッグ・トラブルシューティング
+
+### よくある問題
+1. **型エラー**: tsconfig.json 設定確認
+2. **スタイル適用されない**: CSS Modules インポート確認
+3. **API エラー**: ミドルウェア順序確認
+4. **状態同期問題**: SWR キー重複確認
+5. **ビルドエラー**: 依存関係バージョン確認
+
+### デバッグツール
+- Next.js Dev Tools
+- React Developer Tools
+- Network タブ(API 監視)
+- Console ログ
+- Lighthouse(パフォーマンス)
+
+## パフォーマンス最適化
+
+### フロントエンド
+- コンポーネント lazy loading
+- 画像最適化
+- Bundle サイズ監視
+- メモ化(useMemo, useCallback)
+
+### バックエンド
+- データベースクエリ最適化
+- キャッシュ戦略
+- 非同期処理
+- リソース使用量監視
+
+## セキュリティ考慮事項
+
+### 実装時の注意
+- 入力サニタイゼーション
+- CSRF 対策
+- XSS 防止
+- 認証・認可チェック
+- 機密情報の適切な取り扱い
+
+## デプロイ・運用
+
+### 環境設定
+- 環境変数管理
+- データベース接続
+- 外部サービス連携
+- ログ設定
+- 監視設定
+
+このガイドは apps/app の開発を効率的に進めるための包括的な情報源として活用してください。

+ 192 - 0
.serena/memories/apps-app-jotai-directory-structure.md

@@ -0,0 +1,192 @@
+# Jotai ディレクトリ構造・ファイル配置
+
+## 📁 確立されたディレクトリ構造
+
+```
+states/
+├── ui/
+│   ├── sidebar/                    # サイドバー状態 ✅
+│   ├── editor/                     # エディター状態 ✅
+│   ├── device.ts                   # デバイス状態 ✅
+│   ├── page.ts                     # ページUI状態 ✅
+│   ├── toc.ts                      # TOC状態 ✅
+│   ├── untitled-page.ts            # 無題ページ状態 ✅
+│   ├── page-abilities.ts           # ページ権限判定状態 ✅ DERIVED ATOM!
+│   ├── unsaved-warning.ts          # 未保存警告状態 ✅ JOTAI PATTERN!
+│   └── modal/                      # 個別モーダルファイル ✅
+│       ├── page-create.ts          # ページ作成モーダル ✅
+│       ├── page-delete.ts          # ページ削除モーダル ✅
+│       ├── empty-trash.ts          # ゴミ箱空モーダル ✅
+│       ├── delete-attachment.ts    # 添付ファイル削除 ✅
+│       ├── delete-bookmark-folder.ts # ブックマークフォルダ削除 ✅
+│       ├── update-user-group-confirm.ts # ユーザーグループ更新確認 ✅
+│       ├── page-select.ts          # ページ選択モーダル ✅
+│       ├── page-presentation.ts    # プレゼンテーションモーダル ✅
+│       ├── put-back-page.ts        # ページ復元モーダル ✅
+│       ├── granted-groups-inheritance-select.ts # 権限グループ継承選択 ✅
+│       ├── drawio.ts               # Draw.ioモーダル ✅
+│       ├── handsontable.ts         # Handsontableモーダル ✅
+│       ├── private-legacy-pages-migration.ts # プライベートレガシーページ移行 ✅
+│       ├── descendants-page-list.ts # 子孫ページリスト ✅
+│       ├── conflict-diff.ts        # 競合差分モーダル ✅
+│       ├── page-bulk-export-select.ts # ページ一括エクスポート選択 ✅
+│       ├── drawio-for-editor.ts    # エディタ用Draw.io ✅
+│       ├── link-edit.ts            # リンク編集モーダル ✅
+│       └── template.ts             # テンプレートモーダル ✅
+├── page/                           # ページ関連状態 ✅
+├── server-configurations/          # サーバー設定状態 ✅
+├── global/                         # グローバル状態 ✅
+├── socket-io/                      # Socket.IO状態 ✅
+├── context.ts                      # 共通コンテキスト ✅
+└── features/
+    └── openai/
+        └── client/
+            └── states/             # OpenAI専用状態 ✅
+                ├── index.ts        # exports ✅
+                └── unified-merge-view.ts # UnifiedMergeView状態 ✅
+
+features/                           # Feature Directory Pattern ✅
+└── page-tree/                      # ページツリー機能 ✅ (NEW!)
+    ├── index.ts                    # メインエクスポート
+    ├── client/
+    │   ├── components/             # 汎用UIコンポーネント
+    │   │   ├── SimplifiedItemsTree.tsx
+    │   │   ├── TreeItemLayout.tsx
+    │   │   └── SimpleItemContent.tsx
+    │   ├── hooks/                  # 汎用フック
+    │   │   ├── use-data-loader.ts
+    │   │   └── use-scroll-to-selected-item.ts
+    │   ├── interfaces/             # インターフェース定義
+    │   │   └── index.ts            # TreeItemProps, TreeItemToolProps
+    │   └── states/                 # Jotai状態 ✅
+    │       ├── page-tree-update.ts # ツリー更新状態
+    │       └── page-tree-desc-count-map.ts # 子孫カウント状態
+    └── constants/
+        └── index.ts                # ROOT_PAGE_VIRTUAL_ID
+```
+
+## 📋 ファイル配置ルール
+
+### UI状態系 (`states/ui/`)
+- **個別機能ファイル**: デバイス、TOC、無題ページ等の単一機能
+- **複合機能ディレクトリ**: サイドバー、エディター等の複数機能
+- **モーダル専用ディレクトリ**: `modal/` 配下に個別モーダルファイル
+
+### データ関連状態 (`states/`)
+- **ページ関連**: `page/` ディレクトリ
+- **サーバー設定**: `server-configurations/` ディレクトリ
+- **グローバル状態**: `global/` ディレクトリ
+- **通信系**: `socket-io/` ディレクトリ
+
+### 機能別専用states (`states/features/` および `features/`)
+
+**OpenAI機能**: `states/features/openai/client/states/`
+**ページツリー機能**: `features/page-tree/client/states/` ✅ (Feature Directory Pattern)
+
+### Feature Directory Pattern (新パターン) ✅
+
+`features/{feature-name}/` パターンは、特定機能に関連するコンポーネント、フック、状態、定数をすべて一箇所に集約する構造。
+
+**適用例**: `features/page-tree/`
+```
+features/page-tree/
+├── index.ts           # 全エクスポートの集約
+├── client/
+│   ├── components/    # UIコンポーネント
+│   ├── hooks/         # カスタムフック
+│   ├── interfaces/    # 型定義
+│   └── states/        # Jotai状態
+└── constants/         # 定数
+```
+
+**インポート方法**:
+```typescript
+import { 
+  SimplifiedItemsTree,
+  TreeItemLayout,
+  usePageTreeInformationUpdate,
+  ROOT_PAGE_VIRTUAL_ID 
+} from '~/features/page-tree';
+```
+
+## 🏷️ ファイル命名規則
+
+### 状態ファイル
+- **単一機能**: `{機能名}.ts` (例: `device.ts`, `toc.ts`)
+- **複合機能**: `{機能名}/` ディレクトリ(例: `sidebar/`, `editor/`)
+- **モーダル**: `modal/{モーダル名}.ts`(例: `modal/page-create.ts`)
+
+### export/import規則
+- **公開API**: `index.ts` でのre-export
+- **内部atom**: `_atomsForDerivedAbilities` 特殊名export
+- **機能専用**: 機能ディレクトリ配下の独立したstates
+
+## 📊 ファイルサイズ・複雑度の目安
+
+### 適切なファイル分割
+- **単一ファイル**: ~100行以内、単一責務
+- **ディレクトリ分割**: 複数のhook・機能がある場合
+- **個別モーダルファイル**: 1モーダル = 1ファイル原則
+
+### 複雑度による分類
+- **シンプル**: Boolean状態、基本的な値管理
+- **中程度**: 複数プロパティ、actions分離
+- **複雑**: Derived Atom、Map操作、副作用統合
+
+## 🔗 依存関係・インポート構造
+
+### インポート階層
+```
+components/
+├── import from states/ui/          # UI状態
+├── import from states/page/        # ページ状態  
+├── import from states/global/      # グローバル状態
+└── import from states/features/    # 機能別状態
+
+states/ui/
+├── 内部相互参照可能
+└── states/page/, states/global/ からのimport
+
+states/features/{feature}/
+├── states/ui/ からのimport
+├── 他のfeatures からのimport禁止
+└── 独立性を保つ
+```
+
+### 特殊名Export使用箇所
+```
+states/page/internal-atoms.ts → _atomsForDerivedAbilities
+states/ui/editor/atoms.ts → _atomsForDerivedAbilities  
+states/global/global.ts → _atomsForDerivedAbilities
+states/context.ts → _atomsForDerivedAbilities
+```
+
+## 🎯 今後の拡張指針
+
+### 新規機能追加時
+1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `features/{feature-name}/client/states/`
+2. **複雑度評価**: シンプル → 単一ファイル、複雑 → ディレクトリ
+3. **依存関係確認**: 既存atomの活用可能性
+4. **命名規則遵守**: 確立された命名パターンに従う
+5. **Feature Directory Pattern検討**: 複数のコンポーネント・フック・状態が関連する場合は `features/` 配下に集約
+
+### ディレクトリ構造維持
+- **責務単一原則**: 1ファイル = 1機能・責務
+- **依存関係最小化**: 循環参照の回避
+- **拡張性**: 将来の機能追加を考慮した構造
+- **検索性**: ファイル名から機能が推測できる命名
+
+### Feature Directory Pattern 採用基準
+以下の条件を満たす場合は `features/` 配下に配置:
+- 複数のUIコンポーネントが関連している
+- 専用のカスタムフックがある
+- 専用のJotai状態がある
+- 機能として独立性が高い
+
+**例**: `features/page-tree/` は SimplifiedItemsTree, TreeItemLayout, useDataLoader, page-tree-update.ts などが密接に関連
+
+---
+
+## 📝 最終更新日
+
+2025-11-28 (Feature Directory Pattern 追加)

+ 84 - 0
.serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md

@@ -0,0 +1,84 @@
+# モーダル最適化 V2 完了サマリー
+
+## 📊 最終結果
+
+**完了日**: 2025-10-15  
+**達成率**: **46/51モーダル (90%)**
+
+## ✅ 完了内容
+
+### Phase 1-7: 全46モーダル最適化完了
+
+#### 主要最適化パターン
+1. **Container-Presentation分離** (14モーダル)
+   - 重いロジックをSubstanceに分離
+   - Containerで条件付きレンダリング
+   
+2. **Container超軽量化** (11モーダル - Category B)
+   - Container: 6-15行に削減
+   - 全hooks/state/callbacksをSubstanceに移動
+   - Props最小化 (1-4個のみ)
+   - **実績**: AssociateModal 40行→6行 (85%削減)
+
+3. **Fadeout Transition修正** (25モーダル)
+   - 早期return削除: `if (!isOpen) return <></>;` → `{isOpen && <Substance />}`
+   - Modal常時レンダリングでtransition保証
+
+4. **計算処理メモ化** (全モーダル)
+   - useMemo/useCallbackで不要な再計算防止
+
+## 🎯 確立されたパターン
+
+### Ultra Slim Container Pattern
+```tsx
+// Container (6-10行)
+const Modal = () => {
+  const status = useModalStatus();
+  const { close } = useModalActions();
+  return (
+    <Modal isOpen={status?.isOpened} toggle={close}>
+      {status?.isOpened && <Substance data={status.data} closeModal={close} />}
+    </Modal>
+  );
+};
+
+// Substance (全ロジック)
+const Substance = ({ data, closeModal }) => {
+  const { t } = useTranslation();
+  const { mutate } = useSWR(...);
+  const handler = useCallback(...);
+  // 全てのロジック
+};
+```
+
+## 🔶 未完了 (優先度低)
+
+### Admin系モーダル (11個)
+ユーザー要望により優先度低下、V3では対象外:
+- UserGroupDeleteModal.tsx
+- UserGroupUserModal.tsx
+- UpdateParentConfirmModal.tsx
+- SelectCollectionsModal.tsx
+- ConfirmModal.tsx
+- その他6個
+
+### クラスコンポーネント (2個) - 対象外
+- UserInviteModal.jsx
+- GridEditModal.jsx
+
+## 📈 期待される効果
+
+1. **初期読み込み高速化** - 不要なコンポーネントレンダリング削減
+2. **メモリ効率化** - Container-Presentation分離
+3. **レンダリング最適化** - 計算処理のメモ化
+4. **UX向上** - Fadeout transition保証
+5. **保守性向上** - Container超軽量化 (最大85%削減)
+
+## ➡️ Next: V3へ
+
+V3では動的ロード最適化に移行:
+- モーダルの遅延読み込み実装
+- 初期バンドルサイズ削減
+- useDynamicModalLoader実装
+
+**V2の成果物を基盤として、V3でさらなる最適化を実現**

+ 640 - 0
.serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md

@@ -0,0 +1,640 @@
+# モーダル・コンポーネント パフォーマンス最適化 V3 - 完了記録
+
+**完了日**: 2025-10-20  
+**プロジェクト期間**: 2025-10-15 〜 2025-10-20  
+**最終成果**: 34コンポーネント最適化完了 🎉
+
+---
+
+## 📊 最終成果サマリー
+
+### 実装完了コンポーネント
+
+| カテゴリ | 完了数 | 詳細 |
+|---------|--------|------|
+| **モーダル** | 25個 | useLazyLoader動的ロード |
+| **PageAlerts** | 4個 | Container-Presentation分離 + 条件付きレンダリング |
+| **Sidebar** | 1個 | AiAssistantSidebar (useLazyLoader + SWR最適化) |
+| **その他** | 4個 | 既存のLazyLoaded実装 |
+| **合計** | **34個** | **全体最適化達成** ✨ |
+
+### V3の主要改善
+
+1. **useLazyLoader実装**: 汎用的な動的ローディングフック
+   - グローバルキャッシュによる重複実行防止
+   - 表示条件に基づく真の遅延ロード
+   - テストカバレッジ完備 (12 tests passing)
+
+2. **3つのケース別最適化パターン確立**:
+   - **ケースA**: 単一ファイル → ディレクトリ構造化
+   - **ケースB**: Container-Presentation分離 (Modal外枠なし) → リファクタリング
+   - **ケースC**: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐
+
+3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行
+   - 全ページの初期ロード削減
+   - Container-Presentation分離による不要なレンダリング削減
+   - 条件付きレンダリングによるパフォーマンス向上
+
+4. **Sidebar最適化**: AiAssistantSidebar
+   - useLazyLoader適用(isOpened時のみロード)
+   - useSWRxThreads を Substance へ移動(条件付き実行)
+
+---
+
+## 🎯 パフォーマンス効果
+
+### 初期バンドルサイズ削減
+- **34コンポーネント分の遅延ロード**
+- モーダル平均150行 × 25個 = 約3,750行
+- PageAlerts 4個(最大412行)
+- Sidebar 1個(約600行)
+- **合計: 約5,000行以上のコード削減**
+
+### 初期レンダリングコスト削減
+- Container-Presentation分離による無駄なレンダリング回避
+- 条件が満たされない場合、Substance が全くレンダリングされない
+- SWR hooks の不要な実行を防止
+
+### メモリ効率向上
+- グローバルキャッシュによる重複ロード防止
+- 一度ロードされたコンポーネントは再利用
+
+---
+
+## 📚 技術ガイド
+
+### 1. useLazyLoader フック
+
+**ファイル**: `apps/app/src/client/util/use-lazy-loader.ts`
+
+**特徴**:
+- グローバルキャッシュによる重複実行防止
+- 型安全性(ジェネリクス対応)
+- エラーハンドリング内蔵
+
+**基本的な使い方**:
+```tsx
+const Component = useLazyLoader(
+  'unique-key',           // グローバルキャッシュ用の一意なキー
+  () => import('./Component'), // dynamic import
+  isActive,               // ロードトリガー条件
+);
+
+return Component ? <Component /> : null;
+```
+
+**テスト**: 12 tests passing
+
+---
+
+### 2. ディレクトリ構造と命名規則
+
+```
+apps/app/.../[ComponentName]/
+├── index.ts                    # エクスポート用 (named export)
+├── [ComponentName].tsx         # 実際のコンポーネント (named export)
+└── dynamic.tsx                 # 動的ローダー (named export)
+```
+
+**命名規則**:
+- Hook: `useLazyLoader`
+- 動的ローダーコンポーネント: `[ComponentName]LazyLoaded`
+- ファイル名: `dynamic.tsx`
+- Named Export: 全てのコンポーネントで使用
+
+---
+
+### 3. 実装パターン: モーダル
+
+#### モーダル最適化の3ケース
+
+**ケースA: 単一ファイル**
+- 現状: 単一ファイルで完結
+- 対応: ディレクトリ化 + dynamic.tsx作成
+- 所要時間: 約10分
+
+**ケースB: Container無Modal**
+- 現状: Substance と Container あり、但し Container に `<Modal>` なし
+- 対応: Container に `<Modal>` 外枠追加 + リファクタリング
+- 所要時間: 約15分
+
+**ケースC: Container有Modal** ⭐
+- 現状: 理想的な構造(V2完了済み)
+- 対応: named export化 + dynamic.tsx作成のみ
+- 所要時間: 約5分(最短経路)
+
+#### 実装例: ShortcutsModal (ケースC)
+
+**dynamic.tsx**:
+```tsx
+import type { JSX } from 'react';
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useShortcutsModalStatus } from '~/states/ui/modal/shortcuts';
+
+export const ShortcutsModalLazyLoaded = (): JSX.Element => {
+  const status = useShortcutsModalStatus();
+
+  const ShortcutsModal = useLazyLoader(
+    'shortcuts-modal',
+    () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
+    status?.isOpened ?? false,
+  );
+
+  return ShortcutsModal ? <ShortcutsModal /> : <></>;
+};
+```
+
+**index.ts**:
+```tsx
+export { ShortcutsModalLazyLoaded } from './dynamic';
+```
+
+**BasicLayout.tsx**:
+```tsx
+// Before: Next.js dynamic()
+const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
+
+// After: 直接import (named)
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
+```
+
+---
+
+### 4. 実装パターン: PageAlerts
+
+#### Container-Presentation分離による最適化
+
+**特徴**:
+- Container: 軽量な条件チェックのみ(SWR hooks を含まない)
+- Substance: UI + 状態管理 + SWR データフェッチ
+- 条件が満たされない場合、Substance は全くレンダリングされない
+
+#### 実装例: FixPageGrantAlert
+
+**構造**:
+```
+FixPageGrantAlert/
+├── FixPageGrantModal.tsx (新規) - 342行のモーダルコンポーネント
+├── FixPageGrantAlert.tsx (リファクタリング済み)
+│   ├── FixPageGrantAlert (Container) - ~35行、簡素化
+│   └── FixPageGrantAlertSubstance (Presentation) - ~30行
+└── dynamic.tsx (useLazyLoader パターン)
+```
+
+**Container** (~35行):
+```tsx
+export const FixPageGrantAlert = (): JSX.Element => {
+  const currentUser = useCurrentUser();
+  const pageData = useCurrentPageData();
+  const hasParent = pageData != null ? pageData.parent != null : false;
+  const pageId = pageData?._id;
+
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
+    currentUser != null ? pageId : null,
+  );
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(
+    currentUser != null ? pageId : null,
+  );
+
+  // Early returns for invalid states
+  if (pageData == null) return <></>;
+  if (!hasParent) return <></>;
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  // Render Substance only when all conditions are met
+  if (pageId != null && dataApplicableGrant != null) {
+    return (
+      <FixPageGrantAlertSubstance
+        pageId={pageId}
+        dataApplicableGrant={dataApplicableGrant}
+        currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+      />
+    );
+  }
+
+  return <></>;
+};
+```
+
+**効果**:
+- 条件が満たされない場合、Substance が全くレンダリングされない
+- Modal コンポーネント(342行)が別ファイルで管理しやすい
+- コードサイズ: 412行 → Container 35行 + Substance 30行 + Modal 342行(別ファイル)
+
+#### 実装例: TrashPageAlert
+
+**特徴**:
+- Container で条件チェックのみ
+- Substance 内で useSWRxPageInfo を実行(条件付き)
+
+**Container** (~20行):
+```tsx
+export const TrashPageAlert = (): JSX.Element => {
+  const pageData = useCurrentPageData();
+  const isTrashPage = useIsTrashPage();
+  const pageId = pageData?._id;
+  const pagePath = pageData?.path;
+  const revisionId = pageData?.revision?._id;
+
+  // Lightweight condition checks in Container
+  const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
+
+  // Show this alert only for non-empty pages in trash.
+  if (!isTrashPage || isEmptyPage) {
+    return <></>;
+  }
+
+  // Render Substance only when conditions are met
+  // useSWRxPageInfo will be executed only here
+  return (
+    <TrashPageAlertSubstance
+      pageId={pageId}
+      pagePath={pagePath}
+      revisionId={revisionId}
+    />
+  );
+};
+```
+
+**Substance** (~130行):
+```tsx
+const TrashPageAlertSubstance = (props: SubstanceProps): JSX.Element => {
+  const { pageId, pagePath, revisionId } = props;
+  
+  const pageData = useCurrentPageData();
+  
+  // useSWRxPageInfo is executed only when Substance is rendered
+  const { data: pageInfo } = useSWRxPageInfo(pageId);
+  
+  // ... UI レンダリング + モーダル操作
+};
+```
+
+**効果**:
+- ❌ **Before**: `useSWRxPageInfo` が常に実行される
+- ✅ **After**: Substance がレンダリングされる時のみ `useSWRxPageInfo` が実行される
+- ゴミ箱ページでない場合、不要な API 呼び出しを回避
+
+---
+
+### 5. 実装パターン: Sidebar
+
+#### AiAssistantSidebar の最適化
+
+**構造**:
+```
+AiAssistantSidebar/
+├── dynamic.tsx (新規) - useLazyLoader パターン
+├── AiAssistantSidebar.tsx (リファクタリング済み)
+│   ├── AiAssistantSidebar (Container) - 簡素化、~30行
+│   └── AiAssistantSidebarSubstance (Presentation) - 複雑なロジック、~500行
+└── (その他のサブコンポーネント)
+```
+
+**dynamic.tsx**:
+```tsx
+import type { FC } from 'react';
+import { memo } from 'react';
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useAiAssistantSidebarStatus } from '../../../states';
+
+export const AiAssistantSidebarLazyLoaded: FC = memo(() => {
+  const aiAssistantSidebarData = useAiAssistantSidebarStatus();
+  const isOpened = aiAssistantSidebarData?.isOpened ?? false;
+
+  const ComponentToRender = useLazyLoader(
+    'ai-assistant-sidebar',
+    () => import('./AiAssistantSidebar').then(mod => ({ default: mod.AiAssistantSidebar })),
+    isOpened,
+  );
+
+  if (ComponentToRender == null) {
+    return null;
+  }
+
+  return <ComponentToRender />;
+});
+```
+
+**Container の軽量化**:
+```tsx
+export const AiAssistantSidebar: FC = memo((): JSX.Element => {
+  const aiAssistantSidebarData = useAiAssistantSidebarStatus();
+  const { close: closeAiAssistantSidebar } = useAiAssistantSidebarActions();
+  const { disable: disableUnifiedMergeView } = useUnifiedMergeViewActions();
+
+  const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+  const threadData = aiAssistantSidebarData?.threadData;
+  const isOpened = aiAssistantSidebarData?.isOpened;
+  const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
+
+  // useSWRxThreads を削除(Substance に移動)
+
+  useEffect(() => {
+    if (!aiAssistantSidebarData?.isOpened) {
+      disableUnifiedMergeView();
+    }
+  }, [aiAssistantSidebarData?.isOpened, disableUnifiedMergeView]);
+
+  if (!isOpened) {
+    return <></>;
+  }
+
+  return (
+    <div className="...">
+      <AiAssistantSidebarSubstance
+        isEditorAssistant={isEditorAssistant}
+        threadData={threadData}
+        aiAssistantData={aiAssistantData}
+        onCloseButtonClicked={closeAiAssistantSidebar}
+      />
+    </div>
+  );
+});
+```
+
+**Substance に useSWRxThreads を移動**:
+```tsx
+const AiAssistantSidebarSubstance: React.FC<Props> = (props) => {
+  // useSWRxThreads is executed only when Substance is rendered
+  const { data: threads, mutate: mutateThreads } = useSWRxThreads(aiAssistantData?._id);
+  const { refreshThreadData } = useAiAssistantSidebarActions();
+
+  // refresh thread data when the data is changed
+  useEffect(() => {
+    if (threads == null) return;
+    const currentThread = threads.find(t => t.threadId === threadData?.threadId);
+    if (currentThread != null) {
+      refreshThreadData(currentThread);
+    }
+  }, [threads, refreshThreadData, threadData?.threadId]);
+
+  // ... UI レンダリング
+};
+```
+
+**効果**:
+- ❌ **Before**: Container で `useSWRxThreads` が実行される(isOpened が false でも)
+- ✅ **After**: Substance がレンダリングされる時のみ `useSWRxThreads` が実行される
+- サイドバーが開かれていない場合、不要な API 呼び出しを回避
+
+---
+
+## ✅ 完了コンポーネント一覧
+
+### モーダル (25個)
+
+#### 高頻度モーダル (0/2 - 意図的にスキップ) ⏭️
+- ⏭️ SearchModal (192行) - 検索機能、初期ロード維持
+- ⏭️ PageCreateModal (319行) - ページ作成、初期ロード維持
+
+#### 中頻度モーダル (6/6 - 100%完了) ✅
+- ✅ PageAccessoriesModal (2025-10-15) - ケースB
+- ✅ ShortcutsModal (2025-10-15) - ケースC
+- ✅ PageRenameModal (2025-10-16) - ケースC
+- ✅ PageDuplicateModal (2025-10-16) - ケースC
+- ✅ DescendantsPageListModal (2025-10-16) - ケースC
+- ✅ PageDeleteModal (2025-10-16) - ケースA
+
+#### 低頻度モーダル (19/38完了)
+
+**Session 1完了 (6個)** ✅:
+- ✅ DrawioModal (2025-10-16) - ケースC
+- ✅ HandsontableModal (2025-10-16) - ケースC + 複数ステータス対応
+- ✅ TemplateModal (2025-10-16) - ケースC + @growi/editor state
+- ✅ LinkEditModal (2025-10-16) - ケースC + @growi/editor state
+- ✅ TagEditModal (2025-10-16) - ケースC
+- ✅ ConflictDiffModal (2025-10-16) - ケースC
+
+**Session 2完了 (11個)** ✅:
+- ✅ DeleteBookmarkFolderModal (2025-10-17) - ケースC, BasicLayout
+- ✅ PutbackPageModal (2025-10-17) - ケースC, JSX→TSX変換
+- ✅ AiAssistantManagementModal (2025-10-17) - ケースC
+- ✅ PageSelectModal (2025-10-17) - ケースC
+- ✅ GrantedGroupsInheritanceSelectModal (2025-10-17) - ケースC
+- ✅ DeleteAttachmentModal (2025-10-17) - ケースC
+- ✅ PageBulkExportSelectModal (2025-10-17) - ケースC
+- ✅ PagePresentationModal (2025-10-17) - ケースC
+- ✅ EmptyTrashModal (2025-10-17) - ケースB
+- ✅ CreateTemplateModal (2025-10-17) - ケースB
+- ✅ DeleteCommentModal (2025-10-17) - ケースB
+
+**Session 3 & 4完了 (2個)** ✅:
+- ✅ SearchOptionModal (2025-10-17) - ケースA, SearchPage配下
+- ✅ DeleteAiAssistantModal (2025-10-17) - ケースC, AiAssistantSidebar配下
+
+---
+
+### PageAlerts (4個) 🎉
+
+**Session 5完了 (2025-10-17)** ✅:
+
+全てPageAlerts.tsxで`useLazyLoader`を使用した動的ロード実装に変更。
+
+1. **TrashPageAlert** (171行)
+   - **Container**: ~20行、条件チェックのみ
+   - **Substance**: ~130行、useSWRxPageInfo + UI
+   - **表示条件**: `isTrashPage`
+   - **効果**: ゴミ箱ページでない場合、useSWRxPageInfo が実行されない
+
+2. **PageRedirectedAlert** (60行)
+   - **Container**: ~12行、条件チェックのみ
+   - **Substance**: ~65行、UI + 状態管理 + 非同期処理
+   - **表示条件**: `redirectFrom != null && redirectFrom !== ''`
+   - **効果**: リダイレクトされていない場合、Substance が全くレンダリングされない
+
+3. **FullTextSearchNotCoverAlert** (40行)
+   - **isActive props パターン**: 条件付きレンダリング
+   - **表示条件**: `markdownLength > elasticsearchMaxBodyLengthToIndex`
+   - **効果**: 長いページのみで表示
+
+4. **FixPageGrantAlert** ⭐ 最重要 (412行)
+   - **構造**: Modal分離 + Container-Presentation分離
+   - **Container**: ~35行、SWR hooks + 条件チェック
+   - **Substance**: ~30行、Alert UI + Modal 状態管理
+   - **Modal**: 342行、別ファイル
+   - **表示条件**: `!dataIsGrantNormalized.isGrantNormalized`
+   - **効果**: 最大のバンドル削減、条件が満たされない場合 Substance レンダリングなし
+
+---
+
+### Sidebar (1個) ✨
+
+**Session 6完了 (2025-10-20)** ✅:
+
+**AiAssistantSidebar** (約600行)
+- **dynamic.tsx**: useLazyLoader パターン
+- **Container**: ~30行、aiAssistantSidebarData + actions
+- **Substance**: ~500行、useSWRxThreads + UI + ハンドラー
+- **最適化**:
+  - isOpened 時のみコンポーネントをロード
+  - useSWRxThreads を Substance へ移動(条件付き実行)
+  - threads のリフレッシュロジックも Substance 内に移動
+- **効果**: サイドバーが開かれていない場合、useSWRxThreads が実行されない
+
+---
+
+### 既存のLazyLoaded実装 (4個)
+
+既にuseLazyLoaderパターンで実装済み:
+- ✅ DeleteBookmarkFolderModalLazyLoaded
+- ✅ DeleteAttachmentModalLazyLoaded
+- ✅ PageSelectModalLazyLoaded
+- ✅ PutBackPageModalLazyLoaded
+
+---
+
+## ⏭️ 最適化不要/スキップ(19個)
+
+### 非モーダルコンポーネント(1個)
+- ❌ **ShowShortcutsModal** (35行) - 実体はモーダルではなくホットキートリガーのみ
+
+### 親ページ低頻度 - Me画面(2個)
+- ⏸️ **AssociateModal** (142行) - Me画面(低頻度)内のモーダル
+- ⏸️ **DisassociateModal** (94行) - Me画面(低頻度)内のモーダル
+
+### 親ページ低頻度 - Admin画面(3個)
+- ⏸️ **ImageCropModal** (194行) - Admin/Customize(低頻度)内のモーダル
+- ⏸️ **DeleteSlackBotSettingsModal** (103行) - Admin/SlackIntegration(低頻度)内のモーダル
+- ⏸️ **PluginDeleteModal** (103行) - Admin/Plugins(低頻度)内のモーダル
+
+### 低優先スキップ(1個)
+- ⏸️ **PrivateLegacyPagesMigrationModal** (133行) - ユーザー指示によりスキップ
+
+### クラスコンポーネント(2個)
+- ❌ **UserInviteModal** (299行) - .jsx、対象外
+- ❌ **GridEditModal** (263行) - .jsx、対象外
+
+### 管理画面専用・低頻度(10個)
+
+管理画面自体が遅延ロードされており、使用頻度が極めて低いため最適化不要:
+
+- SelectCollectionsModal (222行) - ExportArchiveData
+- ImportCollectionConfigurationModal (228行) - ImportData
+- NotificationDeleteModal (53行) - Notification
+- DeleteAllShareLinksModal (61行) - Security
+- LdapAuthTestModal (72行) - Security
+- ConfirmBotChangeModal (58行) - SlackIntegration
+- UpdateParentConfirmModal (93行) - UserGroupDetail
+- UserGroupUserModal (110行) - UserGroupDetail
+- UserGroupDeleteModal (208行) - UserGroup
+- UserGroupModal (138行) - ExternalUserGroupManagement
+
+---
+
+## 📈 最適化進捗チャート
+
+```
+完了済み: ████████████████████████████████████████████████████████████  34/53 (64%) 🎉
+スキップ:  ████████                                                      8/53 (15%)
+対象外:   ██                                                            2/53 (4%)
+不要:     ███████████                                                  11/53 (21%)
+```
+
+**V3最適化完了!** 🎉
+
+---
+
+## 🎉 V3最適化完了サマリー
+
+### 達成内容
+- **モーダル最適化**: 25個
+- **PageAlerts最適化**: 4個
+- **Sidebar最適化**: 1個
+- **既存LazyLoaded**: 4個
+- **合計**: 34/53 (64%)
+
+### 主要成果
+
+1. **useLazyLoader実装**: 汎用的な動的ローディングフック
+   - グローバルキャッシュによる重複実行防止
+   - 表示条件に基づく真の遅延ロード
+   - テストカバレッジ完備
+
+2. **3つのケース別最適化パターン確立**:
+   - ケースA: 単一ファイル → ディレクトリ構造化
+   - ケースB: Container-Presentation分離 (Modal外枠なし) → リファクタリング
+   - ケースC: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐
+
+3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行
+   - 全ページの初期ロード削減
+   - Container-Presentation分離による不要なレンダリング削減
+   - FixPageGrantAlert (412行) の大規模バンドル削減
+
+4. **Sidebar最適化**: AiAssistantSidebar
+   - useLazyLoader適用(isOpened時のみロード)
+   - useSWRxThreads を Substance へ移動(条件付き実行)
+
+### パフォーマンス効果
+
+- **初期バンドルサイズ削減**: 34コンポーネント分の遅延ロード(約5,000行以上)
+- **初期レンダリングコスト削減**: Container-Presentation分離による無駄なレンダリング回避
+- **メモリ効率向上**: グローバルキャッシュによる重複ロード防止
+- **API呼び出し削減**: SWR hooks の条件付き実行
+
+### 技術的成果
+
+- **Named Export標準化**: コード可読性とメンテナンス性向上
+- **型安全性保持**: ジェネリクスによる完全な型サポート
+- **開発体験向上**: 既存のインポートパスは変更不要
+- **テストカバレッジ**: useLazyLoader に12テスト
+
+---
+
+## 📝 今後の展開(オプション)
+
+### 残りの19個の評価
+
+現在スキップ・対象外としている19個について、将来的に再評価可能:
+
+1. **Me画面モーダル** (2個): Me画面自体の使用頻度が上がれば最適化検討
+2. **Admin画面モーダル** (13個): 管理機能の使用パターン変化で再評価
+3. **クラスコンポーネント** (2個): Function Component化後に最適化可能
+4. **高頻度モーダル** (2個): コード分割などの別アプローチを検討
+
+### さらなる最適化の可能性
+
+- 高頻度モーダル (SearchModal, PageCreateModal) のコード分割検討
+- 他のレイアウトでの同様パターン適用
+- ページトランジションの最適化
+- Sidebar系コンポーネントの同様最適化
+
+---
+
+## 🏆 完了日: 2025-10-20
+
+**V3最適化プロジェクト完了!** 🎉
+
+- モーダル最適化: 25個 ✅
+- PageAlerts最適化: 4個 ✅
+- Sidebar最適化: 1個 ✅
+- 既存LazyLoaded: 4個 ✅
+- 合計達成率: 64% (34/53) ✅
+- 目標達成! 🎊
+
+---
+
+## 📚 参考情報
+
+### 関連ドキュメント
+- V2完了サマリー: `apps-app-modal-performance-optimization-v2-completion-summary.md`
+- useLazyLoader実装: `apps/app/src/client/util/use-lazy-loader.ts`
+- useLazyLoaderテスト: `apps/app/src/client/util/use-lazy-loader.spec.tsx`
+
+### 重要な学び
+
+1. **正しい判断基準**:
+   - モーダル自身の利用頻度(親ページの頻度ではない)
+   - ファイルサイズ/複雑さ(50行以上で効果的、100行以上で強く推奨)
+   - レンダリングコスト
+
+2. **親の遅延ロード ≠ 子の遅延ロード**:
+   - 親がdynamic()でも、子モーダルは親と一緒にダウンロードされる
+   - 子モーダル自体の最適化が必要
+
+3. **Container-Presentation分離の効果**:
+   - Containerで条件チェック
+   - 条件が満たされない場合、Substanceは全くレンダリングされない
+   - SWR hooksの不要な実行を防止

+ 683 - 0
.serena/memories/apps-app-page-tree-specification.md

@@ -0,0 +1,683 @@
+# PageTree 仕様書
+
+## 概要
+
+GROWIのPageTreeは、`@headless-tree/react` と `@tanstack/react-virtual` を使用したVirtualized Tree実装です。
+5000件以上の兄弟ページでも快適に動作するよう設計されています。
+
+---
+
+## 1. アーキテクチャ
+
+### 1.1 ディレクトリ構成
+
+```
+src/features/page-tree/
+├── index.ts                                # メインエクスポート
+├── components/
+│   ├── ItemsTree.tsx                       # コアvirtualizedツリーコンポーネント
+│   ├── ItemsTree.spec.tsx                  # テスト
+│   ├── TreeItemLayout.tsx                  # 汎用ツリーアイテムレイアウト
+│   ├── TreeItemLayout.module.scss
+│   ├── SimpleItemContent.tsx               # シンプルなアイテムコンテンツ表示
+│   ├── SimpleItemContent.module.scss
+│   ├── TreeNameInput.tsx                   # リネーム/新規作成用入力コンポーネント
+│   ├── _tree-item-variables.scss           # SCSS変数
+│   └── index.ts
+├── hooks/
+│   ├── use-page-rename.tsx                 # Renameビジネスロジック
+│   ├── use-page-create.tsx                 # Createビジネスロジック
+│   ├── use-page-create.spec.tsx
+│   ├── use-page-dnd.tsx                    # Drag & Dropビジネスロジック
+│   ├── use-page-dnd.spec.ts
+│   ├── use-page-dnd.module.scss            # D&D用スタイル
+│   ├── use-placeholder-rename-effect.ts    # プレースホルダーリネームエフェクト
+│   ├── use-socket-update-desc-count.ts     # Socket.ioリアルタイム更新フック
+│   ├── index.ts
+│   └── _inner/
+│       ├── use-data-loader.ts              # データローダーフック
+│       ├── use-data-loader.spec.tsx
+│       ├── use-data-loader.integration.spec.tsx
+│       ├── use-scroll-to-selected-item.ts  # スクロール制御フック
+│       ├── use-tree-features.ts            # Feature統合フック(checkbox・DnD含む)
+│       ├── use-tree-revalidation.ts        # ツリー再検証フック
+│       ├── use-tree-item-handlers.tsx      # アイテムハンドラーフック
+│       ├── use-auto-expand-ancestors.ts    # 祖先自動展開フック
+│       ├── use-auto-expand-ancestors.spec.tsx
+│       ├── use-expand-parent-on-create.ts  # 作成時親展開フック
+│       ├── use-checkbox.ts                 # チェックボックス状態フック
+│       └── index.ts
+├── interfaces/
+│   └── index.ts                            # TreeItemProps, TreeItemToolProps
+├── states/
+│   ├── page-tree-update.ts                 # ツリー更新状態(Jotai)
+│   ├── page-tree-desc-count-map.ts         # 子孫カウント状態(Jotai)
+│   ├── index.ts
+│   └── _inner/
+│       ├── page-tree-create.ts             # 作成中状態(Jotai)
+│       ├── page-tree-create.spec.tsx
+│       └── tree-rebuild.ts                 # ツリー再構築状態
+├── services/
+│   └── page-tree-children.ts               # 子ページ取得サービス
+└── constants/
+    └── _inner.ts                           # ROOT_PAGE_VIRTUAL_ID
+```
+
+### 1.2 Sidebar専用コンポーネント(移動しなかったファイル)
+
+以下は `components/Sidebar/PageTreeItem/` に残留:
+
+- `PageTreeItem.tsx` - Sidebar専用の実装
+- `CountBadgeForPageTreeItem.tsx` - PageTree専用バッジ
+- `use-page-item-control.tsx` - コンテキストメニュー制御
+
+---
+
+## 2. 主要コンポーネント
+
+### 2.1 ItemsTree
+
+**ファイル**: `features/page-tree/components/ItemsTree.tsx`
+
+Virtualizedツリーのコアコンポーネント。`@headless-tree/react` と `@tanstack/react-virtual` を統合。
+
+#### Props
+
+```typescript
+interface ItemsTreeProps {
+  // 表示対象のターゲットパスまたはID
+  targetPathOrId: string | null;
+  // WIPページを表示するか
+  isWipPageShown?: boolean;
+  // 仮想スクロール用の親要素
+  scrollerElem: HTMLElement | null;
+  // カスタムTreeItemコンポーネント
+  CustomTreeItem?: React.ComponentType<TreeItemProps<IPageForTreeItem>>;
+  // チェックボックス機能
+  enableCheckboxes?: boolean;
+  initialCheckedItems?: string[];
+  onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
+}
+```
+
+#### 使用している @headless-tree/core Features
+
+- `asyncDataLoaderFeature` - 非同期データローディング
+- `selectionFeature` - 選択機能
+- `renamingFeature` - リネーム機能
+- `hotkeysCoreFeature` - キーボードショートカット
+- `checkboxesFeature` - チェックボックス(オプション)
+- `dragAndDropFeature` - ドラッグ&ドロップ(オプション)
+
+#### 重要な実装詳細
+
+1. **データローダー**: `use-data-loader.ts` で既存API(`/page-listing/root`, `/page-listing/children`)を活用
+2. **Virtualization**: `@tanstack/react-virtual` の `useVirtualizer` を使用、`overscan: 5` で最適化
+3. **初期スクロール**: `scrollToIndex` で選択アイテムまでスクロール
+
+### 2.2 TreeItemLayout
+
+**ファイル**: `features/page-tree/components/TreeItemLayout.tsx`
+
+汎用的なツリーアイテムレイアウト。展開/折りたたみ、アイコン、カスタムコンポーネントを配置。
+
+#### Props
+
+```typescript
+interface TreeItemLayoutProps {
+  page: IPageForTreeItem;
+  level: number;
+  isOpen: boolean;
+  isSelected: boolean;
+  onToggle?: () => void;
+  onClick?: () => void;
+  // カスタムコンポーネント
+  customEndComponents?: React.ReactNode[];
+  customHoveredEndComponents?: React.ReactNode[];
+  customAlternativeComponents?: React.ReactNode[];
+  showAlternativeContent?: boolean;
+}
+```
+
+#### 自動展開ロジック
+
+```typescript
+useEffect(() => {
+  if (isExpanded) return;
+  const isPathToTarget = page.path != null
+    && targetPath.startsWith(addTrailingSlash(page.path))
+    && targetPath !== page.path;
+  if (isPathToTarget) onToggle?.();
+}, [targetPath, page.path, isExpanded, onToggle]);
+```
+
+### 2.3 PageTreeItem
+
+**ファイル**: `components/Sidebar/PageTreeItem/PageTreeItem.tsx`
+
+Sidebar用のツリーアイテム実装。TreeItemLayoutを使用し、Rename/Create/Control機能を統合。
+
+#### 機能
+
+- WIPページフィルター
+- descendantCountバッジ
+- hover時の操作ボタン(duplicate/delete/rename/create)
+- リネームモード表示
+- 新規作成入力表示(子として)
+
+---
+
+## 3. 機能実装
+
+### 3.1 Rename(ページ名変更)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-rename.tsx`
+- `features/page-tree/components/TreeNameInput.tsx`
+
+#### 使用方法
+
+```typescript
+const { rename, isRenaming, RenameAlternativeComponent } = usePageRename(item);
+
+// TreeItemLayoutに渡す
+<TreeItemLayout
+  showAlternativeContent={isRenaming(item)}
+  customAlternativeComponents={[RenameAlternativeComponent]}
+/>
+```
+
+#### 操作方法
+
+- **開始**: F2キー or コンテキストメニュー
+- **確定**: Enter
+- **キャンセル**: Escape
+
+### 3.2 Create(ページ新規作成)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-create.tsx`
+- `features/page-tree/components/TreeNameInput.tsx`
+- `features/page-tree/states/_inner/page-tree-create.ts`
+
+#### 状態管理(Jotai)
+
+```typescript
+// page-tree-create.ts
+creatingParentIdAtom: 作成中の親ノードID
+useCreatingParentId(): 現在の作成中親ID取得
+useIsCreatingChild(parentId): 特定アイテムが作成中か判定
+usePageTreeCreateActions(): startCreating, cancelCreating
+```
+
+#### 使用方法
+
+```typescript
+const { isCreatingChild, CreateInputComponent, startCreating } = usePageCreate(item);
+
+// PageTreeItemで使用
+{isCreatingChild() && <CreateInputComponent />}
+```
+
+#### 操作方法
+
+- **開始**: コンテキストメニューから「作成」を選択
+- **確定**: Enter → POST /page API → 新規ページに遷移
+- **キャンセル**: Escape or ブラー
+
+### 3.3 Drag and Drop(ページ移動)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-dnd.tsx`
+- `features/page-tree/hooks/use-page-dnd.module.scss`
+- `features/page-tree/hooks/_inner/use-tree-features.ts`
+
+#### 機能概要
+
+ページをドラッグ&ドロップして別のページの子として移動する機能。複数選択D&Dにも対応。
+
+#### 使用方法
+
+```typescript
+<ItemsTree
+  enableDragAndDrop={true}
+  // ...他のprops
+/>
+```
+
+#### 主要コンポーネント
+
+- `usePageDnd(isEnabled)`: D&Dロジックを提供するフック(`UsePageDndProperties`を返す)
+  - `canDrag`: ドラッグ可否判定
+  - `canDrop`: ドロップ可否判定
+  - `onDrop`: ドロップ時の処理(APIコール、ツリー更新)
+  - `renderDragLine`: ドラッグライン描画(treeインスタンスを引数に取る)
+
+**統合方法**:
+- `useTreeFeatures`が内部で`usePageDnd`を呼び出し、`dndProperties`として返す
+- ItemsTree側で`dndProperties.renderDragLine(tree)`を呼び出してドラッグライン表示
+
+#### バリデーションロジック
+
+**canDrag チェック項目**:
+1. 祖先-子孫関係チェック: 選択されたアイテム間に祖先-子孫関係がある場合は禁止
+2. 保護ページチェック: `pagePathUtils.isUsersProtectedPages(path)`が`true`の場合は禁止
+
+**canDrop チェック項目**:
+1. ユーザートップページチェック: `pagePathUtils.isUsersTopPage(targetPath)`が`true`の場合は禁止
+2. 移動可否チェック: `pagePathUtils.canMoveByPath(fromPath, newPath)`で検証
+
+#### エラーハンドリング
+
+- `operation__blocked`エラー: 「このページは現在移動できません」トースト表示
+- その他のエラー: 「ページの移動に失敗しました」トースト表示
+
+#### ドロップ処理の流れ
+
+1. 移動APIコール: `/pages/rename`エンドポイントで各ページを新しいパスに移動
+2. SWRキャッシュ更新: `mutatePageTree()`でページツリーデータを再取得
+3. headless-tree更新: `notifyUpdateItems()`で親ノードの子リストを無効化
+4. ターゲット更新: `targetItem.invalidateItemData()`でdescendantCountを再取得
+5. 自動展開: `targetItem.expand()`でドロップ先を展開
+
+#### 制限事項
+
+- 並び替え(Reorder)は無効(子として追加のみ)
+- キーボードD&Dは非対応
+
+### 3.4 リアルタイム更新(Socket.io統合)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-socket-update-desc-count.ts`
+- `features/page-tree/states/page-tree-desc-count-map.ts`
+- `features/page-tree/states/page-tree-update.ts`
+
+#### 設計方針
+
+**descendantCountバッジの更新** と **ツリー構造の更新** は別々の関心事として分離:
+
+| 更新タイプ | トリガー | 動作 | 対象 |
+|-----------|---------|------|------|
+| バッジ更新 | Socket.io `UpdateDescCount` | 数字のみ更新(軽量) | 全祖先 |
+| ツリー構造更新 | リロードボタン / 自分の操作後 | 子リスト再取得(重い) | 操作した本人のみ |
+
+**この分離の理由:**
+- 大規模環境で多くのユーザーが同時に操作する場合、全員のツリーが頻繁に再構築されるとパフォーマンス問題が発生
+- バッジ(数字)の更新は軽量なので全員にリアルタイム反映してもOK
+- ツリー構造の変更は操作した本人のウィンドウのみで即時反映し、他ユーザーはリロードボタンで対応
+
+#### 使用方法
+
+`ItemsTree`コンポーネント内で自動的に有効化されます。
+
+```typescript
+// ItemsTree.tsx内で呼び出し
+useSocketUpdateDescCount();
+```
+
+#### 受信イベント
+
+- `UpdateDescCount`: ページの子孫カウント(descendantCount)の更新
+  - サーバーからページ作成/削除/移動時に発行される
+  - 受信データ(Record形式)をMap形式に変換してJotai stateに保存
+  - **バッジ表示のみ更新、ツリー構造は更新しない**
+
+#### 実装詳細
+
+```typescript
+export const useSocketUpdateDescCount = (): void => {
+  const socket = useGlobalSocket();
+  const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
+
+  useEffect(() => {
+    if (socket == null) return;
+
+    const handler = (data: UpdateDescCountRawData) => {
+      // バッジの数字のみ更新(ツリー構造は更新しない)
+      const newData: UpdateDescCountData = new Map(Object.entries(data));
+      updatePtDescCountMap(newData);
+    };
+
+    socket.on(SocketEventName.UpdateDescCount, handler);
+    return () => socket.off(SocketEventName.UpdateDescCount, handler);
+  }, [socket, updatePtDescCountMap]);
+};
+```
+
+#### ツリー構造の更新
+
+ツリー構造(子リスト)の更新は以下のタイミングで行われる:
+
+1. **リロードボタン**: `notifyUpdateAllTrees()` を呼び出し、全ツリーを再取得
+2. **自分の操作後**: 
+   - Create/Delete/Move操作の完了コールバックで `notifyUpdateItems([parentId])` を呼び出し
+   - 操作した親ノードの子リストのみ再取得
+
+```typescript
+// リロードボタンの例
+const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
+const handleReload = () => notifyUpdateAllTrees();
+
+// 操作完了後の例(Create, Delete, Move)
+const { notifyUpdateItems } = usePageTreeInformationUpdate();
+const handleOperationComplete = (parentId: string) => notifyUpdateItems([parentId]);
+```
+
+#### 関連状態
+
+- `page-tree-desc-count-map.ts`: 子孫カウントを管理するJotai atom
+  - `usePageTreeDescCountMap()`: カウント取得(バッジ表示用)
+  - `usePageTreeDescCountMapAction()`: カウント更新(Socket.ioから)
+
+- `page-tree-update.ts`: ツリー更新を管理するJotai atom
+  - `generationAtom`: 更新世代番号
+  - `lastUpdatedItemIdsAtom`: 更新対象アイテムID(nullは全体更新)
+  - `usePageTreeInformationUpdate()`: 更新通知(notifyUpdateItems, notifyUpdateAllTrees)
+  - `usePageTreeRevalidationEffect()`: 更新検知と再取得実行
+
+### 3.5 Checkboxes(AI Assistant用)
+
+**使用箇所**: `AiAssistantManagementPageTreeSelection.tsx`
+
+ItemsTreeのcheckboxesオプションを使用。
+
+#### Props
+
+```typescript
+<ItemsTree
+  enableCheckboxes={true}
+  initialCheckedItems={['page-id-1', 'page-id-2']}
+  onCheckedItemsChange={(checkedItems) => {
+    // チェック変更時の処理
+    // ページパスに `/*` を付加して保存
+  }}
+/>
+```
+
+#### 実装詳細
+
+**フック構成**:
+- `useTreeFeatures`: feature設定とチェックボックス・D&D機能を統合管理
+- `useCheckbox`: チェックボックス状態管理(`checkedItemIds`, `setCheckedItems`, `createNotifyEffect`)
+- `createNotifyEffect`: 親コンポーネントへの変更通知用ヘルパー関数を提供
+
+**循環依存の回避**:
+- `useTreeFeatures`はtreeインスタンスに依存しない
+- `createNotifyEffect`がtreeインスタンスとコールバックを受け取り、useEffectのコールバック関数を返す
+- ItemsTree側で`useEffect(createNotifyEffect(tree, onCheckedItemsChange), [createNotifyEffect, tree])`を呼び出す
+
+**設定**:
+- `checkboxesFeature` を条件付きで追加
+- `propagateCheckedState: false` で子への伝播を無効化
+- `canCheckFolders: true` でフォルダもチェック可能
+
+---
+
+## 4. バックエンドAPI
+
+### 4.1 使用エンドポイント
+
+```
+GET /page-listing/root
+→ ルートページ "/" のデータ
+
+GET /page-listing/children?id={pageId}
+→ 指定ページの直下の子のみ
+
+GET /page-listing/item?id={pageId}
+→ 単一ページデータ(新規追加)
+```
+
+### 4.2 IPageForTreeItem インターフェース
+
+```typescript
+interface IPageForTreeItem {
+  _id: string;
+  path: string;
+  parent?: string;
+  descendantCount: number;
+  revision?: string;
+  grant: PageGrant;
+  isEmpty: boolean;
+  wip: boolean;
+  processData?: IPageOperationProcessData;
+}
+```
+
+---
+
+## 5. @headless-tree/react 基礎知識
+
+### 5.1 データ構造
+
+- **IDベースの参照**: ツリーアイテムは文字列IDで識別
+- **フラット構造を推奨**: dataLoaderで親子関係を定義
+- **ジェネリック型対応**: `useTree<IPageForTreeItem>` でカスタム型を指定
+
+### 5.2 非同期データローダー
+
+```typescript
+const tree = useTree<IPageForTreeItem>({
+  rootItemId: "root",
+  dataLoader: {
+    getItem: async (itemId) => await api.fetchItem(itemId),
+    getChildren: async (itemId) => await api.fetchChildren(itemId),
+  },
+  createLoadingItemData: () => ({ /* loading state */ }),
+  features: [asyncDataLoaderFeature],
+});
+```
+
+#### キャッシュの無効化
+
+```typescript
+const item = tree.getItemInstance("item1");
+item.invalidateItemData();      // アイテムデータの再取得
+item.invalidateChildrenIds();   // 子IDリストの再取得
+```
+
+### 5.3 Virtualization統合
+
+```typescript
+const items = tree.getItems(); // フラット化されたアイテムリスト
+
+const virtualizer = useVirtualizer({
+  count: items.length,
+  getScrollElement: () => scrollElementRef.current,
+  estimateSize: () => 32,
+  overscan: 5,
+});
+```
+
+### 5.4 主要API
+
+#### Tree インスタンス
+- `tree.getItems()`: フラット化されたツリーアイテムのリスト
+- `tree.getItemInstance(id)`: IDからアイテムインスタンスを取得
+- `tree.getContainerProps()`: ツリーコンテナのprops(ホットキー有効化に必須)
+- `tree.rebuildTree()`: ツリー構造を再構築
+
+#### Item インスタンス
+- `item.getProps()`: アイテム要素のprops
+- `item.getId()`: アイテムID
+- `item.getItemData()`: カスタムペイロード(IPageForTreeItem)
+- `item.getItemMeta()`: メタデータ(level, indexなど)
+- `item.isFolder()`: フォルダかどうか
+- `item.isExpanded()`: 展開されているか
+- `item.expand()` / `item.collapse()`: 展開/折りたたみ
+- `item.startRenaming()`: リネームモード開始
+- `item.isRenaming()`: リネーム中か判定
+
+---
+
+## 6. パフォーマンス最適化
+
+### 6.1 headless-tree のキャッシュ無効化と再取得
+
+#### 重要な知見
+
+`@headless-tree/core` の `asyncDataLoaderFeature` は内部キャッシュを持ち、`invalidateChildrenIds()` メソッドでキャッシュを無効化できます。
+
+**invalidateChildrenIds(optimistic?: boolean) の動作:**
+
+```typescript
+// 内部実装(feature.ts より)
+invalidateChildrenIds: async ({ tree, itemId }, optimistic) => {
+  if (!optimistic) {
+    delete getDataRef(tree).current.childrenIds?.[itemId];  // キャッシュ削除
+  }
+  await loadChildrenIds(tree, itemId);  // データ再取得
+  // loadChildrenIds 内で自動的に tree.rebuildTree() が呼ばれる
+};
+```
+
+**optimistic パラメータの影響:**
+
+| パラメータ | 動作 | 用途 |
+|-----------|------|------|
+| `false` (デフォルト) | ローディング状態を更新、再レンダリングをトリガー | 最後の呼び出しに使用 |
+| `true` | ローディング状態を更新しない、古いデータを表示し続ける | バッチ処理の途中に使用 |
+
+**パフォーマンス最適化パターン:**
+
+```typescript
+// ❌ 非効率: 全アイテムに optimistic=false
+items.forEach(item => item.invalidateChildrenIds(false));
+// → 各呼び出しで rebuildTree() が実行され、N回の再構築が発生
+
+// ✅ 効率的: 展開済みアイテムのみ対象、最後だけ optimistic=false
+const expandedItems = tree.getItems().filter(item => item.isExpanded());
+expandedItems.forEach(item => item.invalidateChildrenIds(true));  // 楽観的
+rootItem.invalidateChildrenIds(false);  // 最後に1回だけ再構築
+```
+
+**実際の実装 (page-tree-update.ts):**
+
+```typescript
+useEffect(() => {
+  if (globalGeneration <= generation) return;
+
+  const shouldUpdateAll = globalLastUpdatedItemIds == null;
+
+  if (shouldUpdateAll) {
+    // pendingリクエストキャッシュをクリア
+    invalidatePageTreeChildren();
+
+    // 展開済みアイテムのみ楽観的に無効化(rebuildTree回避)
+    const expandedItems = tree.getItems().filter(item => item.isExpanded());
+    expandedItems.forEach(item => item.invalidateChildrenIds(true));
+
+    // ルートのみ optimistic=false で再構築トリガー
+    getItemInstance(ROOT_PAGE_VIRTUAL_ID)?.invalidateChildrenIds(false);
+  } else {
+    // 部分更新: 指定アイテムのみ
+    invalidatePageTreeChildren(globalLastUpdatedItemIds);
+    globalLastUpdatedItemIds.forEach(itemId => {
+      getItemInstance(itemId)?.invalidateChildrenIds(false);
+    });
+  }
+
+  onRevalidatedRef.current?.();
+}, [globalGeneration, generation, getItemInstance, globalLastUpdatedItemIds, tree]);
+```
+
+#### 注意事項
+
+1. **invalidateChildrenIds は async 関数** - Promise を返すが、await しなくても動作する
+2. **loadChildrenIds 完了後に自動で rebuildTree()** - 明示的な呼び出し不要
+3. **optimistic=true でもデータは再取得される** - ただしローディングUIは表示されない
+4. **tree.getItems() は表示中のアイテムのみ** - 折りたたまれた子は含まれない
+
+### 6.2 Virtualization
+
+- **100k+アイテムでテスト済み**
+- `overscan: 5` で表示範囲外の先読み
+- `estimateSize: 32` でアイテム高さを推定
+
+### 6.3 非同期データローダーのキャッシング
+
+- asyncDataLoaderFeatureが自動キャッシング
+- 展開済みアイテムは再取得なし
+- `invalidateChildrenIds()` で明示的に無効化可能
+
+### 6.4 ツリー更新
+
+```typescript
+// Jotai atomでツリー更新を通知
+const { notifyUpdateItems } = usePageTreeInformationUpdate();
+notifyUpdateItems(updatedPages);
+
+// SWRでページデータを再取得
+const { mutate: mutatePageTree } = useSWRxPageTree();
+await mutatePageTree();
+```
+
+---
+
+## 7. 実装済み機能
+
+- ✅ Virtualizedツリー表示
+- ✅ 展開/折りたたみ
+- ✅ ページ遷移(クリック)
+- ✅ 選択状態表示
+- ✅ WIPページフィルター
+- ✅ descendantCountバッジ
+- ✅ hover時の操作ボタン
+- ✅ 選択ページまでの自動展開
+- ✅ 選択ページへの初期スクロール
+- ✅ Rename(F2、コンテキストメニュー)
+- ✅ Create(コンテキストメニュー)
+- ✅ Duplicate(hover時ボタン)
+- ✅ Delete(hover時ボタン)
+- ✅ Checkboxes(AI Assistant用)
+- ✅ Drag and Drop(ページ移動)
+- ✅ リアルタイム更新(Socket.io統合)
+
+---
+
+## 8. 未実装機能
+
+なし(全機能実装済み)
+
+---
+
+## 9. 参考リンク
+
+- @headless-tree/react 公式ドキュメント: https://headless-tree.lukasbach.com/
+- GitHub: https://github.com/lukasbach/headless-tree
+- @tanstack/react-virtual: https://tanstack.com/virtual/latest
+
+---
+
+## 10. 改修時の注意点
+
+### 10.1 ホットキーサポート
+
+`hotkeysCoreFeature` と `getContainerProps()` の組み合わせが必須。
+`getContainerProps()` がないとホットキーが動作しない。
+
+### 10.2 ツリー更新の通知
+
+操作完了後は以下を呼び出す:
+1. `mutatePageTree()` - SWRでデータ再取得
+2. `notifyUpdateItems()` - Jotai atomで更新通知
+
+### 10.3 旧実装について
+
+以下のファイルはTypeScriptエラーあり(許容):
+- `ItemsTree.tsx` - 旧実装
+- `PageTreeItem.tsx` - 旧Sidebar用
+- `TreeItemForModal.tsx` - 旧Modal用
+
+---
+
+## 更新履歴
+
+- 2025-11-10: 初版作成(Virtualization計画)
+- 2025-11-28: Rename/Create実装完了、ディレクトリ再編成
+- 2025-12-05: 仕様書として統合
+- 2025-12-08: Drag and Drop実装完了、ディレクトリ構成更新
+- 2025-12-08: リアルタイム更新(Socket.io統合)実装完了
+- 2025-12-08: headless-tree キャッシュ無効化の知見を追加(invalidateChildrenIds の optimistic パラメータ)
+- 2025-12-08: Socket.io更新の設計方針を明確化(バッジ更新とツリー構造更新の分離)
+- 2025-12-09: useTreeFeaturesリファクタリング完了(checkboxとDnD機能を統合、循環依存を回避)

+ 0 - 186
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -1,186 +0,0 @@
-# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
-
-## 🎯 目標
-現在のパフォーマンス問題を解決:
-- **問題**: 5000件の兄弟ページで初期レンダリングが重い
-- **目標**: 表示速度を10-20倍改善、UX維持
-
-## ✅ 戦略2: API軽量化 - **完了済み**
-
-### 実装済み内容
-- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
-- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
-- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
-- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
-
-### 実現できた効果
-- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
-- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
-- **状況**: **実装完了・効果発現中**
-
----
-
-## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
-
-### 前回のreact-window失敗原因
-1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
-2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
-3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
-
-### 現実的制約の認識
-**ItemsTree/TreeItemLayoutは廃止困難**:
-- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
-- **共通副作用処理**: rename/duplicate/delete時のmutation処理
-- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
-
-## 📋 修正された実装戦略: **ハイブリッドアプローチ**
-
-### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
-
-**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
-
-1. **ItemsTree**: UIロジック・副作用処理はそのまま
-2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
-3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
-4. **Virtualization**: ItemsTree内部にreact-virtualを導入
-
-### **実装計画: 段階的移行**
-
-#### **Phase 1: データ層のheadless-tree化**
-
-**ファイル**: `ItemsTree.tsx`
-```typescript
-// Before: 複雑なSWR + 子コンポーネント管理
-const tree = useTree<IPageForTreeItem>({
-  rootItemId: initialItemNode.page._id,
-  dataLoader: {
-    getItem: async (itemId) => {
-      const response = await apiv3Get('/page-listing/item', { id: itemId });
-      return response.data;
-    },
-    getChildren: async (itemId) => {
-      const response = await apiv3Get('/page-listing/children', { id: itemId });
-      return response.data.children.map(child => child._id);
-    },
-  },
-  features: [asyncDataLoaderFeature],
-});
-
-// 既存のCustomTreeItemに渡すためのアダプター
-const adaptedNodes = tree.getItems().map(item => 
-  new ItemNode(item.getItemData())
-);
-
-return (
-  <ul className={`${moduleClass} list-group`}>
-    {adaptedNodes.map(node => (
-      <CustomTreeItem
-        key={node.page._id}
-        itemNode={node}
-        // ... 既存のpropsをそのまま渡す
-        onRenamed={onRenamed}
-        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickDeleteMenuItem={onClickDeleteMenuItem}
-      />
-    ))}
-  </ul>
-);
-```
-
-#### **Phase 2: Virtualization導入**
-
-**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
-```typescript
-const virtualizer = useVirtualizer({
-  count: adaptedNodes.length,
-  getScrollElement: () => containerRef.current,
-  estimateSize: () => 40,
-});
-
-return (
-  <div ref={containerRef} className="tree-container">
-    <div style={{ height: virtualizer.getTotalSize() }}>
-      {virtualizer.getVirtualItems().map(virtualItem => {
-        const node = adaptedNodes[virtualItem.index];
-        return (
-          <div
-            key={node.page._id}
-            style={{
-              position: 'absolute',
-              top: virtualItem.start,
-              height: virtualItem.size,
-              width: '100%',
-            }}
-          >
-            <CustomTreeItem
-              itemNode={node}
-              // ... 既存props
-            />
-          </div>
-        );
-      })}
-    </div>
-  </div>
-);
-```
-
-#### **Phase 3 (将来): 完全なheadless-tree移行**
-
-最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
-
-## 📁 現実的なファイル変更まとめ
-
-| アクション | ファイル | 内容 | スコープ |
-|---------|---------|------|------|
-| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
-| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
-| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
-| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
-| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
-| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
-
-**新規ファイル**: 1個(データローダー分離のみ)  
-**変更ファイル**: 2個(ItemsTree改修 + store整理)  
-**削除ファイル**: 0個(既存アーキテクチャ尊重)
-
----
-
-## 🎯 実装優先順位
-
-**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
-
-**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
-- **工数**: 2-3日
-- **リスク**: 低(外部IF変更なし)
-- **効果**: 非同期ローディング最適化、キャッシュ改善
-
-**📋 Phase 2-B**: Virtualization導入  
-- **工数**: 2-3日
-- **リスク**: 低(内部実装のみ)
-- **効果**: レンダリング性能10-20倍改善
-
-**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
-**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
-
----
-
-## 🏗️ 実装方針: **既存アーキテクチャ尊重**
-
-**基本方針**:
-- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
-- **データ管理層のみ**をheadless-tree化  
-- **外部インターフェース**は変更せず、内部最適化に集中
-- **段階的移行**で低リスク実装
-
-**今回のスコープ**:
-- ✅ 非同期データローディング最適化
-- ✅ Virtualizationによる大量要素対応  
-- ❌ drag&drop/selection(将来フェーズ)
-- ❌ 既存アーキテクチャの破壊的変更
-
----
-
-## 技術的参考資料
-- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
-- **react-virtual**: @tanstack/react-virtualを使用  
-- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 35 - 0
.serena/memories/apps-app-technical-specs.md

@@ -0,0 +1,35 @@
+# apps/app 技術仕様
+
+## ファイル構造・命名
+- Next.js: `*.page.tsx`
+- テスト: `*.spec.ts`, `*.integ.ts`
+- コンポーネント: `ComponentName.tsx`
+
+## API構造
+- **API v3**: `server/routes/apiv3/` (RESTful + OpenAPI準拠)
+- **Features API**: `features/*/server/routes/`
+
+## 状態管理
+- **Jotai** (推奨): `states/` - アトミック分離
+- **SWR**: `stores/` - データフェッチ・キャッシュ
+
+## データベース
+- **Mongoose**: `server/models/` (スキーマ定義)
+- **Serializers**: `serializers/` (レスポンス変換)
+
+## セキュリティ・i18n
+- **認証**: 複数プロバイダー + アクセストークン
+- **XSS対策**: `services/general-xss-filter/`
+- **i18n**: next-i18next (サーバー・クライアント両対応)
+
+## システム機能
+- **検索**: Elasticsearch統合
+- **監視**: OpenTelemetry (`features/opentelemetry/`)
+- **プラグイン**: 動的読み込み (`features/growi-plugin/`)
+
+## 開発ガイドライン
+1. 新機能は `features/` 実装
+2. TypeScript strict準拠
+3. Jotai状態管理優先
+4. API v3形式
+5. セキュリティ・i18n・テスト必須

+ 0 - 45
.serena/memories/development_environment.md

@@ -1,45 +0,0 @@
-# 開発環境とツール
-
-## 推奨システム要件
-- **Node.js**: ^20 || ^22
-- **パッケージマネージャー**: pnpm 10.4.1
-- **OS**: Linux(Ubuntuベース)、macOS、Windows
-
-## 利用可能なLinuxコマンド
-基本的なLinuxコマンドが利用可能:
-- `apt`, `dpkg`: パッケージ管理
-- `git`: バージョン管理
-- `curl`, `wget`: HTTP クライアント
-- `ssh`, `scp`, `rsync`: ネットワーク関連
-- `ps`, `lsof`, `netstat`, `top`: プロセス・ネットワーク監視
-- `tree`, `find`, `grep`: ファイル検索・操作
-- `zip`, `unzip`, `tar`, `gzip`, `bzip2`, `xz`: アーカイブ操作
-
-## 開発用ブラウザ
-```bash
-# ローカルサーバーをブラウザで開く
-"$BROWSER" http://localhost:3000
-```
-
-## 環境変数管理
-- **dotenv-flow**: 環境ごとの設定管理
-- 環境ファイル:
-  - `.env.development`: 開発環境
-  - `.env.production`: 本番環境
-  - `.env.test`: テスト環境
-  - `.env.*.local`: ローカル固有設定
-
-## デバッグ
-```bash
-# デバッグモードでサーバー起動
-cd apps/app && pnpm run dev  # --inspectフラグ付きでnodemon起動
-
-# REPL(Read-Eval-Print Loop)
-cd apps/app && pnpm run repl
-```
-
-## VS Code設定
-`.vscode/` ディレクトリに設定ファイルが含まれており、推奨拡張機能や設定が適用される。
-
-## Docker対応
-各アプリケーションにDockerファイルが含まれており、コンテナベースでの開発も可能。

+ 390 - 0
.serena/memories/nextjs-pages-router-getLayout-pattern.md

@@ -0,0 +1,390 @@
+# Next.js Pages Router における getLayout パターン完全ガイド
+
+## getLayout パターンの基本概念と仕組み
+
+getLayout パターンは、Next.js Pages Router における**ページごとのレイアウト定義を可能にする強力なアーキテクチャパターン**です。このパターンを使用することで、各ページが独自のレイアウト階層を静的な `getLayout` 関数を通じて定義できます。
+
+### 技術的な仕組み
+
+getLayout パターンは React のコンポーネントツリー構成を活用して動作します:
+
+```typescript
+// pages/dashboard.tsx
+import DashboardLayout from '../components/DashboardLayout'
+
+const Dashboard = () => <div>ダッシュボードコンテンツ</div>
+
+Dashboard.getLayout = function getLayout(page) {
+  return <DashboardLayout>{page}</DashboardLayout>
+}
+
+export default Dashboard
+
+// pages/_app.tsx
+export default function MyApp({ Component, pageProps }) {
+  const getLayout = Component.getLayout ?? ((page) => page)
+  return getLayout(<Component {...pageProps} />)
+}
+```
+
+**動作原理:**
+1. Next.js がページを初期化する際、`getLayout` プロパティをチェック
+2. `getLayout` 関数がページコンポーネントを受け取り、完全なレイアウトツリーを返す
+3. React の差分アルゴリズムがコンポーネントツリーの同じ位置を維持し、効率的な差分更新を実現
+
+## パフォーマンス向上の具体的なメリット
+
+### レンダリング回数の削減
+
+getLayout パターンの最大の利点は、**ページ遷移時のレイアウトコンポーネントの再マウント防止**です。React の差分アルゴリズムは、コンポーネントツリーの同じ位置に同じタイプのコンポーネントが存在する場合、そのインスタンスを再利用します。
+
+**実測データ(Zenn.dev の事例):**
+```
+実装前:
+├ /_app      97.7 kB (全ページで Recoil を含む)
+├ /articles  98 kB
+├ /profile   98 kB
+
+実装後:
+├ /_app      75 kB (22.7 kB 削減)
+├ /articles  75.3 kB (最適化されたバンドル)
+├ /profile   98.3 kB (必要な依存関係のみ)
+```
+
+### メモリ効率の改善
+
+**主要な最適化ポイント:**
+- **状態の永続化**: 入力値、スクロール位置、コンポーネント状態がナビゲーション間で保持
+- **イベントリスナーの永続性**: イベントハンドラーの再アタッチ回避
+- **DOM 参照の安定性**: サードパーティ統合用の DOM ノード参照の維持
+
+## 実装のベストプラクティス
+
+### TypeScript での型安全な実装
+
+```typescript
+// types/layout.ts
+import type { NextPage } from 'next'
+import type { AppProps } from 'next/app'
+import type { ReactElement, ReactNode } from 'react'
+
+export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
+  getLayout?: (page: ReactElement) => ReactNode
+}
+
+export type AppPropsWithLayout = AppProps & {
+  Component: NextPageWithLayout
+}
+
+// pages/_app.tsx
+import type { AppPropsWithLayout } from '../types/layout'
+
+export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
+  const getLayout = Component.getLayout ?? ((page) => page)
+  return getLayout(<Component {...pageProps} />)
+}
+```
+
+### ネストレイアウトの実装
+
+```typescript
+// utils/nestLayout.ts
+export function nestLayout(
+  parentLayout: (page: ReactElement) => ReactNode,
+  childLayout: (page: ReactElement) => ReactNode
+) {
+  return (page: ReactElement) => parentLayout(childLayout(page))
+}
+
+// pages/dashboard/profile.tsx
+import { nestLayout } from '../../utils/nestLayout'
+import { getLayout as getBaseLayout } from '../../components/BaseLayout'
+import { getLayout as getDashboardLayout } from '../../components/DashboardLayout'
+
+const ProfilePage: NextPageWithLayout = () => {
+  return <div>プロフィールコンテンツ</div>
+}
+
+ProfilePage.getLayout = nestLayout(getBaseLayout, getDashboardLayout)
+```
+
+### 状態管理の最適化
+
+```typescript
+// レイアウトごとのコンテキスト分割
+const AuthLayout = ({ children }) => (
+  <AuthProvider>
+    <UserProvider>
+      {children}
+    </UserProvider>
+  </AuthProvider>
+)
+
+const PublicLayout = ({ children }) => (
+  <ThemeProvider>
+    {children}
+  </ThemeProvider>
+)
+
+// 各ページで適切なレイアウトを選択
+Page.getLayout = (page) => <AuthLayout>{page}</AuthLayout>
+```
+
+## バッドプラクティスと実装時の落とし穴
+
+### 避けるべきアンチパターン
+
+**❌ レイアウトの再作成**
+```typescript
+// 悪い例:レイアウトの永続性が失われる
+const BadPage = () => {
+  return (
+    <Layout>
+      <div>ページコンテンツ</div>
+    </Layout>
+  )
+}
+
+// ✅ 良い例:getLayout パターンを使用
+const GoodPage = () => <div>ページコンテンツ</div>
+GoodPage.getLayout = (page) => <Layout>{page}</Layout>
+```
+
+**❌ _app.tsx での条件付きレンダリング**
+```typescript
+// 悪い例:レイアウトの再マウントを引き起こす
+function MyApp({ Component, pageProps, router }) {
+  if (router.pathname.startsWith('/dashboard')) {
+    return <DashboardLayout><Component {...pageProps} /></DashboardLayout>
+  }
+  return <Component {...pageProps} />
+}
+```
+
+### メモリリークの防止
+
+```typescript
+// ✅ 適切なクリーンアップ
+const Layout = ({ children }) => {
+  useEffect(() => {
+    const handleResize = () => { /* 処理 */ }
+    
+    window.addEventListener('resize', handleResize)
+    
+    return () => {
+      window.removeEventListener('resize', handleResize)
+    }
+  }, [])
+
+  return <div>{children}</div>
+}
+```
+
+## 他のレイアウト管理手法との比較
+
+### Pages Router 内での比較
+
+| 手法 | 複雑度 | パフォーマンス | 柔軟性 | 学習曲線 |
+|------|--------|----------------|--------|----------|
+| getLayout | 中 | 高 | 高 | 中 |
+| HOCs | 高 | 中 | 高 | 高 |
+| _app.js ルーティング | 低 | 高 | 低 | 低 |
+| Context ベース | 高 | 中 | 高 | 高 |
+| ラッパーコンポーネント | 低 | 低 | 低 | 低 |
+
+### Next.js 13+ App Router との比較
+
+**App Router の利点:**
+- ビルトインのレイアウトネスティング
+- ファイルシステムベースの直感的な構造
+- 自動的な状態永続化
+- `loading.js` と `error.js` による組み込みの状態管理
+
+**getLayout パターンの利点:**
+- 明示的なレイアウト制御
+- 成熟した安定したパターン
+- シンプルなメンタルモデル
+- 優れた TTFB パフォーマンス
+
+**パフォーマンス比較:**
+- **TTFB**: Pages Router が App Router より最大 2 倍高速
+- **開発サーバー**: Pages Router がより安定
+- **バンドルサイズ**: getLayout により選択的な読み込みが可能
+
+## SEO と SSR/SSG への影響
+
+### Core Web Vitals への影響
+
+**測定された改善効果:**
+- **LCP (Largest Contentful Paint)**: レイアウトの永続化により改善
+- **INP (Interaction to Next Paint)**: JavaScript 実行時間の削減
+- **CLS (Cumulative Layout Shift)**: レイアウトシフトの除去
+
+**Netflix の事例:**
+- Time-to-Interactive が **50% 削減**
+- JavaScript バンドルサイズが **200KB 削減**
+- デスクトップユーザーの 97% が高速な First Input Delay を体験
+
+### SSR/SSG との統合
+
+```typescript
+// SSR との完全な互換性
+export async function getServerSideProps() {
+  const data = await fetchData()
+  return { props: { data } }
+}
+
+function Page({ data }) {
+  return <div>{data.content}</div>
+}
+
+Page.getLayout = (page) => <Layout>{page}</Layout>
+```
+
+## 実際のプロジェクトでの活用例
+
+### 企業での実装事例
+
+**Netflix:**
+- ログアウト済みホームページで Time-to-Interactive を 50% 削減
+- 戦略的なプリフェッチで後続ページロードを 30% 改善
+
+**Hulu:**
+- Next.js による統一されたフロントエンドアーキテクチャ
+- CSS-in-JS の自動コード分割を実装
+
+**Sonos:**
+- ビルド時間を **75% 短縮**
+- パフォーマンススコアを **10% 改善**
+
+## パフォーマンス測定と最適化
+
+### 測定ツールの設定
+
+```javascript
+// next.config.js - Bundle Analyzer の設定
+const withBundleAnalyzer = require('@next/bundle-analyzer')({
+  enabled: process.env.ANALYZE === 'true',
+});
+
+module.exports = withBundleAnalyzer(nextConfig);
+
+// 使用方法
+// ANALYZE=true npm run build
+```
+
+### React DevTools Profiler の活用
+
+```javascript
+import { Profiler } from 'react';
+
+function onRenderCallback(id, phase, actualDuration, baseDuration) {
+  console.log({ id, phase, actualDuration, baseDuration });
+}
+
+<Profiler id="LayoutProfile" onRender={onRenderCallback}>
+  <MyLayout>{children}</MyLayout>
+</Profiler>
+```
+
+### 最適化テクニック
+
+**メモ化の実装:**
+```typescript
+import { memo, useMemo, useCallback } from 'react'
+
+const Layout = memo(({ children, menuItems }) => {
+  const processedMenu = useMemo(() => 
+    menuItems.filter(item => item.visible).sort(), 
+    [menuItems]
+  );
+  
+  const handleNavigation = useCallback((path) => {
+    router.push(path);
+  }, [router]);
+  
+  return (
+    <div>
+      <Navigation items={processedMenu} onNavigate={handleNavigation} />
+      {children}
+    </div>
+  );
+});
+```
+
+**動的インポートによるコード分割:**
+```typescript
+import dynamic from 'next/dynamic';
+
+const DynamicSidebar = dynamic(() => import('../components/Sidebar'), {
+  loading: () => <SidebarSkeleton />,
+  ssr: false
+});
+
+const Layout = ({ children }) => (
+  <div>
+    <Header />
+    <DynamicSidebar />
+    <main>{children}</main>
+  </div>
+);
+```
+
+### パフォーマンスバジェットの実装
+
+```javascript
+export const PERFORMANCE_BUDGETS = {
+  layoutRenderTime: 16, // 60fps のための 16ms
+  memoryUsage: 50 * 1024 * 1024, // 50MB
+  bundleSize: 200 * 1024, // 200KB
+  firstContentfulPaint: 2000, // 2秒
+};
+
+const measureLayoutPerformance = (layoutName, renderFn) => {
+  const start = performance.now();
+  const result = renderFn();
+  const duration = performance.now() - start;
+  
+  if (duration > PERFORMANCE_BUDGETS.layoutRenderTime) {
+    console.warn(`Layout ${layoutName} がレンダーバジェットを超過: ${duration}ms`);
+  }
+  
+  return result;
+};
+```
+
+## 実装チェックリスト
+
+### 初期設定
+- [ ] TypeScript の型定義を設定
+- [ ] `_app.tsx` に getLayout パターンを実装
+- [ ] React DevTools をインストール
+- [ ] Bundle Analyzer を設定
+
+### 最適化の優先順位
+
+**高影響・低労力:**
+- [ ] レイアウトコンポーネントに React.memo を実装
+- [ ] Bundle Analyzer で大きな依存関係を特定
+- [ ] Context Provider をレイアウトごとに分割
+
+**中影響・中労力:**
+- [ ] 非クリティカルなレイアウトコンポーネントに動的インポートを実装
+- [ ] Suspense 境界を追加してストリーミングを改善
+- [ ] 自動パフォーマンス監視を設定
+
+**高影響・高労力:**
+- [ ] 状態管理アーキテクチャの再設計
+- [ ] 包括的なプログレッシブエンハンスメントの実装
+- [ ] 高度なパフォーマンスバジェットシステムの作成
+
+## まとめ
+
+getLayout パターンは、Next.js Pages Router において**強力なパフォーマンス最適化とアーキテクチャの柔軟性**を提供します。適切に実装すれば、以下の利点が得られます:
+
+1. **パフォーマンスの向上**: 不要な再レンダリングの削減とバンドルサイズの最適化
+2. **ユーザー体験の向上**: 状態の永続化とスムーズなページ遷移
+3. **アーキテクチャの柔軟性**: ページごとのレイアウトカスタマイズとパフォーマンスの維持
+4. **メモリ効率**: コンポーネントの再利用による最適なリソース使用
+
+App Router が新しい代替手段を提供する一方で、getLayout パターンの理解は React のレンダリング最適化とコンポーネントライフサイクル管理への深い洞察を提供します。Pages Router アプリケーションでは、プロジェクトの開始時から getLayout を実装することで、アプリケーションのスケールに応じて最大限のパフォーマンス利点とアーキテクチャの柔軟性を維持できます。

+ 441 - 0
.serena/memories/page-state-hooks-useLatestRevision-degradation.md

@@ -0,0 +1,441 @@
+# Page State Hooks - useLatestRevision リファクタリング記録
+
+**Date**: 2025-10-31
+**Branch**: support/use-jotai
+
+## 🎯 実施内容のサマリー
+
+`support/use-jotai` ブランチで `useLatestRevision` が機能していなかった問題を解決し、リビジョン管理の状態管理を大幅に改善しました。
+
+### 主な成果
+
+1. ✅ `IPageInfoForEntity.latestRevisionId` を導入
+2. ✅ `useSWRxIsLatestRevision` を SWR ベースで実装(Jotai atom から脱却)
+3. ✅ `remoteRevisionIdAtom` を完全削除(状態管理の簡素化)
+4. ✅ `useIsRevisionOutdated` の意味論を改善(「意図的な過去閲覧」を考慮)
+5. ✅ `useRevisionIdFromUrl` で URL パラメータ取得を一元化
+
+---
+
+## 📋 実装の要点
+
+### 1. `IPageInfoForEntity` に `latestRevisionId` を追加
+
+**ファイル**: `packages/core/src/interfaces/page.ts`
+
+```typescript
+export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
+  // ... existing fields
+  latestRevisionId?: string;  // ✅ 追加
+};
+```
+
+**ファイル**: `apps/app/src/server/service/page/index.ts:2605`
+
+```typescript
+const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
+  // ... existing fields
+  latestRevisionId: page.revision != null ? getIdStringForRef(page.revision) : undefined,
+};
+```
+
+**データフロー**: SSR で `constructBasicPageInfo` が自動的に `latestRevisionId` を設定 → `useSWRxPageInfo` で参照
+
+---
+
+### 2. `useSWRxIsLatestRevision` を SWR ベースで実装
+
+**ファイル**: `stores/page.tsx:164-191`
+
+```typescript
+export const useSWRxIsLatestRevision = (): SWRResponse<boolean, Error> => {
+  const currentPage = useCurrentPageData();
+  const pageId = currentPage?._id;
+  const shareLinkId = useShareLinkId();
+  const { data: pageInfo } = useSWRxPageInfo(pageId, shareLinkId);
+
+  const latestRevisionId = pageInfo && 'latestRevisionId' in pageInfo
+    ? pageInfo.latestRevisionId
+    : undefined;
+
+  const key = useMemo(() => {
+    if (currentPage?.revision?._id == null) {
+      return null;
+    }
+    return ['isLatestRevision', currentPage.revision._id, latestRevisionId ?? null];
+  }, [currentPage?.revision?._id, latestRevisionId]);
+
+  return useSWRImmutable(
+    key,
+    ([, currentRevisionId, latestRevisionId]) => {
+      if (latestRevisionId == null) {
+        return true;  // Assume latest if not available
+      }
+      return latestRevisionId === currentRevisionId;
+    },
+  );
+};
+```
+
+**使用箇所**: OldRevisionAlert, DisplaySwitcher, PageEditorReadOnly
+
+**判定**: `.data !== false` で「古いリビジョン」を検出
+
+---
+
+### 3. `remoteRevisionIdAtom` の完全削除
+
+**削除理由**:
+- `useSWRxPageInfo.data.latestRevisionId` で代替可能
+- 「Socket.io 更新検知」と「最新リビジョン保持」の用途が混在していた
+- 状態管理が複雑化していた
+
+**重要**: `RemoteRevisionData.remoteRevisionId` は型定義に残した
+→ コンフリクト解決時に「どのリビジョンに対して保存するか」の情報として必要
+
+---
+
+### 4. `useIsRevisionOutdated` の意味論的改善
+
+**改善前**: 単純に「現在のリビジョン ≠ 最新リビジョン」を判定
+**問題**: URL `?revisionId=xxx` で意図的に過去を見ている場合も `true` を返していた
+
+**改善後**: 「ユーザーが意図的に過去リビジョンを見ているか」を考慮
+
+**ファイル**: `states/context.ts:82-100`
+
+```typescript
+export const useRevisionIdFromUrl = (): string | undefined => {
+  const router = useRouter();
+  const revisionId = router.query.revisionId;
+  return typeof revisionId === 'string' ? revisionId : undefined;
+};
+
+export const useIsViewingSpecificRevision = (): boolean => {
+  const revisionId = useRevisionIdFromUrl();
+  return revisionId != null;
+};
+```
+
+**ファイル**: `stores/page.tsx:193-219`
+
+```typescript
+export const useIsRevisionOutdated = (): boolean => {
+  const { data: isLatestRevision } = useSWRxIsLatestRevision();
+  const isViewingSpecificRevision = useIsViewingSpecificRevision();
+
+  // If user intentionally views a specific revision, don't show "outdated" alert
+  if (isViewingSpecificRevision) {
+    return false;
+  }
+
+  if (isLatestRevision == null) {
+    return false;
+  }
+
+  // User expects latest, but it's not latest = outdated
+  return !isLatestRevision;
+};
+```
+
+---
+
+## 🎭 動作例
+
+| 状況 | isLatestRevision | isViewingSpecificRevision | isRevisionOutdated | 意味 |
+|------|------------------|---------------------------|---------------------|------|
+| 最新を表示中 | true | false | false | 正常 |
+| Socket.io更新を受信 | false | false | **true** | 「再fetchせよ」 |
+| URL `?revisionId=old` で過去を閲覧 | false | true | false | 「意図的な過去閲覧」 |
+
+---
+
+## 🔄 現状の remoteRevision 系 atom と useSetRemoteLatestPageData
+
+### 削除済み
+- ✅ `remoteRevisionIdAtom` - 完全削除(`useSWRxPageInfo.data.latestRevisionId` で代替)
+
+### 残存している atom(未整理)
+- ⚠️ `remoteRevisionBodyAtom` - ConflictDiffModal で使用
+- ⚠️ `remoteRevisionLastUpdateUserAtom` - ConflictDiffModal, PageStatusAlert で使用
+- ⚠️ `remoteRevisionLastUpdatedAtAtom` - ConflictDiffModal で使用
+
+### `useSetRemoteLatestPageData` の役割
+
+**定義**: `states/page/use-set-remote-latest-page-data.ts`
+
+```typescript
+export type RemoteRevisionData = {
+  remoteRevisionId: string;      // 型には含むが atom には保存しない
+  remoteRevisionBody: string;
+  remoteRevisionLastUpdateUser?: IUserHasId;
+  remoteRevisionLastUpdatedAt: Date;
+};
+
+export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => {
+  // remoteRevisionBodyAtom, remoteRevisionLastUpdateUserAtom, remoteRevisionLastUpdatedAtAtom を更新
+  // remoteRevisionId は atom に保存しない(コンフリクト解決時のパラメータとしてのみ使用)
+};
+```
+
+**使用箇所**(6箇所):
+
+1. **`page-updated.ts`** - Socket.io でページ更新受信時
+   ```typescript
+   // 他のユーザーがページを更新したときに最新リビジョン情報を保存
+   setRemoteLatestPageData({
+     remoteRevisionId: s2cMessagePageUpdated.revisionId,
+     remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
+     remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
+     remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
+   });
+   ```
+
+2. **`page-operation.ts`** - 自分がページ保存した後(`useUpdateStateAfterSave`)
+   ```typescript
+   // 自分が保存した後の最新リビジョン情報を保存
+   setRemoteLatestPageData({
+     remoteRevisionId: updatedPage.revision._id,
+     remoteRevisionBody: updatedPage.revision.body,
+     remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
+     remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
+   });
+   ```
+
+3. **`conflict.tsx`** - コンフリクト解決時(`useConflictResolver`)
+   ```typescript
+   // コンフリクト発生時にリモートリビジョン情報を保存
+   setRemoteLatestPageData(remoteRevidsionData);
+   ```
+
+4. **`drawio-modal-launcher-for-view.ts`** - Drawio 編集でコンフリクト発生時
+5. **`handsontable-modal-launcher-for-view.ts`** - Handsontable 編集でコンフリクト発生時
+6. **定義ファイル自体**
+
+### 現在のデータフロー
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Socket.io / 保存処理 / コンフリクト                  │
+└─────────────────────────────────────────────────────┘
+                    ↓
+┌─────────────────────────────────────────────────────┐
+│ useSetRemoteLatestPageData                          │
+│  ├─ remoteRevisionBodyAtom ← body                   │
+│  ├─ remoteRevisionLastUpdateUserAtom ← user         │
+│  └─ remoteRevisionLastUpdatedAtAtom ← date          │
+│  (remoteRevisionId は保存しない)                    │
+└─────────────────────────────────────────────────────┘
+                    ↓
+┌─────────────────────────────────────────────────────┐
+│ 使用箇所                                             │
+│  ├─ ConflictDiffModal: body, user, date を表示     │
+│  └─ PageStatusAlert: user を表示                    │
+└─────────────────────────────────────────────────────┘
+```
+
+### 問題点
+
+1. **PageInfo (latestRevisionId) との同期がない**:
+   - Socket.io 更新時に `remoteRevision*` atom は更新される
+   - しかし `useSWRxPageInfo.data.latestRevisionId` は更新されない
+   - → `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` がリアルタイム更新を検知できない
+
+2. **用途が限定的**:
+   - 主に ConflictDiffModal でリモートリビジョンの詳細を表示するために使用
+   - PageStatusAlert でも使用しているが、本来は `useIsRevisionOutdated()` で十分
+
+3. **データの二重管理**:
+   - リビジョン ID: `useSWRxPageInfo.data.latestRevisionId` で管理
+   - リビジョン詳細 (body, user, date): atom で管理
+   - 一貫性のないデータ管理
+
+---
+
+## 🎯 次に取り組むべきタスク
+
+### PageInfo (useSWRxPageInfo) の mutate が必要な3つのタイミング
+
+#### 1. 🔴 SSR時の optimistic update
+
+**問題**:
+- SSR で `pageWithMeta.meta` (IPageInfoForEntity) が取得されているが、`useSWRxPageInfo` のキャッシュに入っていない
+- クライアント初回レンダリング時に PageInfo が未取得状態になる
+
+**実装方針**:
+```typescript
+// [[...path]]/index.page.tsx または適切な場所
+const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
+
+useEffect(() => {
+  if (pageWithMeta?.meta) {
+    mutatePageInfo(pageWithMeta.meta, { revalidate: false });
+  }
+}, [pageWithMeta?.meta, mutatePageInfo]);
+```
+
+**Note**:
+- Jotai の hydrate とは別レイヤー(Jotai は atom、これは SWR のキャッシュ)
+- `useSWRxPageInfo` は既に `initialData` パラメータを持っているが、呼び出し側で渡していない
+- **重要**: `mutatePageInfo` は bound mutate(hook から返されるもの)を使う
+
+---
+
+#### 2. 🔴 same route 遷移時の mutate
+
+**問題**:
+- `[[...path]]` ルート内での遷移(例: `/pageA` → `/pageB`)時に PageInfo が更新されない
+- `useFetchCurrentPage` が新しいページを取得しても PageInfo は古いまま
+
+**実装方針**:
+```typescript
+// states/page/use-fetch-current-page.ts
+export const useFetchCurrentPage = () => {
+  const shareLinkId = useAtomValue(shareLinkIdAtom);
+  const revisionIdFromUrl = useRevisionIdFromUrl();
+
+  // ✅ 追加: PageInfo の mutate 関数を取得
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPageId, shareLinkId);
+
+  const fetchCurrentPage = useAtomCallback(
+    useCallback(async (get, set, args) => {
+      // ... 既存のフェッチ処理 ...
+
+      const { data } = await apiv3Get('/page', params);
+      const { page: newData } = data;
+
+      set(currentPageDataAtom, newData);
+      set(currentPageEntityIdAtom, newData._id);
+      set(currentPageEmptyIdAtom, undefined);
+
+      // ✅ 追加: PageInfo を再フェッチ
+      mutatePageInfo();  // 引数なし = revalidate (再フェッチ)
+
+      return newData;
+    }, [shareLinkId, revisionIdFromUrl, mutatePageInfo])
+  );
+};
+```
+
+**Note**:
+- `mutatePageInfo()` を引数なしで呼ぶと SWR が再フェッチする
+- `/page` API からは meta が取得できないため、再フェッチが必要
+
+---
+
+#### 3. 🔴 Socket.io 更新時の mutate
+
+**問題**:
+- Socket.io で他のユーザーがページを更新したとき、`useSWRxPageInfo` のキャッシュが更新されない
+- `latestRevisionId` が古いままになる
+- **重要**: `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` が正しく動作しない
+
+**実装方針**:
+```typescript
+// client/services/side-effects/page-updated.ts
+const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id, shareLinkId);
+
+const remotePageDataUpdateHandler = useCallback((data) => {
+  const { s2cMessagePageUpdated } = data;
+
+  // 既存: remoteRevision atom を更新
+  setRemoteLatestPageData(remoteData);
+
+  // ✅ 追加: PageInfo の latestRevisionId を optimistic update
+  if (currentPage?._id != null) {
+    mutatePageInfo((currentPageInfo) => {
+      if (currentPageInfo && 'latestRevisionId' in currentPageInfo) {
+        return {
+          ...currentPageInfo,
+          latestRevisionId: s2cMessagePageUpdated.revisionId,
+        };
+      }
+      return currentPageInfo;
+    }, { revalidate: false });
+  }
+}, [currentPage?._id, mutatePageInfo, setRemoteLatestPageData]);
+```
+
+**Note**:
+- 引数に updater 関数を渡して既存データを部分更新
+- `revalidate: false` で再フェッチを抑制(optimistic update のみ)
+
+---
+
+### SWR の mutate の仕組み
+
+**Bound mutate** (推奨):
+```typescript
+const { data, mutate } = useSWRxPageInfo(pageId, shareLinkId);
+mutate(newData, options);  // 自動的に key に紐付いている
+```
+
+**グローバル mutate**:
+```typescript
+import { mutate } from 'swr';
+mutate(['/page/info', pageId, shareLinkId, isGuestUser], newData, options);
+```
+
+**optimistic update のオプション**:
+- `{ revalidate: false }` - 再フェッチせず、キャッシュのみ更新
+- `mutate()` (引数なし) - 再フェッチ
+- `mutate(updater, options)` - updater 関数で部分更新
+
+---
+
+### 🟡 優先度 中: PageStatusAlert の重複ロジック削除
+
+**ファイル**: `src/client/components/PageStatusAlert.tsx`
+
+**現状**: 独自に `isRevisionOutdated` を計算している
+**提案**: `useIsRevisionOutdated()` を使用
+
+---
+
+### 🟢 優先度 低
+
+- テストコードの更新
+- `initLatestRevisionField` の役割ドキュメント化
+
+---
+
+## 📊 アーキテクチャの改善
+
+### Before (問題のある状態)
+
+```
+┌─────────────────────┐
+│ latestRevisionAtom  │ ← atom(true) でハードコード(機能せず)
+└─────────────────────┘
+┌─────────────────────┐
+│ remoteRevisionIdAtom│ ← 複数の用途で混在(Socket.io更新 + 最新リビジョン保持)
+└─────────────────────┘
+```
+
+### After (改善後)
+
+```
+┌──────────────────────────────┐
+│ useSWRxPageInfo              │
+│  └─ data.latestRevisionId    │ ← SSR で自動設定、SWR でキャッシュ管理
+└──────────────────────────────┘
+        ↓
+┌──────────────────────────────┐
+│ useSWRxIsLatestRevision()        │ ← SWR ベース、汎用的な状態確認
+└──────────────────────────────┘
+        ↓
+┌──────────────────────────────┐
+│ useIsRevisionOutdated()      │ ← 「再fetch推奨」のメッセージ性
+│  + useIsViewingSpecificRevision│ ← URL パラメータを考慮
+└──────────────────────────────┘
+```
+
+---
+
+## ✅ メリット
+
+1. **状態管理の簡素化**: Jotai atom を削減、SWR の既存インフラを活用
+2. **データフローの明確化**: SSR → SWR → hooks という一貫した流れ
+3. **意味論の改善**: `useIsRevisionOutdated` が「再fetch推奨」を正確に表現
+4. **保守性の向上**: URL パラメータ取得を `useRevisionIdFromUrl` に集約
+5. **型安全性**: `IPageInfoForEntity` で厳密に型付け

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

@@ -0,0 +1,65 @@
+# ページ遷移とレンダリングのデータフロー
+
+このドキュメントは、GROWIのページ遷移からレンダリングまでのデータフローを解説します。
+
+## 登場人物
+
+1.  **`[[...path]].page.tsx`**: Next.js の動的ルーティングを担うメインコンポーネント。サーバーサイドとクライアントサイドの両方で動作します。
+2.  **`useSameRouteNavigation.ts`**: クライアントサイドでのパス変更を検知し、データ取得を**トリガー**するフック。
+3.  **`useFetchCurrentPage.ts`**: データ取得と関連する Jotai atom の更新を一元管理するフック。データ取得が本当に必要かどうかの最終判断も担います。
+4.  **`useShallowRouting.ts`**: サーバーサイドで正規化されたパスとブラウザのURLを同期させるフック。
+5.  **`server-side-props.ts`**: サーバーサイドレンダリング(SSR)時にページデータを取得し、`props` としてページコンポーネントに渡します。
+
+---
+
+## フロー1: サーバーサイドレンダリング(初回アクセス時)
+
+ユーザーがURLに直接アクセスするか、ページをリロードした際のフローです。
+
+1.  **リクエスト受信**: サーバーがユーザーからのリクエスト(例: `/user/username/memo`)を受け取ります。
+2.  **`getServerSideProps` の実行**:
+    - `server-side-props.ts` の `getServerSidePropsForInitial` が実行されます。
+    - `retrievePageData` が呼び出され、パスの正規化(例: `/user/username` → `/user/username/`)が行われ、APIからページデータを取得します。
+    - 取得したデータと、正規化後のパス (`currentPathname`) を `props` として `[[...path]].page.tsx` に渡します。
+3.  **コンポーネントのレンダリングとJotai Atomの初期化**:
+    - `[[...path]].page.tsx` は `props` を受け取り、そのデータで `currentPageDataAtom` などのJotai Atomを初期化します。
+    - `PageView` などのコンポーネントがサーバーサイドでレンダリングされます。
+4.  **クライアントサイドでのハイドレーションとURL正規化**:
+    - レンダリングされたHTMLがブラウザに送信され、Reactがハイドレーションを行います。
+    - **`useShallowRouting`** が実行され、ブラウザのURL (`/user/username/memo`) と `props.currentPathname` (`/user/username/memo/`) を比較します。
+    - 差異がある場合、`router.replace` を `shallow: true` で実行し、ブラウザのURLをサーバーが認識している正規化後のパスに静かに更新します。
+
+---
+
+## フロー2: クライアントサイドナビゲーション(`<Link>` クリック時)
+
+アプリケーション内でページ間を移動する際のフローです。
+
+1.  **ナビゲーション開始**:
+    - ユーザーが `<Link href="/new/page">` をクリックします。
+    - Next.js の `useRouter` がURLの変更を検出し、`[[...path]].page.tsx` が再評価されます。
+2.  **`useSameRouteNavigation` によるトリガー**:
+    - このフックの `useEffect` が `router.asPath` の変更 (`/new/page`) を検知します。
+    - **`fetchCurrentPage({ path: '/new/page' })`** を呼び出します。このフックは常にデータ取得を試みます。
+3.  **`useFetchCurrentPage` によるデータ取得の判断と実行**:
+    - `fetchCurrentPage` 関数が実行されます。
+    - **3a. パスの前処理**:
+        - まず、引数で渡された `path` をデコードします(例: `encoded%2Fpath` → `encoded/path`)。
+        - 次に、パスがパーマリンク形式(例: `/65d4e0a0f7b7b2e5a8652e86`)かどうかを判定します。
+    - **3b. 重複取得の防止(ガード節)**:
+        - 前処理したパスや、パーマリンクから抽出したページIDが、現在Jotaiで管理されているページのパスやIDと同じでないかチェックします。
+        - 同じであれば、APIを叩かずに処理を中断し、現在のページデータを返します。
+    - **3c. 読み込み状態開始**: `pageLoadingAtom` を `true` に設定します。
+    - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
+4.  **アトミックな状態更新**:
+    - **API成功時**:
+        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
+        - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
+    - **APIエラー時 (例: 404 Not Found)**:
+        - `pageErrorAtom` にエラーオブジェクトを設定します。
+        - `pageNotFoundAtom` を `true` に設定します。
+        - 最後に `pageLoadingAtom` を `false` に設定します。
+5.  **`PageView` の最終レンダリング**:
+    - `currentPageDataAtom` の更新がトリガーとなり、`PageView` コンポーネントが新しいデータで再レンダリングされます。
+6.  **副作用の実行**:
+    - `useSameRouteNavigation` 内で `fetchCurrentPage` が完了した後、`mutateEditingMarkdown` が呼び出され、エディタの状態が更新されます。

+ 0 - 100
.serena/memories/suggested_commands.md

@@ -1,100 +0,0 @@
-# 推奨開発コマンド集
-
-## セットアップ
-```bash
-# 初期セットアップ
-pnpm run bootstrap
-# または
-pnpm install
-```
-
-## 開発サーバー
-```bash
-# メインアプリケーション開発モード
-cd /workspace/growi/apps/app && pnpm run dev
-
-# ルートから起動(本番用ビルド後)
-pnpm start
-```
-
-## ビルド
-```bash
-# メインアプリケーションのビルド
-pnpm run app:build
-
-# Slackbot Proxyのビルド
-pnpm run slackbot-proxy:build
-
-# 全体ビルド(Turboで並列実行)
-turbo run build
-```
-
-## Lint・フォーマット
-```bash
-# 全てのLint実行
-pnpm run lint
-```
-
-## apps/app の Lint・フォーマット
-```bash
-# 【推奨】Biome実行(lint + format)
-cd /workspace/growi/apps/app pnpm run lint:biome
-
-# 【過渡期】ESLint実行(廃止予定)
-cd /workspace/growi/apps/app pnpm run lint:eslint
-
-# Stylelint実行
-cd /workspace/growi/apps/app pnpm run lint:styles
-
-# 全てのLint実行
-cd /workspace/growi/apps/app pnpm run lint
-
-# TypeScript型チェック
-cd /workspace/growi/apps/app pnpm run lint:typecheck
-```
-
-## テスト
-```bash
-# 【推奨】Vitestテスト実行
-pnpm run test:vitest
-
-# 【過渡期】Jest(統合テスト)(廃止予定)
-pnpm run test:jest
-
-# 全てのテスト実行(過渡期対応)
-pnpm run test
-
-# Vitestで特定のファイルに絞って実行
-pnpm run test:vitest {target-file-name}
-
-# E2Eテスト(Playwright)
-npx playwright test
-```
-
-## データベース関連
-```bash
-# マイグレーション実行
-cd apps/app && pnpm run migrate
-
-# 開発環境でのマイグレーション
-cd apps/app && pnpm run dev:migrate
-
-# マイグレーション状態確認
-cd apps/app && pnpm run dev:migrate:status
-```
-
-## その他の便利コマンド
-```bash
-# REPL起動
-cd apps/app && pnpm run repl
-
-# OpenAPI仕様生成
-cd apps/app && pnpm run openapi:generate-spec:apiv3
-
-# クリーンアップ
-cd apps/app && pnpm run clean
-```
-
-## 注意事項
-- ESLintとJestは廃止予定のため、新規開発ではBiomeとVitestを使用してください
-- 既存のコードは段階的に移行中です

+ 41 - 42
.serena/memories/tech_stack.md

@@ -1,42 +1,41 @@
-# 技術スタック
-
-## プログラミング言語
-- **TypeScript**: メイン言語(~5.0.0)
-- **JavaScript**: 一部のコンポーネント
-
-## フロントエンド
-- **Next.js**: Reactベースのフレームワーク
-- **React**: UIライブラリ
-- **Vite**: ビルドツール、開発サーバー
-- **SCSS**: スタイルシート
-- **SWR**: グローバルステート管理、データフェッチ・キャッシュ管理(^2.3.2)
-
-## バックエンド
-- **Node.js**: ランタイム(^20 || ^22)
-- **Express.js**: Webフレームワーク(推測)
-- **MongoDB**: データベース
-- **Mongoose**: MongoDB用ORM(^6.13.6)
-  - mongoose-gridfs: GridFS対応(^1.2.42)
-  - mongoose-paginate-v2: ページネーション(^1.3.9)
-  - mongoose-unique-validator: バリデーション(^2.0.3)
-
-## 開発ツール
-- **pnpm**: パッケージマネージャー(10.4.1)
-- **Turbo**: モノレポビルドシステム(^2.1.3)
-- **ESLint**: Linter(weseek設定を使用)【廃止予定 - 現在は過渡期】
-- **Biome**: 統一予定のLinter/Formatter
-- **Stylelint**: CSS/SCSSのLinter
-- **Jest**: テスティングフレームワーク【廃止予定 - 現在は過渡期】
-- **Vitest**: 高速テスティングフレームワーク【統一予定】
-- **Playwright**: E2Eテスト【統一予定】
-
-## その他のツール
-- **SWC**: TypeScriptコンパイラー(高速)
-- **ts-node**: TypeScript直接実行
-- **nodemon**: 開発時のホットリロード
-- **dotenv-flow**: 環境変数管理
-- **Swagger/OpenAPI**: API仕様
-
-## 移行計画
-- **Linter**: ESLint → Biome に統一予定
-- **テスト**: Jest → Vitest + Playwright に統一予定
+# 技術スタック & 開発環境
+
+## コア技術
+- **TypeScript** ~5.0.0 + **Next.js** (React)
+- **Node.js** ^20||^22 + **MongoDB** + **Mongoose** ^6.13.6
+- **pnpm** 10.4.1 + **Turbo** ^2.1.3 (モノレポ)
+
+## 状態管理・データ
+- **Jotai**: アトミック状態管理(推奨)
+- **SWR** ^2.3.2: データフェッチ・キャッシュ
+
+## 開発ツール移行状況
+| 従来 | 移行先 | 状況 |
+|------|--------|------|
+| ESLint | **Biome** | 新規推奨 |
+| Jest | **Vitest** + **Playwright** | 新規推奨 |
+
+## 主要コマンド
+```bash
+# 開発
+cd apps/app && pnpm run dev
+
+# 品質チェック
+pnpm run lint:biome        # 新規推奨
+pnpm run lint:typecheck    # 型チェック正式コマンド
+pnpm run test:vitest       # 新規推奨
+
+# ビルド
+pnpm run app:build
+turbo run build           # 並列ビルド
+```
+
+## ファイル命名規則
+- Next.js: `*.page.tsx`
+- テスト: `*.spec.ts` (Vitest), `*.integ.ts`
+- コンポーネント: `ComponentName.tsx`
+
+## API・アーキテクチャ
+- **API v3**: `server/routes/apiv3/` (RESTful + OpenAPI)
+- **Features**: `features/*/` (機能別分離)
+- **SCSS**: CSS Modules使用

+ 95 - 0
.serena/memories/vitest-testing-tips-and-best-practices.md

@@ -0,0 +1,95 @@
+# Vitest + TypeScript Testing Guide
+
+## 核心技術要素
+
+### tsconfig.json最適設定
+```json
+{
+  "compilerOptions": {
+    "types": ["vitest/globals"]  // グローバルAPI: describe, it, expect等をインポート不要化
+  }
+}
+```
+
+### vitest-mock-extended: 型安全モッキング
+```typescript
+import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
+
+// 完全型安全なNext.js Routerモック
+const mockRouter: DeepMockProxy<NextRouter> = mockDeep<NextRouter>();
+mockRouter.asPath = '/test-path';  // TypeScript補完・型チェック有効
+
+// 複雑なUnion型も完全サポート
+interface ComplexProps {
+  currentPageId?: string | null;
+  currentPathname?: string | null;
+}
+const mockProps: DeepMockProxy<ComplexProps> = mockDeep<ComplexProps>();
+```
+
+### React Testing Library + Jotai統合
+```typescript
+const renderWithProvider = (ui: React.ReactElement, scope?: Scope) => {
+  const Wrapper = ({ children }: { children: React.ReactNode }) => (
+    <Provider scope={scope}>{children}</Provider>
+  );
+  return render(ui, { wrapper: Wrapper });
+};
+```
+
+## 実践パターン
+
+### 非同期テスト
+```typescript
+import { waitFor, act } from '@testing-library/react';
+
+await act(async () => {
+  result.current.triggerAsyncAction();
+});
+
+await waitFor(() => {
+  expect(result.current.isLoading).toBe(false);
+});
+```
+
+### 詳細アサーション
+```typescript
+expect(mockFunction).toHaveBeenCalledWith(
+  expect.objectContaining({
+    pathname: '/expected-path',
+    data: expect.any(Object)
+  })
+);
+```
+
+## 実行コマンド
+
+### 基本テスト実行
+```bash
+# Vitest単体
+pnpm run test:vitest
+
+# Vitest単体(coverageあり)
+pnpm run test:vitest:coverage
+
+# 特定ファイルのみ実行(coverageあり)
+pnpm run test:vitest src/path/to/test.spec.tsx
+```
+
+### package.jsonスクリプト参照
+```json
+{
+  "scripts": {
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
+    "test:vitest": "vitest run --coverage"
+  }
+}
+```
+
+## Jest→Vitest移行要点
+- `jest.config.js` → `vitest.config.ts`
+- `@types/jest` → `vitest/globals`
+- ESModulesネイティブサポート → 高速起動・実行
+
+この設定により型安全性と保守性を両立した高品質テストが可能。

+ 10 - 0
.serena/serena_config.yml

@@ -0,0 +1,10 @@
+web_dashboard: false
+# whether to open the Serena web dashboard (which will be accessible through your web browser) that
+# shows Serena's current session logs - as an alternative to the GUI log window which
+# is supported on all platforms.
+
+web_dashboard_open_on_launch: false
+# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard
+# is enabled). If set to False, you can still open the dashboard manually by navigating to
+# http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard).
+# If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc.

+ 3 - 1
CLAUDE.md

@@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 ## Language
 
-If it is detected at the start or during a session that the user's primary language is not English, always respond in that language from then on. However, technical terms may remain in English as needed.
+If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, we may retain technical terms in English if necessary.
+
+When generating source code, all comments and explanations within the code will be written in English.
 
 ## Project Overview
 

+ 42 - 12
apps/app/.eslintrc.js

@@ -32,23 +32,13 @@ module.exports = {
     'src/linter-checker/**',
     'src/migrations/**',
     'src/models/**',
-    'src/features/callout/**',
-    'src/features/comment/**',
-    'src/features/templates/**',
-    'src/features/mermaid/**',
-    'src/features/search/**',
-    'src/features/plantuml/**',
-    'src/features/external-user-group/**',
-    'src/features/page-bulk-export/**',
-    'src/features/growi-plugin/**',
-    'src/features/opentelemetry/**',
-    'src/features/openai/**',
-    'src/features/rate-limiter/**',
+    'src/features/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/utils/**',
     'src/components/**',
     'src/services/**',
+    'src/states/**',
     'src/stores/**',
     'src/pages/**',
     'src/server/crowi/**',
@@ -58,9 +48,48 @@ module.exports = {
     'src/server/util/**',
     'src/server/app.ts',
     'src/server/repl.ts',
+    'src/server/middlewares/**',
     'src/server/routes/*.js',
     'src/server/routes/*.ts',
     'src/server/routes/attachment/**',
+    'src/server/routes/apiv3/interfaces/**',
+    'src/server/routes/apiv3/pages/**',
+    'src/server/routes/apiv3/user/**',
+    'src/server/routes/apiv3/personal-setting/**',
+    'src/server/routes/apiv3/security-settings/**',
+    'src/server/routes/apiv3/app-settings/**',
+    'src/server/routes/apiv3/page/**',
+    'src/server/routes/apiv3/*.js',
+    'src/server/routes/apiv3/*.ts',
+    'src/server/service/*.ts',
+    'src/server/service/*.js',
+    'src/server/service/access-token/**',
+    'src/server/service/config-manager/**',
+    'src/server/service/page/**',
+    'src/server/service/page-listing/**',
+    'src/server/service/revision/**',
+    'src/server/service/s2s-messaging/**',
+    'src/server/service/search-delegator/**',
+    'src/server/service/search-reconnect-context/**',
+    'src/server/service/slack-command-handler/**',
+    'src/server/service/slack-event-handler/**',
+    'src/server/service/socket-io/**',
+    'src/server/service/system-events/**',
+    'src/server/service/user-notification/**',
+    'src/server/service/yjs/**',
+    'src/server/service/file-uploader/**',
+    'src/server/service/global-notification/**',
+    'src/server/service/growi-bridge/**',
+    'src/server/service/growi-info/**',
+    'src/server/service/import/**',
+    'src/server/service/in-app-notification/**',
+    'src/server/service/interfaces/**',
+    'src/server/service/normalize-data/**',
+    'src/server/service/page/**',
+    'src/client/interfaces/**',
+    'src/client/models/**',
+    'src/client/services/**',
+    'src/client/util/**',
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
@@ -69,6 +98,7 @@ module.exports = {
     },
   },
   rules: {
+    'space-before-function-paren': 'off',
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

+ 1 - 1
apps/app/bin/github-actions/update-readme.sh

@@ -2,4 +2,4 @@
 
 cd docker
 
-sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`7\.0\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/apps\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md
+sed -i -e "s/^\([*] \[\`\)[^\`]\+\(\`, \`7\.4\`, .\+\]\)\(.\+\/blob\/v\).\+\(\/apps\/app\/docker\/Dockerfile.\+\)$/\1${RELEASED_VERSION}\2\3${RELEASED_VERSION}\4/" README.md

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

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

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

@@ -10,9 +10,9 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 ------------------------------------------------
 
-* [`7.1.0`, `7.1`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.1.0/apps/app/docker/Dockerfile)
-* [`7.0.23`, `7.0` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.0.23/apps/app/docker/Dockerfile)
-* [`6.3.2`, `6.3`, `6` (Dockerfile)](https://github.com/growilabs/growi/blob/v6.3.2/apps/app/docker/Dockerfile)
+* [`7.4.0`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.0/apps/app/docker/Dockerfile)
+* [`7.3.0`, `7.3` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.3.0/apps/app/docker/Dockerfile)
+* [`7.2.0`, `7.2` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.2.0/apps/app/docker/Dockerfile)
 
 
 What is GROWI?

+ 40 - 0
apps/app/docs/plan/README.md

@@ -0,0 +1,40 @@
+# React State Management Documentation
+
+# React State Management Documentation
+
+## Current Documentation
+
+### [`jotai-migration.md`](jotai-migration.md)
+Jotai移行のガイドドキュメント(実装方針・パターン)
+
+- 移行方針と背景
+- 実装パターンとガイドライン
+- 判断基準とベストプラクティス
+- 移行の成果と技術スタック
+
+### [`jotai-migration-progress.md`](jotai-migration-progress.md)
+実装進捗と次のステップ(随時更新)
+
+- 完了済み実装の一覧
+- 次の実装ステップと優先順位
+- 進捗サマリーと更新履歴
+
+---
+
+## ドキュメント構造について
+
+### 設計方針
+**役割分離**: 安定的なガイドと頻繁に更新される進捗を分離
+
+- **`jotai-migration.md`**: 実装方針とパターン(安定的)
+- **`jotai-migration-progress.md`**: 進捗と次のステップ(頻繁更新)
+
+### メリット
+- **メンテナンス性向上**: 更新頻度に応じた適切な構造
+- **情報の明確化**: 各ドキュメントの責務が明確
+- **開発効率向上**: 必要な情報に素早くアクセス可能
+
+### 推奨される利用方法
+1. **新規参入者**: `jotai-migration.md` で実装方針とパターンを理解
+2. **開発者**: `jotai-migration-progress.md` で次のタスクを確認
+3. **レビュー**: `jotai-migration.md` の実装パターンで一貫性を保証

+ 4 - 1
apps/app/next.config.js

@@ -58,6 +58,7 @@ const getTranspilePackages = () => {
     'github-slugger',
     'html-url-attributes',
     'estree-util-is-identifier-name',
+    'superjson',
     ...listPrefixedPackages([
       'remark-',
       'rehype-',
@@ -159,8 +160,10 @@ module.exports = async (phase) => {
   };
 
   // production server
+  // Skip withSuperjson() in production server phase because the pages directory
+  // doesn't exist in the production build and withSuperjson() tries to find it
   if (phase === PHASE_PRODUCTION_SERVER) {
-    return withSuperjson()(nextConfig);
+    return nextConfig;
   }
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({

+ 11 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.3.9",
+  "version": "7.4.0-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -127,7 +127,6 @@
     "diff_match_patch": "^0.1.1",
     "dotenv-flow": "^3.2.0",
     "ejs": "^3.1.10",
-    "esa-node": "^0.2.2",
     "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
     "express": "^4.20.0",
@@ -147,6 +146,8 @@
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
+    "jotai": "^2.12.3",
+    "js-cookie": "^3.0.5",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.1",
     "jsonrepair": "^3.12.0",
@@ -175,7 +176,7 @@
     "next": "^14.2.32",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^0.0.4",
+    "next-superjson": "^1.0.7",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
@@ -190,6 +191,7 @@
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
+    "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
     "qs": "^6.11.1",
     "rate-limiter-flexible": "^2.3.7",
@@ -230,7 +232,7 @@
     "sanitize-filename": "^1.6.3",
     "socket.io": "^4.7.5",
     "string-width": "=4.2.2",
-    "superjson": "^1.9.1",
+    "superjson": "^2.2.2",
     "swagger-jsdoc": "^6.2.8",
     "swr": "^2.3.2",
     "throttle-debounce": "^5.0.0",
@@ -269,10 +271,13 @@
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
     "@handsontable/react": "=2.1.0",
+    "@headless-tree/core": "^1.5.1",
+    "@headless-tree/react": "^1.5.1",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
+    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
@@ -280,6 +285,7 @@
     "@types/express": "^4.17.21",
     "@types/hast": "^3.0.4",
     "@types/jest": "^29.5.2",
+    "@types/js-cookie": "^3.0.6",
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
     "@types/node-cron": "^3.0.11",
@@ -315,6 +321,7 @@
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
+    "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "mdast-util-directive": "^3.0.0",

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 5 - 26
apps/app/public/static/locales/en_US/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_mail_visibility": "Disclose e-mail for new users",
+    "default_read_only_for_new_user": "Editing Restrictions for New Users",
+    "set_read_only_for_new_user": "Set new users to read-only mode",
     "file_uploading": "File uploading",
-    "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
-    "attach_enable": "You can attach files other than image files if you enable this option.",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "enable_page_bulk_export": "Enable bulk export",
     "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
@@ -448,10 +448,7 @@
     "customize_settings": "Customize",
     "default_sidebar_mode": {
       "title": "Default sidebar mode",
-      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
-      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
-      "dock_mode_default_open": "Open the page as it was opened from the beginning",
-      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+      "desc": "You can set the sidebar mode for new users and guests visiting the page."
     },
     "layout": "Layout",
     "layout_options": {
@@ -531,7 +528,7 @@
     "page_path": "Page Path",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
-    "import_growi_archive": "Import GROWI archive",
+    "import_growi_archive": "Import Archive Data",
     "error": {
       "only_upsert_available": "Only 'Upsert' option is available for pages collection."
     },
@@ -577,23 +574,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to esa"
-    },
-    "qiita_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to qiita:team"
-    },
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "prepare_new_account_for_migration": "Prepare new account for migration",
     "archive_data_import_detail": "More Details? Ckick here.",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "Pages with a name that already exists on GROWI are not imported",
-    "Directory_hierarchy_tag": "Directory hierarchy tag"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "Export Archive Data",
@@ -1015,12 +1000,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",
     "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Discard Archived Data",
-    "ADMIN_ESA_DATA_IMPORTED": "Import from esa.io",
-    "ADMIN_ESA_DATA_UPDATED": "Update esa.io import settings",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Test connection to esa",
-    "ADMIN_QIITA_DATA_IMPORTED": "Import from Qiita:Team",
-    "ADMIN_QIITA_DATA_UPDATED": "Update Qiita:Team import settings",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Test connection to Qiita:Team",
     "ADMIN_ARCHIVE_DATA_CREATE": "Create Archived Data",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data",
     "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data",

+ 5 - 26
apps/app/public/static/locales/fr_FR/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
+    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
     "file_uploading": "Téléversement de fichiers",
-    "enable_files_except_image": "Autoriser tout les types de fichiers",
-    "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
@@ -448,10 +448,7 @@
     "customize_settings": "Interface",
     "default_sidebar_mode": {
       "title": "Barre latérale",
-      "desc": "Mode d'affichage et comportement par défaut de la barre latérale.",
-      "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
-      "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
-      "dock_mode_default_close": "Afficher la page comme si elle était fermée"
+      "desc": "Mode d'affichage et comportement par défaut de la barre latérale."
     },
     "layout": "Largeur du contenu",
     "layout_options": {
@@ -531,7 +528,7 @@
     "page_path": "Chemin de page",
     "beta_warning": "Cette fonctionnalité est en beta.",
     "import_from": "Importer depuis {{from}}",
-    "import_growi_archive": "Importer une archive GROWI",
+    "import_growi_archive": "Importer les données d'archive",
     "error": {
       "only_upsert_available": "Seul l'option 'Upsert' est disponible pour les collections de pages"
     },
@@ -577,23 +574,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "Nom de l'équipe",
-      "access_token": "Jeton d'accès",
-      "test_connection": "Essai de la connection esa"
-    },
-    "qiita_settings": {
-      "team_name": "Nom de l'équipe",
-      "access_token": "Jeton d'accès",
-      "test_connection": "Essai de la connection qiita:team"
-    },
     "import": "Importer",
     "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement",
     "prepare_new_account_for_migration": "Préparer le compte pour la migration",
     "archive_data_import_detail": "En savoir plus",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "Les pages ayant le nom d'une page déjà existante ne seront pas importées.",
-    "Directory_hierarchy_tag": "Tag de hiérarchie"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "Archive de données d'export",
@@ -1014,12 +999,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Téléverser les données d'archive",
     "ADMIN_GROWI_DATA_IMPORTED": "Importer les données d'archive",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Supprimer les données d'archive",
-    "ADMIN_ESA_DATA_IMPORTED": "Importer depuis esa.io",
-    "ADMIN_ESA_DATA_UPDATED": "Mettre à jour les paramètres d'import esa.io",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Tester la connexion esa",
-    "ADMIN_QIITA_DATA_IMPORTED": "Importer depuis Qiita:Team",
-    "ADMIN_QIITA_DATA_UPDATED": "Mettre à jour les paramètres d'import Qiita:Team",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Tester la connexion Qiita:Team",
     "ADMIN_ARCHIVE_DATA_CREATE": "Créer données d'archive",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Télécharger les données d'archive",
     "ADMIN_ARCHIVE_DATA_DELETE": "Supprimer les données d'archive",

+ 5 - 27
apps/app/public/static/locales/ja_JP/admin.json

@@ -365,9 +365,9 @@
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_mail_visibility": "新規ユーザーの初期メール公開設定",
+    "default_read_only_for_new_user": "新規ユーザーの編集制限",
+    "set_read_only_for_new_user": "新規ユーザーを閲覧専用にする",
     "file_uploading": "ファイルアップロード",
-    "enable_files_except_image": "画像以外のファイルアップロードを許可",
-    "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "page_bulk_export_settings": "ページ一括エクスポート設定",
     "enable_page_bulk_export": "一括エクスポートを有効にする",
     "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
@@ -457,10 +457,7 @@
     "customize_settings": "カスタマイズ",
     "default_sidebar_mode": {
       "title": "デフォルトのサイドバーモード",
-      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
-      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
-      "dock_mode_default_open": "初めから開いた状態でページを開く",
-      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。"
     },
     "layout": "レイアウト",
     "layout_options": {
@@ -540,7 +537,7 @@
     "page_path": "ページパス",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
-    "import_growi_archive": "GROWI アーカイブをインポート",
+    "import_growi_archive": "データインポート",
     "error": {
       "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
     },
@@ -586,23 +583,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "チーム名",
-      "access_token": "アクセストークン",
-      "test_connection": "接続テスト"
-    },
-    "qiita_settings": {
-      "team_name": "チーム名",
-      "access_token": "アクセストークン",
-      "test_connection": "接続テスト"
-    },
     "import": "インポート",
     "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。",
     "prepare_new_account_for_migration": "移行用のアカウントを新環境で用意してください。",
     "archive_data_import_detail": "参考: GROWI Docs - データのインポート",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88",
-    "page_skip": "既に GROWI 側に同名のページが存在する場合、そのページはスキップされます",
-    "Directory_hierarchy_tag": "ディレクトリ階層タグ"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/ja/admin-guide/management-cookbook/import.html#growi-%E3%82%A2%E3%83%BC%E3%82%AB%E3%82%A4%E3%83%96%E3%83%87%E3%83%BC%E3%82%BF%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88"
   },
   "export_management": {
     "export_archive_data": "データアーカイブ",
@@ -811,7 +796,6 @@
       "inactive": "非アクティブ"
     }
   },
-
   "user_group_management": {
     "user_group_management": "グループ管理",
     "create_group": "新規グループの作成",
@@ -1025,12 +1009,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",
     "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "アーカイブデータの破棄",
-    "ADMIN_ESA_DATA_IMPORTED": "esa.io からインポート",
-    "ADMIN_ESA_DATA_UPDATED": "esa.io のインポート設定の更新",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa.io の接続テスト",
-    "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team からのインポート",
-    "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team のインポート設定の更新",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team の接続テスト",
     "ADMIN_ARCHIVE_DATA_CREATE": "アーカイブデータの作成",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード",
     "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除",

+ 5 - 26
apps/app/public/static/locales/ko_KR/admin.json

@@ -356,9 +356,9 @@
     "confidential_example": "예): 내부 전용",
     "default_language": "새 사용자를 위한 기본 언어",
     "default_mail_visibility": "새 사용자를 위한 이메일 공개",
+    "default_read_only_for_new_user": "신규 사용자의 편집 제한",
+    "set_read_only_for_new_user": "신규 사용자를 열람 전용으로 설정",
     "file_uploading": "파일 업로드",
-    "enable_files_except_image": "이 옵션을 활성화하면 모든 파일 형식을 업로드할 수 있습니다. 이 옵션이 없으면 이미지 파일 업로드만 지원됩니다.",
-    "attach_enable": "이 옵션을 활성화하면 이미지 파일 외의 파일을 첨부할 수 있습니다.",
     "page_bulk_export_settings": "페이지 대량 내보내기 설정",
     "enable_page_bulk_export": "대량 내보내기 활성화",
     "page_bulk_export_explanation": "모든 사용자가 메뉴에서 한 번에 페이지와 모든 하위 페이지를 내보낼 수 있는 기능을 활성화합니다. 내보낸 데이터는 저장 기간이 지나면 자동으로 삭제됩니다.",
@@ -448,10 +448,7 @@
     "customize_settings": "사용자 지정",
     "default_sidebar_mode": {
       "title": "기본 사이드바 모드",
-      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다.",
-      "dock_mode_default_desc": "독 모드가 선택되었을 때 사이드바의 초기 상태를 설정할 수 있습니다.",
-      "dock_mode_default_open": "처음부터 열린 상태로 페이지 열기",
-      "dock_mode_default_close": "처음부터 닫힌 상태로 페이지 열기"
+      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다."
     },
     "layout": "레이아웃",
     "layout_options": {
@@ -531,7 +528,7 @@
     "page_path": "페이지 경로",
     "beta_warning": "이 기능은 베타입니다.",
     "import_from": "{{from}}에서 가져오기",
-    "import_growi_archive": "GROWI 아카이브 가져오기",
+    "import_growi_archive": "아카이브 데이터 가져오기",
     "error": {
       "only_upsert_available": "페이지 컬렉션에는 'Upsert' 옵션만 사용할 수 있습니다."
     },
@@ -577,23 +574,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "팀 이름",
-      "access_token": "액세스 토큰",
-      "test_connection": "esa 연결 테스트"
-    },
-    "qiita_settings": {
-      "team_name": "팀 이름",
-      "access_token": "액세스 토큰",
-      "test_connection": "qiita:team 연결 테스트"
-    },
     "import": "가져오기",
     "skip_username_and_email_when_overlapped": "새 환경에서 동일한 사용자 이름과 이메일을 사용하는 경우 사용자 이름과 이메일 건너뛰기",
     "prepare_new_account_for_migration": "마이그레이션을 위한 새 계정 준비",
     "archive_data_import_detail": "자세한 내용은 여기를 클릭하십시오.",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "GROWI에 이미 존재하는 이름의 페이지는 가져오지 않습니다.",
-    "Directory_hierarchy_tag": "디렉토리 계층 태그"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "아카이브 데이터 내보내기",
@@ -1015,12 +1000,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "아카이브 데이터 업로드",
     "ADMIN_GROWI_DATA_IMPORTED": "아카이브 데이터 가져오기",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "업로드된 GROWI 데이터 버리기",
-    "ADMIN_ESA_DATA_IMPORTED": "esa.io에서 가져오기",
-    "ADMIN_ESA_DATA_UPDATED": "esa.io 가져오기 설정 업데이트",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "esa 연결 테스트",
-    "ADMIN_QIITA_DATA_IMPORTED": "Qiita:Team에서 가져오기",
-    "ADMIN_QIITA_DATA_UPDATED": "Qiita:Team 가져오기 설정 업데이트",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Qiita:Team 연결 테스트",
     "ADMIN_ARCHIVE_DATA_CREATE": "아카이브 데이터 생성",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "아카이브 데이터 다운로드",
     "ADMIN_ARCHIVE_DATA_DELETE": "아카이브 데이터 삭제",

+ 5 - 26
apps/app/public/static/locales/zh_CN/admin.json

@@ -365,9 +365,9 @@
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "default_read_only_for_new_user": "新用戶編輯限制",
+    "set_read_only_for_new_user": "將新用戶設為僅限瀏覽",
     "file_uploading": "文件上传",
-    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
     "page_bulk_export_settings": "页面批量导出设置",
     "enable_page_bulk_export": "启用批量导出",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
@@ -457,10 +457,7 @@
     "customize_settings": "页面定制",
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
-      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
-      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
-      "dock_mode_default_open": "从头开始翻页",
-      "dock_mode_default_close": "从头开始打开关闭的页面"
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。"
     },
     "layout": "布局",
     "layout_options": {
@@ -540,7 +537,7 @@
     "page_path": "相对路径",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
-    "import_growi_archive": "Import GROWI archive",
+    "import_archive_data": "导入存档数据",
     "error": {
       "only_upsert_available": "Only 'Upsert' option is available for pages collection."
     },
@@ -586,23 +583,11 @@
         }
       }
     },
-    "esa_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to esa"
-    },
-    "qiita_settings": {
-      "team_name": "Team name",
-      "access_token": "Access token",
-      "test_connection": "Test connection to qiita:team"
-    },
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "prepare_new_account_for_migration": "Prepare new account for migration",
     "archive_data_import_detail": "More details? Click here.",
-    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
-    "page_skip": "Pages with a name that already exists on GROWI are not imported",
-    "Directory_hierarchy_tag": "Directory hierarchy tag"
+    "admin_archive_data_import_guide_url": "https://docs.growi.org/en/admin-guide/management-cookbook/import.html"
   },
   "export_management": {
     "export_archive_data": "导出主题数据",
@@ -1024,12 +1009,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",
     "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "丢弃存档数据",
-    "ADMIN_ESA_DATA_IMPORTED": "从 esa.io 导入",
-    "ADMIN_ESA_DATA_UPDATED": "更新 esa.io 导入设置",
-    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "测试与 esa 的连接",
-    "ADMIN_QIITA_DATA_IMPORTED": "从 Qiita:Team 导入",
-    "ADMIN_QIITA_DATA_UPDATED": "更新 Qiita:团队导入设置",
-    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "测试与 Qiita:Team 的连接",
     "ADMIN_ARCHIVE_DATA_CREATE": "创建归档数据",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据",
     "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据",

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

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

+ 2 - 2
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
-import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
+import { useIsMaintenanceMode } from '~/states/global';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -29,7 +29,7 @@ const AppSettingsPageContents = (props: Props) => {
   const { t } = useTranslation('admin');
   const { adminAppContainer } = props;
 
-  const { data: isMaintenanceMode } = useIsMaintenanceMode();
+  const isMaintenanceMode = useIsMaintenanceMode();
 
   const { isV5Compatible } = adminAppContainer.state;
 

+ 4 - 7
apps/app/src/client/components/Admin/App/MaintenanceMode.tsx

@@ -3,21 +3,18 @@ import React, { useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { useMaintenanceModeActions } from '~/client/services/maintenance-mode';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
-import loggerFactory from '~/utils/logger';
+import { useIsMaintenanceMode } from '~/states/global';
 
 import { ConfirmModal } from './ConfirmModal';
 
-const logger = loggerFactory('growi:maintenanceMode');
-
 
 export const MaintenanceMode: FC = () => {
   const { t } = useTranslation();
 
-  const {
-    data: isMaintenanceMode, start: startMaintenanceMode, end: endMaintenanceMode,
-  } = useIsMaintenanceMode();
+  const isMaintenanceMode = useIsMaintenanceMode();
+  const { start: startMaintenanceMode, end: endMaintenanceMode } = useMaintenanceModeActions();
 
   const [isModalOpen, setModalOpen] = useState<boolean>(false);
 

+ 2 - 2
apps/app/src/client/components/Admin/App/V5PageMigration.tsx

@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import type {
   PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
 } from '~/interfaces/websocket';
 import {
   SocketEventName,
 } from '~/interfaces/websocket';
-import { useGlobalAdminSocket } from '~/stores/websocket';
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -33,7 +33,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const [current, setCurrent] = useState<number>(0);
   const [isSucceeded, setSucceeded] = useState<boolean | undefined>(undefined);
 
-  const { data: adminSocket } = useGlobalAdminSocket();
+  const adminSocket = useAdminSocket();
   const { t } = useTranslation();
 
   const { adminAppContainer } = props;

+ 4 - 5
apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -1,22 +1,21 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 
 import { AllSupportedActions } from '~/interfaces/activity';
-import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores-universal/context';
+import { activityExpirationSecondsAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations';
 
 export const AuditLogSettings: FC = () => {
   const { t } = useTranslation();
 
   const [isExpandActionList, setIsExpandActionList] = useState(false);
 
-  const { data: activityExpirationSecondsData } = useActivityExpirationSeconds();
-  const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000;
+  const activityExpirationSeconds = useAtomValue(activityExpirationSecondsAtom) || 2592000;
 
-  const { data: availableActionsData } = useAuditLogAvailableActions();
-  const availableActions = availableActionsData != null ? availableActionsData : [];
+  const availableActions = useAtomValue(auditLogAvailableActionsAtom);
 
   return (
     <>

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

@@ -3,12 +3,13 @@ import React, { useState, useCallback, useRef } from 'react';
 
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'react-i18next';
 
 import type { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/toastr';
 import type { SupportedActionType } from '~/interfaces/activity';
-import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores-universal/context';
+import { auditLogEnabledAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations';
 import { useSWRxActivity } from '~/stores/activity';
 
 import PaginationWrapper from '../PaginationWrapper';
@@ -34,7 +35,7 @@ export const AuditLogManagement: FC = () => {
 
   const typeaheadRef = useRef<IClearable>(null);
 
-  const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
+  const auditLogAvailableActionsData = useAtomValue(auditLogAvailableActionsAtom);
 
   /*
    * State
@@ -67,7 +68,7 @@ export const AuditLogManagement: FC = () => {
     toastError('Failed to get Audit Log');
   }
 
-  const { data: auditLogEnabled } = useAuditLogEnabled();
+  const auditLogEnabled = useAtomValue(auditLogEnabledAtom);
 
   /*
    * Functions

+ 17 - 14
apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback, useState, type JSX } from 'react';
 
+import { useAtomValue, useSetAtom } from 'jotai';
 import { useTranslation } from 'react-i18next';
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
@@ -7,7 +8,8 @@ import {
   apiv3Delete, apiv3PostForm, apiv3Put,
 } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useIsDefaultLogo, useIsCustomizedLogoUploaded } from '~/stores-universal/context';
+import { useIsDefaultLogo } from '~/states/global';
+import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
@@ -18,8 +20,9 @@ const CUSTOMIZED_LOGO = '/attachment/brand-logo';
 const CustomizeLogoSetting = (): JSX.Element => {
 
   const { t } = useTranslation();
-  const { data: isDefaultLogo } = useIsDefaultLogo();
-  const { data: isCustomizedLogoUploaded, mutate: mutateIsCustomizedLogoUploaded } = useIsCustomizedLogoUploaded();
+  const isDefaultLogo = useIsDefaultLogo();
+  const isCustomizedLogoUploaded = useAtomValue(isCustomizedLogoUploadedAtom);
+  const setIsCustomizedLogoUploaded = useSetAtom(isCustomizedLogoUploadedAtom);
 
   const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
   const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
@@ -35,7 +38,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, []);
 
-  const onClickSubmit = useCallback(async() => {
+  const onClickSubmit = useCallback(async () => {
     try {
       await apiv3Put('/customize-setting/customize-logo', { isDefaultLogo: isDefaultLogoSelected });
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' }));
@@ -45,10 +48,10 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, [t, isDefaultLogoSelected]);
 
-  const onClickDeleteBtn = useCallback(async() => {
+  const onClickDeleteBtn = useCallback(async () => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
-      mutateIsCustomizedLogoUploaded(false);
+      setIsCustomizedLogoUploaded(false);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
     }
     catch (err) {
@@ -56,15 +59,15 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to delete logo');
     }
-  }, [mutateIsCustomizedLogoUploaded, t]);
+  }, [setIsCustomizedLogoUploaded, t]);
 
 
-  const processImageCompletedHandler = useCallback(async(croppedImage) => {
+  const processImageCompletedHandler = useCallback(async (croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
       await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
-      mutateIsCustomizedLogoUploaded(true);
+      setIsCustomizedLogoUploaded(true);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
     }
     catch (err) {
@@ -72,7 +75,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to upload brand logo');
     }
-  }, [mutateIsCustomizedLogoUploaded, t]);
+  }, [setIsCustomizedLogoUploaded, t]);
 
   return (
     <React.Fragment>
@@ -113,13 +116,13 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       onChange={() => { setIsDefaultLogoSelected(false) }}
                     />
                     <label className="form-check-label" htmlFor="radioUploadLogo">
-                      { t('admin:customize_settings.upload_logo') }
+                      {t('admin:customize_settings.upload_logo')}
                     </label>
                   </div>
                 </h4>
                 <div className="row mb-3">
                   <label className="col-sm-4 col-12 col-form-label text-start">
-                    { t('admin:customize_settings.current_logo') }
+                    {t('admin:customize_settings.current_logo')}
                   </label>
                   <div className="col-sm-8 col-12">
                     {isCustomizedLogoUploaded && (
@@ -128,7 +131,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
                           <img src={CUSTOMIZED_LOGO} id="settingBrandLogo" width="64" />
                         </p>
                         <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
-                          { t('admin:customize_settings.delete_logo') }
+                          {t('admin:customize_settings.delete_logo')}
                         </button>
                       </>
                     )}
@@ -136,7 +139,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
                 </div>
                 <div className="row">
                   <label className="col-sm-4 col-12 col-form-label text-start">
-                    { t('admin:customize_settings.upload_new_logo') }
+                    {t('admin:customize_settings.upload_new_logo')}
                   </label>
                   <div className="col-sm-8 col-12">
                     <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />

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

+ 4 - 4
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -7,7 +7,7 @@ import { Card, CardBody } from 'reactstrap';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCustomizeTitle } from '~/stores-universal/context';
+import { useCustomTitleTemplate } from '~/states/global';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
@@ -15,7 +15,7 @@ export const CustomizeTitle: FC = () => {
 
   const { t } = useTranslation('admin');
 
-  const { data: customizeTitle } = useCustomizeTitle();
+  const customTitleTemplate = useCustomTitleTemplate();
 
   const {
     register,
@@ -26,9 +26,9 @@ export const CustomizeTitle: FC = () => {
   // Sync form with store data
   useEffect(() => {
     reset({
-      customizeTitle: customizeTitle ?? '',
+      customizeTitle: customTitleTemplate ?? '',
     });
-  }, [customizeTitle, reset]);
+  }, [customTitleTemplate, reset]);
 
   const onSubmit = useCallback(async(data) => {
     try {

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

@@ -1,13 +1,13 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 
-
 import { apiv3Get, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { SocketEventName } from '~/interfaces/websocket';
-import { useIsSearchServiceReachable } from '~/stores-universal/context';
-import { useAdminSocket } from '~/stores/socket-io';
+import { isSearchServiceReachableAtom } from '~/states/server-configurations';
 
 import NormalizeIndicesControls from './NormalizeIndicesControls';
 import RebuildIndexControls from './RebuildIndexControls';
@@ -16,8 +16,9 @@ import StatusTable from './StatusTable';
 
 const ElasticsearchManagement = (): JSX.Element => {
   const { t } = useTranslation('admin');
-  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
-  const { data: socket } = useAdminSocket();
+  // Get search service reachable flag from atom
+  const isSearchServiceReachable = useAtomValue(isSearchServiceReachableAtom);
+  const socket = useAdminSocket();
 
   const [isInitialized, setIsInitialized] = useState(false);
 
@@ -77,7 +78,7 @@ const ElasticsearchManagement = (): JSX.Element => {
     if (socket == null) {
       return;
     }
-    socket.on(SocketEventName.AddPageProgress, (data) => {
+    socket.on(SocketEventName.AddPageProgress, () => {
       setIsRebuildingProcessing(true);
     });
 

+ 2 - 2
apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,8 +3,8 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { SocketEventName } from '~/interfaces/websocket';
-import { useAdminSocket } from '~/stores/socket-io';
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 
@@ -99,7 +99,7 @@ class RebuildIndexControls extends React.Component {
 
 const RebuildIndexControlsFC = (props) => {
   const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
   return <RebuildIndexControls t={t} socket={socket} {...props} />;
 };
 

+ 2 - 2
apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx

@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
 import { apiDelete } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useAdminSocket } from '~/stores/socket-io';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 
 import LabeledProgressBar from './Common/LabeledProgressBar';
 import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
@@ -20,7 +20,7 @@ const IGNORED_COLLECTION_NAMES = [
 ];
 
 const ExportArchiveDataPage = (): JSX.Element => {
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
   const { t } = useTranslation('admin');
 
   const [collections, setCollections] = useState<any[]>([]);

+ 4 - 4
apps/app/src/client/components/Admin/G2GDataTransfer.tsx

@@ -7,9 +7,9 @@ import { useTranslation } from 'next-i18next';
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
-import { useGrowiDocumentationUrl } from '~/stores-universal/context';
-import { useAdminSocket } from '~/stores/socket-io';
+import { useGrowiDocumentationUrl } from '~/states/context';
 
 import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
 
@@ -22,7 +22,7 @@ const IGNORED_COLLECTION_NAMES = [
 ];
 
 const G2GDataTransfer = (): JSX.Element => {
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
   const { t } = useTranslation(['admin', 'commons']);
 
   const [startTransferKey, setStartTransferKey] = useState('');
@@ -124,7 +124,7 @@ const G2GDataTransfer = (): JSX.Element => {
     }
   }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
 
-  const { data: documentationUrl } = useGrowiDocumentationUrl();
+  const documentationUrl = useGrowiDocumentationUrl();
 
   // File upload
   // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {

+ 2 - 2
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -5,10 +5,10 @@ import PropTypes from 'prop-types';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
 import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
-import { useAdminSocket } from '~/stores/socket-io';
 
 
 import ErrorViewer from './ErrorViewer';
@@ -507,7 +507,7 @@ ImportForm.propTypes = {
 
 const ImportFormWrapperFc = (props) => {
   const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
 
   if (socket == null) {
     return;

+ 2 - 264
apps/app/src/client/components/Admin/ImportData/ImportDataPageContents.jsx

@@ -1,280 +1,18 @@
-import React, { useEffect } from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import { useForm } from 'react-hook-form';
-
-import AdminImportContainer from '~/client/services/AdminImportContainer';
-import { toastError } from '~/client/util/toastr';
-import { toArrayIfNot } from '~/utils/array-utils';
-import loggerFactory from '~/utils/logger';
-
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
 import GrowiArchiveSection from './GrowiArchiveSection';
 
-const logger = loggerFactory('growi:importer');
-
-const ImportDataPageContents = ({ t, adminImportContainer }) => {
-  const { register: registerEsa, reset: resetEsa, handleSubmit: handleSubmitEsa } = useForm();
-  const { register: registerQiita, reset: resetQiita, handleSubmit: handleSubmitQiita } = useForm();
-
-  useEffect(() => {
-    resetEsa({
-      esaTeamName: adminImportContainer.state.esaTeamName || '',
-      esaAccessToken: adminImportContainer.state.esaAccessToken || '',
-    });
-  }, [resetEsa, adminImportContainer.state.esaTeamName, adminImportContainer.state.esaAccessToken]);
-
-  useEffect(() => {
-    resetQiita({
-      qiitaTeamName: adminImportContainer.state.qiitaTeamName || '',
-      qiitaAccessToken: adminImportContainer.state.qiitaAccessToken || '',
-    });
-  }, [resetQiita, adminImportContainer.state.qiitaTeamName, adminImportContainer.state.qiitaAccessToken]);
-
+const ImportDataPageContents = () => {
   return (
     <div data-testid="admin-import-data">
       <GrowiArchiveSection />
-
-      <form
-        className="mt-5"
-        id="importerSettingFormEsa"
-        role="form"
-        onSubmit={handleSubmitEsa(adminImportContainer.esaHandleSubmitUpdate)}
-      >
-        <fieldset>
-          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'esa.io' })}</h2>
-          <table className="table table-bordered table-mapping">
-            <thead>
-              <tr>
-                <th width="45%">esa.io</th>
-                <th width="10%"></th>
-                <th>GROWI</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th>{t('importer_management.article')}</th>
-                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                <th>{t('importer_management.page')}</th>
-              </tr>
-              <tr>
-                <th>{t('importer_management.category')}</th>
-                <th><span className="material-symbols-outlined text-success">arrow_circle_right</span></th>
-                <th>{t('importer_management.page_path')}</th>
-              </tr>
-              <tr>
-                <th>{t('User')}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-            </tbody>
-          </table>
-
-          <div className="card custom-card bg-body-tertiary mb-0 small">
-            <ul>
-              <li>{t('importer_management.page_skip')}</li>
-            </ul>
-          </div>
-
-          <div className="row mt-4">
-            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-          </div>
-
-          <div className="row">
-            <label htmlFor="settingForm[importer:esa:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.esa_settings.team_name')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="text"
-                {...registerEsa('esaTeamName')}
-              />
-            </div>
-
-          </div>
-
-          <div className="row mt-3">
-            <label htmlFor="settingForm[importer:esa:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.esa_settings.access_token')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="password"
-                {...registerEsa('esaAccessToken')}
-              />
-            </div>
-          </div>
-
-          <div className="row mt-3">
-            <div className="offset-md-3 col-md-6">
-              <input
-                id="testConnectionToEsa"
-                type="button"
-                className="btn btn-primary btn-esa me-3"
-                name="Esa"
-                onClick={adminImportContainer.esaHandleSubmit}
-                value={t('importer_management.import')}
-              />
-              <input type="submit" className="btn btn-secondary" value={t('Update')} />
-              <span className="offset-0 offset-sm-1">
-                <input
-                  id="importFromEsa"
-                  type="button"
-                  name="Esa"
-                  className="btn btn-secondary btn-esa"
-                  onClick={adminImportContainer.esaHandleSubmitTest}
-                  value={t('importer_management.esa_settings.test_connection')}
-                />
-              </span>
-            </div>
-          </div>
-        </fieldset>
-      </form>
-
-      <form
-        className="mt-5"
-        id="importerSettingFormQiita"
-        role="form"
-        onSubmit={handleSubmitQiita(adminImportContainer.qiitaHandleSubmitUpdate)}
-      >
-        <fieldset>
-          <h2 className="admin-setting-header">{t('importer_management.import_from', { from: 'Qiita:Team' })}</h2>
-          <table className="table table-bordered table-mapping">
-            <thead>
-              <tr>
-                <th width="45%">Qiita:Team</th>
-                <th width="10%"></th>
-                <th>GROWI</th>
-              </tr>
-            </thead>
-            <tbody>
-              <tr>
-                <th>{t('importer_management.article')}</th>
-                <th><span className="material-symbols-outlined">arrow_circle_right</span></th>
-                <th>{t('importer_management.page')}</th>
-              </tr>
-              <tr>
-                <th>{t('importer_management.tag')}</th>
-                <th></th>
-                <th>-</th>
-              </tr>
-              <tr>
-                <th>{t('importer_management.Directory_hierarchy_tag')}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-              <tr>
-                <th>{t('User')}</th>
-                <th></th>
-                <th>(TBD)</th>
-              </tr>
-            </tbody>
-          </table>
-          <div className="card custom-card bg-body-tertiary mb-3 small">
-            <ul>
-              <li>{t('importer_management.page_skip')}</li>
-            </ul>
-          </div>
-
-          <div className="row mt-3">
-            <input type="password" name="dummypass" style={{ display: 'none', top: '-100px', left: '-100px' }} />
-          </div>
-          <div className="row mt-3">
-            <label htmlFor="settingForm[importer:qiita:team_name]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.qiita_settings.team_name')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="text"
-                {...registerQiita('qiitaTeamName')}
-              />
-            </div>
-          </div>
-
-          <div className="row mt-3">
-            <label htmlFor="settingForm[importer:qiita:access_token]" className="text-start text-md-end col-md-3 col-form-label">
-              {t('importer_management.qiita_settings.access_token')}
-            </label>
-            <div className="col-md-6">
-              <input
-                className="form-control"
-                type="password"
-                {...registerQiita('qiitaAccessToken')}
-              />
-            </div>
-          </div>
-
-
-          <div className="row mt-3">
-            <div className="offset-md-3 col-md-6">
-              <input
-                id="testConnectionToQiita"
-                type="button"
-                className="btn btn-primary btn-qiita me-3"
-                name="Qiita"
-                onClick={adminImportContainer.qiitaHandleSubmit}
-                value={t('importer_management.import')}
-              />
-              <input type="submit" className="btn btn-secondary" value={t('Update')} />
-              <span className="offset-0 offset-sm-1">
-                <input
-                  name="Qiita"
-                  type="button"
-                  id="importFromQiita"
-                  className="btn btn-secondary btn-qiita"
-                  onClick={adminImportContainer.qiitaHandleSubmitTest}
-                  value={t('importer_management.qiita_settings.test_connection')}
-                />
-              </span>
-
-            </div>
-          </div>
-
-
-        </fieldset>
-
-
-      </form>
     </div>
   );
 };
 
-ImportDataPageContents.propTypes = {
-  t: PropTypes.func.isRequired,
-  adminImportContainer: PropTypes.instanceOf(AdminImportContainer).isRequired,
-};
-
-const ImportDataPageContentsWrapperFc = (props) => {
-  const { t } = useTranslation('admin');
-
-  const { adminImportContainer } = props;
-
-  useEffect(() => {
-    const fetchImportSettingsData = async() => {
-      await adminImportContainer.retrieveImportSettingsData();
-    };
-
-    try {
-      fetchImportSettingsData();
-    }
-    catch (err) {
-      const errs = toArrayIfNot(err);
-      toastError(errs);
-      logger.error(errs);
-    }
-  }, [adminImportContainer]);
-
-  return <ImportDataPageContents t={t} {...props} />;
-};
-
 /**
  * Wrapper component for using unstated
  */
-const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContentsWrapperFc, [AdminImportContainer]);
+const ImportDataPageContentsWrapper = withUnstatedContainers(ImportDataPageContents, []);
 
 export default ImportDataPageContentsWrapper;

+ 4 - 2
apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useMemo, useEffect, useState, type JSX,
 } from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
@@ -9,7 +10,7 @@ import { useRouter } from 'next/router';
 import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 import { useSWRxGlobalNotification } from '~/stores/global-notification';
 import loggerFactory from '~/utils/logger';
 
@@ -106,7 +107,8 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
   }, [emailToSend, notifyType, props.globalNotificationId, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]);
 
 
-  const { data: isMailerSetup } = useIsMailerSetup();
+  // Mailer setup status (unused yet but kept for potential conditional logic)
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
   const { t } = useTranslation('admin');
 
   return (

+ 12 - 18
apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx → apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx

@@ -3,7 +3,6 @@ import React, { useCallback, useEffect } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 
@@ -11,15 +10,23 @@ import urljoin from 'url-join';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-const GitHubSecurityManagementContents = (props) => {
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
+  adminGitHubSecurityContainer: AdminGitHubSecurityContainer
+};
+
+const GitHubSecurityManagementContents = (props: Props) => {
   const {
-    t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
+    adminGeneralSecurityContainer, adminGitHubSecurityContainer,
   } = props;
 
+  const { t } = useTranslation('admin');
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
+
   const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
   const { githubClientId, githubClientSecret, retrieveError } = adminGitHubSecurityContainer.state;
   const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
@@ -192,23 +199,10 @@ const GitHubSecurityManagementContents = (props) => {
   );
 };
 
-GitHubSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
-  siteUrl: PropTypes.string,
-};
-
-const GitHubSecurityManagementContentsFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <GitHubSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
-};
-
 /**
  * Wrapper component for using unstated
  */
-const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContentsFC, [
+const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContents, [
   AdminGeneralSecurityContainer,
   AdminGitHubSecurityContainer,
 ]);

+ 13 - 18
apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx → apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx

@@ -1,23 +1,31 @@
+/* eslint-disable react/no-danger */
 import React, { useCallback, useEffect } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-const GoogleSecurityManagementContents = (props) => {
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
+  adminGoogleSecurityContainer: AdminGoogleSecurityContainer
+};
+
+const GoogleSecurityManagementContents = (props: Props) => {
   const {
-    t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
+    adminGeneralSecurityContainer, adminGoogleSecurityContainer,
   } = props;
 
+  const { t } = useTranslation('admin');
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
+
   const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
   const { googleClientId, googleClientSecret, retrieveError } = adminGoogleSecurityContainer.state;
   const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
@@ -193,20 +201,7 @@ const GoogleSecurityManagementContents = (props) => {
   );
 };
 
-GoogleSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
-  siteUrl: PropTypes.string,
-};
-
-const GoogleSecurityManagementContentsFc = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
-};
-
-const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [
+const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContents, [
   AdminGeneralSecurityContainer,
   AdminGoogleSecurityContainer,
 ]);

+ 4 - 4
apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx

@@ -1,13 +1,13 @@
 import React, { useCallback, useEffect } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
+import { useAtomValue } from 'jotai';
 import { useForm } from 'react-hook-form';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -23,7 +23,7 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
   } = props;
 
   const { t } = useTranslation('admin');
-  const { data: isMailerSetup = false } = useIsMailerSetup();
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
 
   const { register, handleSubmit, reset } = useForm();
 

+ 3 - 6
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx

@@ -9,7 +9,7 @@ import urljoin from 'url-join';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -20,7 +20,7 @@ type Props = {
 
 const OidcSecurityManagementContents = (props: Props) => {
   const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
 
   const {
     adminGeneralSecurityContainer, adminOidcSecurityContainer,
@@ -33,10 +33,7 @@ const OidcSecurityManagementContents = (props: Props) => {
     oidcAttrMapId, oidcAttrMapUserName, oidcAttrMapName, oidcAttrMapEmail,
   } = adminOidcSecurityContainer.state;
 
-  const oidcCallbackUrl = urljoin(
-    siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl),
-    '/passport/oidc/callback',
-  );
+  const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback');
 
   const { register, handleSubmit, reset } = useForm();
 

+ 3 - 6
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx

@@ -11,7 +11,7 @@ import urljoin from 'url-join';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -26,7 +26,7 @@ const SamlSecurityManagementContents = (props: Props) => {
   } = props;
 
   const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
 
   const [isHelpOpened, setIsHelpOpened] = useState(false);
   const { register, handleSubmit, reset } = useForm();
@@ -77,10 +77,7 @@ const SamlSecurityManagementContents = (props: Props) => {
   const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
   const { isSamlEnabled } = adminGeneralSecurityContainer.state;
 
-  const samlCallbackUrl = urljoin(
-    siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl),
-    '/passport/saml/callback',
-  );
+  const samlCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback');
 
   return (
     <React.Fragment>

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useAppTitle } from '~/stores-universal/context';
+import { useAppTitle } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,7 +26,7 @@ const CustomBotWithProxySettings = (props) => {
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [siteName, setSiteName] = useState('');
   const { t } = useTranslation();
-  const { data: appTitle } = useAppTitle();
+  const appTitle = useAppTitle();
 
   // componentDidUpdate
   useEffect(() => {

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-import { useAppTitle } from '~/stores-universal/context';
+import { useAppTitle } from '~/states/global';
 
 import { CustomBotWithoutProxyConnectionStatus } from './CustomBotWithoutProxyConnectionStatus';
 import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './CustomBotWithoutProxySettingsAccordion';
@@ -11,7 +11,7 @@ import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './C
 const CustomBotWithoutProxySettings = (props) => {
   const { connectionStatuses } = props;
   const { t } = useTranslation();
-  const { data: appTitle } = useAppTitle();
+  const appTitle = useAppTitle();
   const [siteName, setSiteName] = useState('');
 
   useEffect(() => {

+ 56 - 39
apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -29,53 +29,70 @@ export const DeleteSlackBotSettingsModal = React.memo((props: DeleteSlackBotSett
     onClose?.();
   }, [onClose]);
 
+  // Memoize conditional content
+  const headerContent = useMemo(() => {
+    if (isResetAll) {
+      return (
+        <>
+          <span className="material-symbols-outlined">delete_forever</span>
+          {t('admin:slack_integration.reset_all_settings')}
+        </>
+      );
+    }
+    return (
+      <>
+        <span className="material-symbols-outlined">delete</span>
+        {t('admin:slack_integration.delete_slackbot_settings')}
+      </>
+    );
+  }, [isResetAll, t]);
+
+  const bodyContent = useMemo(() => {
+    const htmlContent = isResetAll
+      ? t('admin:slack_integration.all_settings_of_the_bot_will_be_reset')
+      : t('admin:slack_integration.slackbot_settings_notice');
+    return (
+      <span
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{ __html: htmlContent }}
+      />
+    );
+  }, [isResetAll, t]);
+
+  const deleteButtonContent = useMemo(() => {
+    if (isResetAll) {
+      return (
+        <>
+          <span className="material-symbols-outlined">delete_forever</span>
+          {t('admin:slack_integration.reset')}
+        </>
+      );
+    }
+    return (
+      <>
+        <span className="material-symbols-outlined">delete</span>
+        {t('admin:slack_integration.delete')}
+      </>
+    );
+  }, [isResetAll, t]);
+
+  // Early return optimization
+  if (!isOpen) {
+    return <></>;
+  }
+
   return (
     <Modal isOpen={isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="text-danger">
-        <span>
-          {isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete_forever</span>
-              {t('admin:slack_integration.reset_all_settings')}
-            </>
-          )}
-          {!isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete</span>
-              {t('admin:slack_integration.delete_slackbot_settings')}
-            </>
-          )}
-        </span>
+        <span>{headerContent}</span>
       </ModalHeader>
       <ModalBody>
-        {isResetAll && (
-          <span
-            // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.all_settings_of_the_bot_will_be_reset') }}
-          />
-        )}
-        {!isResetAll && (
-          <span
-            // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.slackbot_settings_notice') }}
-          />
-        )}
+        {bodyContent}
       </ModalBody>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
         <Button color="danger" onClick={deleteSlackCredentialsHandler}>
-          {isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete_forever</span>
-              {t('admin:slack_integration.reset')}
-            </>
-          )}
-          {!isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete</span>
-              {t('admin:slack_integration.delete')}
-            </>
-          )}
+          {deleteButtonContent}
         </Button>
       </ModalFooter>
     </Modal>

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useAppTitle } from '~/stores-universal/context';
+import { useAppTitle } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 
 
@@ -27,7 +27,7 @@ const OfficialBotSettings = (props) => {
   const [siteName, setSiteName] = useState('');
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const { t } = useTranslation();
-  const { data: appTitle } = useAppTitle();
+  const appTitle = useAppTitle();
 
   const addSlackAppIntegrationHandler = async() => {
     if (onClickAddSlackWorkspaceBtn != null) {

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 
 import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
@@ -286,7 +286,7 @@ const TestProcess = ({
 
 const WithProxyAccordions = (props) => {
   const { t } = useTranslation();
-  const { data: siteUrl } = useSiteUrl();
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
   const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false);
 
   const submitForm = () => {

+ 75 - 60
apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,5 +1,7 @@
 import type { FC } from 'react';
-import React, { useState, useEffect, useCallback } from 'react';
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
 
 import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -16,12 +18,12 @@ type Props = {
   isExternalGroup?: boolean
 };
 
-export const UserGroupModal: FC<Props> = (props: Props) => {
+const UserGroupModalSubstance: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
+    userGroup, buttonLabel, onClickSubmit, onHide, isExternalGroup = false,
   } = props;
 
   /*
@@ -42,6 +44,14 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
+  // Memoized user group data for submission
+  const userGroupData = useMemo(() => ({
+    _id: userGroup?._id,
+    name: currentName,
+    description: currentDescription,
+    parent: currentParent,
+  }), [userGroup?._id, currentName, currentDescription, currentParent]);
+
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
@@ -49,13 +59,8 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
       return;
     }
 
-    await onClickSubmit({
-      _id: userGroup?._id,
-      name: currentName,
-      description: currentDescription,
-      parent: currentParent,
-    });
-  }, [userGroup, currentName, currentDescription, currentParent, onClickSubmit]);
+    await onClickSubmit(userGroupData);
+  }, [onClickSubmit, userGroupData]);
 
   // componentDidMount
   useEffect(() => {
@@ -66,58 +71,68 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
     }
   }, [userGroup]);
 
+  return (
+    <form onSubmit={onSubmitHandler}>
+      <ModalHeader tag="h4" toggle={onHide}>
+        {t('user_group_management.basic_info')}
+      </ModalHeader>
+
+      <ModalBody>
+        <div>
+          <label htmlFor="name" className="form-label">
+            {t('user_group_management.group_name')}
+          </label>
+          <input
+            className="form-control"
+            type="text"
+            name="name"
+            placeholder={t('user_group_management.group_example')}
+            value={currentName}
+            onChange={onChangeNameHandler}
+            required
+            disabled={isExternalGroup}
+          />
+        </div>
+
+        <div>
+          <label htmlFor="description" className="form-label">
+            {t('Description')}
+          </label>
+          <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+          {isExternalGroup && (
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_form_detail')}
+              </small>
+            </p>
+          )}
+        </div>
+
+        {/* TODO 90732: Add a drop-down to show selectable parents */}
+
+        {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
+
+      </ModalBody>
+
+      <ModalFooter>
+        <div>
+          <button type="submit" className="btn btn-primary">
+            {buttonLabel}
+          </button>
+        </div>
+      </ModalFooter>
+    </form>
+  );
+};
+
+export const UserGroupModal: FC<Props> = (props: Props) => {
+  const { isShow, onHide } = props;
+
   return (
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
-      <form onSubmit={onSubmitHandler}>
-        <ModalHeader tag="h4" toggle={onHide}>
-          {t('user_group_management.basic_info')}
-        </ModalHeader>
-
-        <ModalBody>
-          <div>
-            <label htmlFor="name" className="form-label">
-              {t('user_group_management.group_name')}
-            </label>
-            <input
-              className="form-control"
-              type="text"
-              name="name"
-              placeholder={t('user_group_management.group_example')}
-              value={currentName}
-              onChange={onChangeNameHandler}
-              required
-              disabled={isExternalGroup}
-            />
-          </div>
-
-          <div>
-            <label htmlFor="description" className="form-label">
-              {t('Description')}
-            </label>
-            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
-            {isExternalGroup && (
-              <p className="form-text text-muted">
-                <small>
-                  {t('external_user_group.description_form_detail')}
-                </small>
-              </p>
-            )}
-          </div>
-
-          {/* TODO 90732: Add a drop-down to show selectable parents */}
-
-          {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
-
-        </ModalBody>
-
-        <ModalFooter>
-          <div>
-            <button type="submit" className="btn btn-primary">
-              {buttonLabel}
-            </button>
-          </div>
-        </ModalFooter>
-      </form>
+      {isShow && (
+        <UserGroupModalSubstance {...props} />
+      )}
     </Modal>
   );
 };

+ 3 - 2
apps/app/src/client/components/Admin/UserGroup/UserGroupPage.tsx

@@ -4,6 +4,7 @@ import React, { useState, useCallback } from 'react';
 import {
   GroupType, getIdForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
 } from '@growi/core';
+import { useAtomValue } from 'jotai';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
@@ -12,7 +13,7 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useSWRxExternalUserGroupList } from '~/features/external-user-group/client/stores/external-user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
-import { useIsAclEnabled } from '~/stores-universal/context';
+import { isAclEnabledAtom } from '~/states/server-configurations';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
 
@@ -23,7 +24,7 @@ const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.
 export const UserGroupPage: FC = () => {
   const { t } = useTranslation();
 
-  const { data: isAclEnabled } = useIsAclEnabled();
+  const isAclEnabled = useAtomValue(isAclEnabledAtom);
 
   /*
    * Fetch

+ 3 - 2
apps/app/src/client/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+import { useUpdateUserGroupConfirmModalStatus, useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
 
 
 export const UpdateParentConfirmModal: FC = () => {
@@ -14,7 +14,8 @@ export const UpdateParentConfirmModal: FC = () => {
 
   const [isForceUpdate, setForceUpdate] = useState(false);
 
-  const { data: modalStatus, close: closeModal } = useUpdateUserGroupConfirmModal();
+  const modalStatus = useUpdateUserGroupConfirmModalStatus();
+  const { close: closeModal } = useUpdateUserGroupConfirmModalActions();
 
   if (modalStatus == null) {
     closeModal();

+ 5 - 5
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -6,6 +6,7 @@ import {
   GroupType, getIdStringForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
 } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
@@ -18,8 +19,8 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
-import { useIsAclEnabled } from '~/stores-universal/context';
-import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+import { isAclEnabledAtom } from '~/states/server-configurations';
+import { useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
 import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 
@@ -104,9 +105,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
-  const { data: isAclEnabled } = useIsAclEnabled();
+  const isAclEnabled = useAtomValue(isAclEnabledAtom);
 
-  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
+  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModalActions();
 
   const parentUserGroup = (() => {
     if (isExternalGroup) {
@@ -365,7 +366,6 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
               <li
                 key={ancestorUserGroup._id}
                 className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
-                aria-current="page"
               >
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>

+ 3 - 2
apps/app/src/client/components/Admin/Users/PasswordResetModal.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 
 import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -11,7 +12,7 @@ import {
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 
 class PasswordResetModal extends React.Component {
 
@@ -204,7 +205,7 @@ class PasswordResetModal extends React.Component {
 
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
-  const { data: isMailerSetup } = useIsMailerSetup();
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
   return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 

+ 2 - 2
apps/app/src/client/components/Admin/Users/RevokeAdminButton.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -17,7 +17,7 @@ type RevokeAdminButtonProps = {
 const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser(); // hook returns single value now
   const { adminUsersContainer, user } = props;
 
   const onClickRevokeAdminBtnHandler = useCallback(async() => {

+ 2 - 2
apps/app/src/client/components/Admin/Users/RevokeAdminMenuItem.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,7 +33,7 @@ const RevokeAdminMenuItem = (props: Props): JSX.Element => {
 
   const { adminUsersContainer, user } = props;
 
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser();
 
   const clickRevokeAdminBtnHandler = useCallback(async() => {
     try {

+ 2 - 2
apps/app/src/client/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { withUnstatedContainers } from '~/client/components/UnstatedUtils';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 
 const SuspendAlert = React.memo((): JSX.Element => {
@@ -32,7 +32,7 @@ const StatusSuspendMenuItem = (props: Props): JSX.Element => {
 
   const { adminUsersContainer, user } = props;
 
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser(); // custom hook now returns single value
 
   const clickDeactiveBtnHandler = useCallback(async() => {
     try {

+ 3 - 3
apps/app/src/client/components/Admin/Users/UserInviteModal.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -8,10 +9,9 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -280,7 +280,7 @@ class UserInviteModal extends React.Component {
 
 const UserInviteModalWrapperFC = (props) => {
   const { t } = useTranslation();
-  const { data: isMailerSetup } = useIsMailerSetup();
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
   return <UserInviteModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 

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

@@ -2,7 +2,7 @@ import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrl } from '~/states/global';
 
 const isValidUrl = (str: string): boolean => {
   try {
@@ -17,12 +17,7 @@ const isValidUrl = (str: string): boolean => {
 
 export const AlertSiteUrlUndefined = (): JSX.Element => {
   const { t } = useTranslation('commons');
-  const { data: siteUrl, error: errorSiteUrl } = useSiteUrl();
-  const isLoadingSiteUrl = siteUrl === undefined && errorSiteUrl === undefined;
-
-  if (isLoadingSiteUrl) {
-    return <></>;
-  }
+  const siteUrl = useSiteUrl();
 
   if (typeof siteUrl === 'string' && isValidUrl(siteUrl)) {
     return <></>;

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

@@ -12,7 +12,7 @@ import { toastError } from '~/client/util/toastr';
 import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
-import { useBookmarkFolderDeleteModal } from '~/stores/modal';
+import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
 
 import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
 import { BookmarkFolderNameInput } from './BookmarkFolderNameInput';
@@ -49,7 +49,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
-  const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
+  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
 

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

@@ -7,7 +7,7 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRMUTxPageInfo } from '~/stores/page';
@@ -34,7 +34,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
 
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
 
   const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();

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

@@ -9,13 +9,14 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
 
 import { toastSuccess } from '~/client/util/toastr';
 import type { OnDeletedFunction } from '~/interfaces/ui';
-import { useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsReadOnlyUser } from '~/states/context';
+import { useCurrentPageData } from '~/states/page';
+import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
   useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { usePageDeleteModal } from '~/stores/modal';
-import { mutateAllPageInfo, useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
+import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -41,13 +42,13 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const router = useRouter();
 
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: currentPage } = useSWRxCurrentPage();
+  const isReadOnlyUser = useIsReadOnlyUser();
+  const currentPage = useCurrentPageData();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
   const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
   const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
-  const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openDeleteModal } = usePageDeleteModalActions();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();

+ 55 - 29
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,8 +1,11 @@
-import React, { useCallback, useState, type JSX } from 'react';
+import React, {
+  useCallback, useMemo, useState, type JSX,
+} from 'react';
 
 import nodePath from 'path';
 
-import type { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
@@ -15,8 +18,9 @@ import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
-import { usePutBackPageModal } from '~/stores/modal';
-import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useFetchCurrentPage } from '~/states/page';
+import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
+import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/page';
 
 import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import { PageListItemS } from '../PageList/PageListItemS';
@@ -47,28 +51,32 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
-  const { open: openPutBackPageModal } = usePutBackPageModal();
+  const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
   const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage?._id);
-  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { fetchCurrentPage } = useFetchCurrentPage();
 
   const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
   const dragItem: Partial<DragItemDataType> = {
     ...bookmarkedPage, parentFolder,
   };
 
+  const bookmarkedPageId = bookmarkedPage?._id;
+  const bookmarkedPagePath = bookmarkedPage?.path;
+  const bookmarkedPageRevision = bookmarkedPage?.revision;
+
   const onClickMoveToRootHandler = useCallback(async() => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
     try {
-      await addBookmarkToFolder(bookmarkedPage._id, null);
+      await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, bookmarkedPage]);
+  }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
   const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
     if (shouldBookmark) {
@@ -90,23 +98,23 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   }, []);
 
   const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
     if (inputText.trim() === '') {
       return cancel();
     }
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPage.path) {
+    if (newPagePath === bookmarkedPagePath) {
       setRenameInputShown(false);
       return;
     }
 
     try {
       setRenameInputShown(false);
-      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
       bookmarkFolderTreeMutation();
       mutatePageInfo();
     }
@@ -114,26 +122,26 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
-    if (bookmarkedPage == null) return;
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
+    if (bookmarkedPageId == null) return;
 
-    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
       throw Error('_id and path must not be null.');
     }
 
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
-        _id: bookmarkedPage._id,
-        revision: bookmarkedPage.revision as string,
-        path: bookmarkedPage.path,
+        _id: bookmarkedPageId,
+        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
+        path: bookmarkedPagePath,
       },
       meta: pageInfo,
     };
 
     onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPage, onClickDeleteMenuItemHandler]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
 
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
@@ -145,7 +153,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         mutateAllPageInfo();
         bookmarkFolderTreeMutation();
         router.push(`/${pageId}`);
-        mutateCurrentPage();
+        fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
       }
       catch (err) {
@@ -153,17 +161,35 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       }
     };
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
-  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, mutateCurrentPage, t]);
+  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
+
+  const {
+    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
+  } = useMemo(() => {
+    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+    if (bookmarkedPagePath == null) {
+      return {
+        pageTitle: '',
+        formerPagePath: '',
+        isFormerRoot: false,
+        bookmarkItemId,
+      };
+    }
+
+    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
+    return {
+      pageTitle: dPagePath.latter,
+      formerPagePath: dPagePath.former,
+      isFormerRoot: dPagePath.isFormerRoot,
+      bookmarkItemId,
+    };
+  }, [bookmarkedPagePath, bookmarkedPageId]);
 
   if (bookmarkedPage == null) {
     return <></>;
   }
 
-  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
-  const { latter: pageTitle, former: formerPagePath } = dPagePath;
-
-  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -214,7 +240,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
+          {isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
       </li>
     </DragAndDropWrapper>

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

@@ -8,9 +8,10 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
+import { useIsTrashPage } from '~/states/page';
 import { useSWRxPageComment } from '~/stores/comment';
-import { useIsTrashPage, useSWRMUTxPageInfo } from '~/stores/page';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 
 
 const { isTopPage } = pagePathUtils;
@@ -36,8 +37,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
 
   const { mutate } = useSWRxPageComment(pageId);
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
-  const { data: isDeleted } = useIsTrashPage();
-  const { data: currentUser } = useCurrentUser();
+  const isDeleted = useIsTrashPage();
+  const currentUser = useCurrentUser();
 
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
 

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

@@ -1,7 +1,6 @@
 import { type ReactNode, type JSX } from 'react';
 
-import { useDrawerOpened } from '~/stores/ui';
-
+import { useDrawerOpened } from '~/states/ui/sidebar';
 
 import styles from './DrawerToggler.module.scss';
 
@@ -17,7 +16,7 @@ export const DrawerToggler = (props: Props): JSX.Element => {
 
   const { className, children } = props;
 
-  const { data: isOpened, mutate } = useDrawerOpened();
+  const [isOpened, setIsOpened] = useDrawerOpened();
 
   return (
     <div className={`${moduleClass} ${className ?? ''}`}>
@@ -26,7 +25,7 @@ export const DrawerToggler = (props: Props): JSX.Element => {
         type="button"
         aria-expanded="false"
         aria-label="Toggle navigation"
-        onClick={() => mutate(!isOpened)}
+        onClick={() => setIsOpened(!isOpened)}
       >
         {children}
       </button>

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

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

+ 42 - 22
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type IPageInfoAll, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -33,18 +33,18 @@ export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
 export type ForceHideMenuItems = MenuItemType[];
 
-export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 
 type CommonProps = {
-  pageInfo?: IPageInfoAll,
+  pageInfo?: IPageInfoExt,
   isEnableActions?: boolean,
   isReadOnlyUser?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
@@ -58,6 +58,7 @@ type CommonProps = {
 type DropdownMenuProps = CommonProps & {
   pageId: string,
   isLoading?: boolean,
+  isDataUnavailable?: boolean,
   operationProcessData?: IPageOperationProcessData,
 }
 
@@ -65,7 +66,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
   const { t } = useTranslation('');
 
   const {
-    pageId, isLoading, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
+    pageId, isLoading, isDataUnavailable, pageInfo, isEnableActions, isReadOnlyUser, forceHideMenuItems, operationProcessData,
     onClickBookmarkMenuItem, onClickRenameMenuItem, onClickDuplicateMenuItem, onClickDeleteMenuItem,
     onClickRevertMenuItem, onClickPathRecoveryMenuItem,
     additionalMenuItemOnTopRenderer: AdditionalMenuItemsOnTop,
@@ -75,21 +76,24 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
-    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+    if (onClickBookmarkMenuItem == null) return;
+
+    if (!isIPageInfoForEmpty(pageInfo) && !isIPageInfoForOperation(pageInfo)) {
       return;
     }
+
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
-    if (onClickRenameMenuItem == null) {
-      return;
-    }
-    if (!pageInfo?.isMovable) {
+    if (onClickRenameMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
+
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
@@ -110,10 +114,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
-    if (pageInfo == null || onClickDeleteMenuItem == null) {
-      return;
-    }
-    if (!pageInfo.isDeletable) {
+    if (onClickDeleteMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       return;
     }
@@ -130,7 +133,15 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   let contents = <></>;
 
-  if (isLoading) {
+  if (isDataUnavailable) {
+    // Show message when data is not available (e.g., fetch error)
+    contents = (
+      <div className="text-warning text-center px-3">
+        <span className="material-symbols-outlined">error_outline</span> No data available
+      </div>
+    );
+  }
+  else if (isLoading) {
     contents = (
       <div className="text-muted text-center my-2">
         <LoadingSpinner />
@@ -164,7 +175,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -176,7 +187,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
@@ -200,7 +213,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Revert */}
-        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser && pageInfo.isRevertible && (
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -230,7 +245,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isDeletable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem
@@ -281,7 +298,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
   const [isOpen, setIsOpen] = useState(false);
   const [shouldFetch, setShouldFetch] = useState(false);
 
-  const { data: fetchedPageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
+  const { data: fetchedPageInfo, error: fetchError, mutate: mutatePageInfo } = useSWRxPageInfo(shouldFetch ? pageId : null);
 
   // update shouldFetch (and will never be false)
   useEffect(() => {
@@ -304,7 +321,9 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
     }
   }, [mutatePageInfo, onClickBookmarkMenuItem, shouldFetch]);
 
-  const isLoading = shouldFetch && fetchedPageInfo == null;
+  // isLoading should be true only when fetching is in progress (data and error are both undefined)
+  const isLoading = shouldFetch && fetchedPageInfo == null && fetchError == null;
+  const isDataUnavailable = !isLoading && fetchedPageInfo == null && presetPageInfo == null;
 
   const renameMenuItemClickHandler = useCallback(async() => {
     if (onClickRenameMenuItem == null) {
@@ -347,6 +366,7 @@ export const PageItemControlSubstance = (props: PageItemControlSubstanceProps):
           <PageItemControlDropdownMenu
             {...props}
             isLoading={isLoading}
+            isDataUnavailable={isDataUnavailable}
             pageInfo={fetchedPageInfo ?? presetPageInfo}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}

+ 65 - 61
apps/app/src/client/components/Common/ImageCropModal.tsx

@@ -76,14 +76,14 @@ const ImageCropModal: FC<Props> = (props: Props) => {
     reset();
   }, [reset]);
 
-  const onImageLoaded = (image) => {
+  // Memoize image processing functions
+  const onImageLoaded = useCallback((image) => {
     setImageRef(image);
     reset();
     return false;
-  };
-
+  }, [reset]);
 
-  const getCroppedImg = async(image: HTMLImageElement, crop: ICropOptions) => {
+  const getCroppedImg = useCallback(async(image: HTMLImageElement, crop: ICropOptions) => {
     const {
       naturalWidth: imageNaturalWidth, naturalHeight: imageNaturalHeight, width: imageWidth, height: imageHeight,
     } = image;
@@ -107,82 +107,86 @@ const ImageCropModal: FC<Props> = (props: Props) => {
       logger.error(err);
       toastError(new Error('Failed to draw image'));
     }
-  };
+  }, []);
 
   // Convert base64 Image to blob
-  const convertBase64ToBlob = async(base64Image: string) => {
+  const convertBase64ToBlob = useCallback(async(base64Image: string) => {
     const base64Response = await fetch(base64Image);
     return base64Response.blob();
-  };
+  }, []);
 
 
-  // Clear image and set isImageCrop true on modal close
-  const onModalCloseHandler = async() => {
+  // Memoize event handlers
+  const onModalCloseHandler = useCallback(async() => {
     setImageRef(null);
     onModalClose();
-  };
+  }, [onModalClose]);
 
-  // Process and save image
-  // Cropping image is optional
-  // If crop is active , the saved image is cropped image (png). Otherwise, the original image will be saved (Original size and file type)
-  const processAndSaveImage = async() => {
+  const processAndSaveImage = useCallback(async() => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
       const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
       // Save image to database
       onImageProcessCompleted(processedImage);
     }
     onModalCloseHandler();
-  };
+  }, [imageRef, cropOptions, isCropImage, getCroppedImg, convertBase64ToBlob, onImageProcessCompleted, onModalCloseHandler]);
+
+  const toggleCropMode = useCallback(() => setIsCropImage(!isCropImage), [isCropImage]);
+  const handleCropChange = useCallback((crop: CropOptions) => setCropOtions(crop), []);
 
   return (
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
-      <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
-        {t('crop_image_modal.image_crop')}
-      </ModalHeader>
-      <ModalBody className="my-4">
-        {
-          isCropImage
-            ? (
-              <ReactCrop
-                style={{ backgroundColor: 'transparent' }}
-                src={src}
-                crop={cropOptions}
-                onImageLoaded={onImageLoaded}
-                onChange={crop => setCropOtions(crop)}
-                circularCrop={isCircular}
-              />
+      {isShow && (
+        <>
+          <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
+            {t('crop_image_modal.image_crop')}
+          </ModalHeader>
+          <ModalBody className="my-4">
+            {
+              isCropImage
+                ? (
+                  <ReactCrop
+                    style={{ backgroundColor: 'transparent' }}
+                    src={src}
+                    crop={cropOptions}
+                    onImageLoaded={onImageLoaded}
+                    onChange={handleCropChange}
+                    circularCrop={isCircular}
+                  />
+                )
+                : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
+            }
+          </ModalBody>
+          <ModalFooter>
+            <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
+              {t('commons:Reset')}
+            </button>
+            { !showCropOption && (
+              <div className="me-auto">
+                <div className="form-check form-switch">
+                  <input
+                    id="cropImageOption"
+                    className="form-check-input me-auto"
+                    type="checkbox"
+                    checked={isCropImage}
+                    onChange={toggleCropMode}
+                  />
+                  <label className="form-label form-check-label" htmlFor="cropImageOption">
+                    { t('crop_image_modal.image_crop') }
+                  </label>
+                </div>
+              </div>
             )
-            : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
-        }
-      </ModalBody>
-      <ModalFooter>
-        <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
-          {t('commons:Reset')}
-        </button>
-        { !showCropOption && (
-          <div className="me-auto">
-            <div className="form-check form-switch">
-              <input
-                id="cropImageOption"
-                className="form-check-input me-auto"
-                type="checkbox"
-                checked={isCropImage}
-                onChange={() => { setIsCropImage(!isCropImage) }}
-              />
-              <label className="form-label form-check-label" htmlFor="cropImageOption">
-                { t('crop_image_modal.image_crop') }
-              </label>
-            </div>
-          </div>
-        )
-        }
-        <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
-          {t('crop_image_modal.cancel')}
-        </button>
-        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
-          { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
-        </button>
-      </ModalFooter>
+            }
+            <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
+              {t('crop_image_modal.cancel')}
+            </button>
+            <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
+              { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
+            </button>
+          </ModalFooter>
+        </>
+      )}
     </Modal>
   );
 };

+ 25 - 23
apps/app/src/client/components/CreateTemplateModal.tsx → apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
@@ -67,9 +67,11 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
     }
   }, [createTemplate, onClose, path, t]);
 
-  const parentPath = pathUtils.addTrailingSlash(path);
+  // Memoize computed path
+  const parentPath = useMemo(() => pathUtils.addTrailingSlash(path), [path]);
 
-  const renderTemplateCard = (target: TargetType, label: LabelType) => (
+  // Memoize template card rendering function
+  const renderTemplateCard = useCallback((target: TargetType, label: LabelType) => (
     <div className="col">
       <TemplateCard
         target={target}
@@ -78,29 +80,29 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
         onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
     </div>
-  );
-
-  if (!isCreatable) {
-    return <></>;
-  }
+  ), [isCreating, onClickTemplateButtonHandler]);
 
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
-      <ModalHeader tag="h4" toggle={onClose}>
-        {t('template.modal_label.Create/Edit Template Page')}
-      </ModalHeader>
-      <ModalBody>
-        <div>
-          <label className="form-label mb-4">
-            <code>{parentPath}</code><br />
-            {t('template.modal_label.Create template under')}
-          </label>
-          <div className="row row-cols-2">
-            {renderTemplateCard('children', '_template')}
-            {renderTemplateCard('descendants', '__template')}
-          </div>
-        </div>
-      </ModalBody>
+      {(isCreatable && isOpen) && (
+        <>
+          <ModalHeader tag="h4" toggle={onClose}>
+            {t('template.modal_label.Create/Edit Template Page')}
+          </ModalHeader>
+          <ModalBody>
+            <div>
+              <label className="form-label mb-4">
+                <code>{parentPath}</code><br />
+                {t('template.modal_label.Create template under')}
+              </label>
+              <div className="row row-cols-2">
+                {renderTemplateCard('children', '_template')}
+                {renderTemplateCard('descendants', '__template')}
+              </div>
+            </div>
+          </ModalBody>
+        </>
+      )}
     </Modal>
   );
 };

+ 19 - 0
apps/app/src/client/components/CreateTemplateModal/dynamic.tsx

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '../../../components/utils/use-lazy-loader';
+
+type CreateTemplateModalProps = {
+  path: string;
+  isOpen: boolean;
+  onClose: () => void;
+};
+
+export const CreateTemplateModalLazyLoaded = (props: CreateTemplateModalProps): JSX.Element => {
+  const CreateTemplateModal = useLazyLoader<CreateTemplateModalProps>(
+    'create-template-modal',
+    () => import('./CreateTemplateModal').then(mod => ({ default: mod.CreateTemplateModal })),
+    props.isOpen,
+  );
+
+  return CreateTemplateModal != null ? <CreateTemplateModal {...props} /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/CreateTemplateModal/index.ts

@@ -0,0 +1 @@
+export { CreateTemplateModalLazyLoaded } from './dynamic';

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

@@ -3,14 +3,14 @@ import React, { type JSX } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
-import { useGrowiDocumentationUrl } from '~/stores-universal/context';
+import { useGrowiDocumentationUrl } from '~/states/context';
 
 import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
 
 const DataTransferForm = (): JSX.Element => {
   const { t } = useTranslation('commons');
   const { transferKey, generateTransferKey } = useGenerateTransferKey();
-  const { data: documentationUrl } = useGrowiDocumentationUrl();
+  const documentationUrl = useGrowiDocumentationUrl();
 
   return (
     <div data-testid="installerForm" className="py-3 px-4">

+ 0 - 71
apps/app/src/client/components/DeleteBookmarkFolderModal.tsx

@@ -1,71 +0,0 @@
-
-import type { FC } from 'react';
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalBody, ModalFooter, ModalHeader,
-} from 'reactstrap';
-
-import { FolderIcon } from '~/client/components/Icons/FolderIcon';
-import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
-import { toastError } from '~/client/util/toastr';
-import { useBookmarkFolderDeleteModal } from '~/stores/modal';
-
-
-const DeleteBookmarkFolderModal: FC = () => {
-  const { t } = useTranslation();
-  const { data: deleteBookmarkFolderModalData, close: closeBookmarkFolderDeleteModal } = useBookmarkFolderDeleteModal();
-  const isOpened = deleteBookmarkFolderModalData?.isOpened ?? false;
-
-  async function deleteBookmark() {
-    if (deleteBookmarkFolderModalData == null || deleteBookmarkFolderModalData.bookmarkFolder == null) {
-      return;
-    }
-    if (deleteBookmarkFolderModalData.bookmarkFolder != null) {
-      try {
-        await deleteBookmarkFolder(deleteBookmarkFolderModalData.bookmarkFolder._id);
-        const onDeleted = deleteBookmarkFolderModalData.opts?.onDeleted;
-        if (onDeleted != null) {
-          onDeleted(deleteBookmarkFolderModalData.bookmarkFolder._id);
-        }
-        closeBookmarkFolderDeleteModal();
-      }
-      catch (err) {
-        toastError(err);
-      }
-    }
-  }
-  async function onClickDeleteButton() {
-    await deleteBookmark();
-  }
-
-  return (
-    <Modal size="md" isOpen={isOpened} toggle={closeBookmarkFolderDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
-      <ModalHeader tag="h4" toggle={closeBookmarkFolderDeleteModal} className="text-danger">
-        <span className="material-symbols-outlined">delete</span>
-        {t('bookmark_folder.delete_modal.modal_header_label')}
-      </ModalHeader>
-      <ModalBody>
-        <div className="pb-1 text-break">
-          <label className="form-label">{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
-          <FolderIcon isOpen={false} /> {deleteBookmarkFolderModalData?.bookmarkFolder?.name}
-        </div>
-        {t('bookmark_folder.delete_modal.modal_body_alert')}
-      </ModalBody>
-      <ModalFooter>
-        <button
-          type="button"
-          className="btn btn-danger"
-          onClick={onClickDeleteButton}
-        >
-          <span className="material-symbols-outlined" aria-hidden="true">delete</span>
-          {t('bookmark_folder.delete_modal.modal_footer_button')}
-        </button>
-      </ModalFooter>
-    </Modal>
-
-  );
-};
-
-export { DeleteBookmarkFolderModal };

+ 96 - 0
apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx

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

+ 18 - 0
apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useDeleteBookmarkFolderModalStatus } from '~/states/ui/modal/delete-bookmark-folder';
+
+type DeleteBookmarkFolderModalProps = Record<string, unknown>;
+
+export const DeleteBookmarkFolderModalLazyLoaded = (): JSX.Element => {
+  const status = useDeleteBookmarkFolderModalStatus();
+
+  const DeleteBookmarkFolderModal = useLazyLoader<DeleteBookmarkFolderModalProps>(
+    'delete-bookmark-folder-modal',
+    () => import('./DeleteBookmarkFolderModal').then(mod => ({ default: mod.DeleteBookmarkFolderModal })),
+    status?.isOpened ?? false,
+  );
+
+  return DeleteBookmarkFolderModal ? <DeleteBookmarkFolderModal /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/DeleteBookmarkFolderModal/index.ts

@@ -0,0 +1 @@
+export { DeleteBookmarkFolderModalLazyLoaded } from './dynamic';

+ 4 - 4
apps/app/src/client/components/DescendantsPageList.tsx

@@ -11,7 +11,7 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPagingResult } from '~/interfaces/paging-result';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import {
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList, mutateRecentlyUpdated,
@@ -42,8 +42,8 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
   } = props;
 
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const isGuestUser = useIsGuestUser();
+  const isReadOnlyUser = useIsReadOnlyUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
@@ -133,7 +133,7 @@ export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Elemen
 
   const [activePage, setActivePage] = useState(1);
 
-  const { data: isSharedUser } = useIsSharedUser();
+  const isSharedUser = useIsSharedUser();
 
   const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
 

+ 0 - 129
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -1,129 +0,0 @@
-
-import React, {
-  useState, useMemo, useEffect, type JSX,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import { useRouter } from 'next/router';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useIsSharedUser } from '~/stores-universal/context';
-import { useDescendantsPageListModal } from '~/stores/modal';
-import { useIsDeviceLargerThanLg } from '~/stores/ui';
-
-import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
-import type { DescendantsPageListProps } from './DescendantsPageList';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-import styles from './DescendantsPageListModal.module.scss';
-
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
-
-const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
-
-export const DescendantsPageListModal = (): JSX.Element => {
-  const { t } = useTranslation();
-
-  const [activeTab, setActiveTab] = useState('pagelist');
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const { data: isSharedUser } = useIsSharedUser();
-
-  const { data: status, close } = useDescendantsPageListModal();
-
-  const { events } = useRouter();
-
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
-
-  useEffect(() => {
-    events.on('routeChangeStart', close);
-    return () => {
-      events.off('routeChangeStart', close);
-    };
-  }, [close, events]);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => {
-          if (status == null || status.path == null || !status.isOpened) {
-            return <></>;
-          }
-          return <DescendantsPageList path={status.path} />;
-        },
-        i18n: t('page_list'),
-        isLinkEnabled: () => !isSharedUser,
-      },
-      timeline: {
-        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
-        Content: () => {
-          if (status == null || !status.isOpened) {
-            return <></>;
-          }
-          return <PageTimeline />;
-        },
-        i18n: t('Timeline View'),
-        isLinkEnabled: () => !isSharedUser,
-      },
-    };
-  }, [isSharedUser, status, t]);
-
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={() => setIsWindowExpanded(true)}
-        contractWindow={() => setIsWindowExpanded(false)}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={close} aria-label="Close"></button>
-    </span>
-  ), [close, isWindowExpanded]);
-
-  if (status == null) {
-    return <></>;
-  }
-
-  const { isOpened } = status;
-
-  return (
-    <Modal
-      size="xl"
-      isOpen={isOpened}
-      toggle={close}
-      data-testid="descendants-page-list-modal"
-      className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-    >
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
-        {isDeviceLargerThanLg && (
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            breakpointToHideInactiveTabsDown="md"
-            onNavSelected={v => setActiveTab(v)}
-            hideBorderBottom
-          />
-        )}
-      </ModalHeader>
-      <ModalBody>
-        {!isDeviceLargerThanLg && (
-          <CustomNavDropdown
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={v => setActiveTab(v)}
-          />
-        )}
-        <CustomTabContent
-          activeTab={activeTab}
-          navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
-        />
-      </ModalBody>
-    </Modal>
-  );
-
-};

+ 0 - 0
apps/app/src/client/components/DescendantsPageListModal.module.scss → apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss


+ 22 - 7
apps/app/src/client/components/DescendantsPageListModal.spec.tsx → apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx

@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
 const mockClose = vi.hoisted(() => vi.fn());
-const useIsDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+const useDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue([true]));
 
 vi.mock('next/router', () => ({
   useRouter: () => ({
@@ -14,15 +14,30 @@ vi.mock('next/router', () => ({
   }),
 }));
 
-vi.mock('~/stores/modal', () => ({
-  useDescendantsPageListModal: vi.fn().mockReturnValue({
-    data: { isOpened: true },
+vi.mock('~/states/ui/modal/descendants-page-list', () => ({
+  useDescendantsPageListModalStatus: vi.fn().mockReturnValue({
+    isOpened: true,
+    path: '/test/path',
+  }),
+  useDescendantsPageListModalActions: vi.fn().mockReturnValue({
     close: mockClose,
   }),
 }));
 
-vi.mock('~/stores/ui', () => ({
-  useIsDeviceLargerThanLg,
+vi.mock('~/states/ui/device', () => ({
+  useDeviceLargerThanLg,
+}));
+
+vi.mock('~/states/context', () => ({
+  useIsSharedUser: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock('../DescendantsPageList', () => ({
+  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+}));
+
+vi.mock('../PageTimeline', () => ({
+  PageTimeline: () => <div data-testid="page-timeline">PageTimeline</div>,
 }));
 
 describe('DescendantsPageListModal.tsx', () => {
@@ -54,7 +69,7 @@ describe('DescendantsPageListModal.tsx', () => {
 
   describe('when device is smaller than lg', () => {
     beforeEach(() => {
-      useIsDeviceLargerThanLg.mockReturnValue({ data: false });
+      useDeviceLargerThanLg.mockReturnValue([false]);
     });
 
     it('should render CustomNavDropdown on devices smaller than lg', () => {

+ 164 - 0
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -0,0 +1,164 @@
+
+import React, {
+  useState, useMemo, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useIsSharedUser } from '~/states/context';
+import { useDeviceLargerThanLg } from '~/states/ui/device';
+import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+
+import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
+import type { DescendantsPageListProps } from '../DescendantsPageList';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import styles from './DescendantsPageListModal.module.scss';
+
+const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('../DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+
+const PageTimeline = dynamic(() => import('../PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+
+/**
+ * DescendantsPageListModalSubstance - Presentation component (all logic here)
+ */
+type DescendantsPageListModalSubstanceProps = {
+  path: string | undefined;
+  closeModal: () => void;
+  onExpandedChange?: (isExpanded: boolean) => void;
+};
+
+const DescendantsPageListModalSubstance = ({
+  path,
+  closeModal,
+  onExpandedChange,
+}: DescendantsPageListModalSubstanceProps): React.JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const isSharedUser = useIsSharedUser();
+  const { events } = useRouter();
+  const [isDeviceLargerThanLg] = useDeviceLargerThanLg();
+
+  useEffect(() => {
+    events.on('routeChangeStart', closeModal);
+    return () => {
+      events.off('routeChangeStart', closeModal);
+    };
+  }, [closeModal, events]);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
+        Content: () => {
+          if (path == null) {
+            return <></>;
+          }
+          return <DescendantsPageList path={path} />;
+        },
+        i18n: t('page_list'),
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
+        Content: () => {
+          return <PageTimeline />;
+        },
+        i18n: t('Timeline View'),
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, path, t]);
+
+  // Memoize event handlers
+  const expandWindow = useCallback(() => {
+    setIsWindowExpanded(true);
+    onExpandedChange?.(true);
+  }, [onExpandedChange]);
+  const contractWindow = useCallback(() => {
+    setIsWindowExpanded(false);
+    onExpandedChange?.(false);
+  }, [onExpandedChange]);
+  const onNavSelected = useCallback((v: string) => setActiveTab(v), []);
+
+  const buttons = useMemo(() => (
+    <span className="me-3">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={expandWindow}
+        contractWindow={contractWindow}
+      />
+      <button type="button" className="btn btn-close ms-2" onClick={closeModal} aria-label="Close"></button>
+    </span>
+  ), [closeModal, isWindowExpanded, expandWindow, contractWindow]);
+
+  return (
+    <div>
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={onNavSelected}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody>
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={onNavSelected}
+          />
+        )}
+        <CustomTabContent
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+        />
+      </ModalBody>
+    </div>
+  );
+};
+
+/**
+ * DescendantsPageListModal - Container component (lightweight, always rendered)
+ */
+export const DescendantsPageListModal = (): React.JSX.Element => {
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+  const status = useDescendantsPageListModalStatus();
+  const { close } = useDescendantsPageListModalActions();
+
+  const handleExpandedChange = useCallback((isExpanded: boolean) => {
+    setIsWindowExpanded(isExpanded);
+  }, []);
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={status.isOpened}
+      toggle={close}
+      data-testid="descendants-page-list-modal"
+      className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''}`}
+    >
+      {status.isOpened && (
+        <DescendantsPageListModalSubstance
+          path={status?.path}
+          closeModal={close}
+          onExpandedChange={handleExpandedChange}
+        />
+      )}
+    </Modal>
+  );
+};

+ 18 - 0
apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+
+type DescendantsPageListModalProps = Record<string, unknown>;
+
+export const DescendantsPageListModalLazyLoaded = (): JSX.Element => {
+  const status = useDescendantsPageListModalStatus();
+
+  const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
+    'descendants-page-list-modal',
+    () => import('./DescendantsPageListModal').then(mod => ({ default: mod.DescendantsPageListModal })),
+    status?.isOpened ?? false,
+  );
+
+  return DescendantsPageListModal ? <DescendantsPageListModal /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/DescendantsPageListModal/index.ts

@@ -0,0 +1 @@
+export { DescendantsPageListModalLazyLoaded } from './dynamic';

+ 0 - 93
apps/app/src/client/components/EmptyTrashModal.tsx

@@ -1,93 +0,0 @@
-import type { FC } from 'react';
-import React, {
-  useState,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { apiv3Delete } from '~/client/util/apiv3-client';
-import { useEmptyTrashModal } from '~/stores/modal';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-
-const EmptyTrashModal: FC = () => {
-  const { t } = useTranslation();
-
-  const { data: emptyTrashModalData, close: closeEmptyTrashModal } = useEmptyTrashModal();
-
-  const isOpened = emptyTrashModalData?.isOpened ?? false;
-
-  const canDeleteAllpages = emptyTrashModalData?.opts?.canDeleteAllPages ?? false;
-
-  const [errs, setErrs] = useState<Error[] | null>(null);
-
-  async function emptyTrash() {
-    if (emptyTrashModalData == null || emptyTrashModalData.pages == null) {
-      return;
-    }
-
-    try {
-      await apiv3Delete('/pages/empty-trash');
-      const onEmptiedTrash = emptyTrashModalData.opts?.onEmptiedTrash;
-      if (onEmptiedTrash != null) {
-        onEmptiedTrash();
-      }
-      closeEmptyTrashModal();
-    }
-    catch (err) {
-      setErrs([err]);
-    }
-  }
-
-  async function emptyTrashButtonHandler() {
-    await emptyTrash();
-  }
-
-  const renderPagePaths = () => {
-    const pages = emptyTrashModalData?.pages;
-
-    if (pages != null) {
-      return pages.map(page => (
-        <p key={page.data._id} className="mb-1">
-          <code>{ page.data.path }</code>
-        </p>
-      ));
-    }
-    return <></>;
-  };
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
-      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="text-danger">
-        <span className="material-symbols-outlined">delete_forever</span>
-        {t('modal_empty.empty_the_trash')}
-      </ModalHeader>
-      <ModalBody>
-        <div className="grw-scrollable-modal-body pb-1">
-          <label className="form-label">{ t('modal_delete.deleting_page') }:</label><br />
-          {/* Todo: change the way to show path on modal when too many pages are selected */}
-          {renderPagePaths()}
-        </div>
-        {!canDeleteAllpages && t('modal_empty.not_deletable_notice')}<br />
-        {t('modal_empty.notice')}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button
-          type="button"
-          className="btn btn-danger"
-          onClick={emptyTrashButtonHandler}
-        >
-          <span className="material-symbols-outlined" aria-hidden="true">delete_forever</span>
-          {t('modal_empty.empty_the_trash_button')}
-        </button>
-      </ModalFooter>
-    </Modal>
-
-  );
-};
-
-export default EmptyTrashModal;

Some files were not shown because too many files changed in this diff