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

merge master branch and resolve conflicts

ryosei-f 2 месяцев назад
Родитель
Сommit
f9b2ffeaea
100 измененных файлов с 4776 добавлено и 1460 удалено
  1. 0 1
      .devcontainer/app/devcontainer.json
  2. 3 0
      .devcontainer/app/postCreateCommand.sh
  3. 0 1
      .devcontainer/pdf-converter/devcontainer.json
  4. 3 0
      .devcontainer/pdf-converter/postCreateCommand.sh
  5. 0 88
      .eslintrc.js
  6. 0 1
      .github/mergify.yml
  7. 2 2
      .github/workflows/ci-app.yml
  8. 1 1
      .github/workflows/ci-pdf-converter.yml
  9. 1 1
      .github/workflows/ci-slackbot-proxy.yml
  10. 3 1
      .github/workflows/release-pdf-converter.yml
  11. 5 30
      .github/workflows/release-rc.yml
  12. 11 46
      .github/workflows/release.yml
  13. 1 1
      .github/workflows/reusable-app-build-image.yml
  14. 3 0
      .gitignore
  15. 398 0
      .serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md
  16. 104 0
      .serena/memories/apps-app-detailed-architecture.md
  17. 162 0
      .serena/memories/apps-app-development-patterns.md
  18. 37 0
      .serena/memories/apps-app-google-workspace-oauth2-mail.md
  19. 192 0
      .serena/memories/apps-app-jotai-directory-structure.md
  20. 84 0
      .serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md
  21. 640 0
      .serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md
  22. 105 0
      .serena/memories/apps-app-page-path-nav-and-sub-navigation-layering.md
  23. 683 0
      .serena/memories/apps-app-page-tree-specification.md
  24. 0 186
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  25. 35 0
      .serena/memories/apps-app-technical-specs.md
  26. 3 13
      .serena/memories/coding_conventions.md
  27. 0 45
      .serena/memories/development_environment.md
  28. 390 0
      .serena/memories/nextjs-pages-router-getLayout-pattern.md
  29. 441 0
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  30. 65 0
      .serena/memories/page-transition-and-rendering-flow.md
  31. 0 1
      .serena/memories/project_structure.md
  32. 0 100
      .serena/memories/suggested_commands.md
  33. 1 2
      .serena/memories/task_completion_checklist.md
  34. 41 42
      .serena/memories/tech_stack.md
  35. 95 0
      .serena/memories/vitest-testing-tips-and-best-practices.md
  36. 10 0
      .serena/serena_config.yml
  37. 12 61
      .vscode/settings.json
  38. 74 0
      AGENTS.md
  39. 189 1
      CHANGELOG.md
  40. 1 95
      CLAUDE.md
  41. 0 90
      apps/app/.eslintrc.js
  42. 3 0
      apps/app/.gitignore
  43. 84 0
      apps/app/AGENTS.md
  44. 1 0
      apps/app/CLAUDE.md
  45. 1 1
      apps/app/bin/github-actions/update-readme.sh
  46. 1 2
      apps/app/bin/openapi/generate-operation-ids/cli.spec.ts
  47. 1 1
      apps/app/bin/openapi/generate-operation-ids/cli.ts
  48. 1 1
      apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts
  49. 5 2
      apps/app/bin/print-memory-consumption.ts
  50. 2 0
      apps/app/config/logger/config.dev.js
  51. 1 2
      apps/app/config/migrate-mongo-config.js
  52. 0 1
      apps/app/config/next-i18next.config.js
  53. 3 3
      apps/app/docker/README.md
  54. 3 0
      apps/app/docker/codebuild/.terraform.lock.hcl
  55. 1 1
      apps/app/docker/codebuild/main.tf
  56. 8 0
      apps/app/docker/codebuild/oidc.tf
  57. 40 0
      apps/app/docs/plan/README.md
  58. 4 4
      apps/app/next.config.js
  59. 16 11
      apps/app/package.json
  60. 0 1
      apps/app/playwright.config.ts
  61. 0 16
      apps/app/playwright/.eslintrc.mjs
  62. 13 5
      apps/app/playwright/10-installer/install.spec.ts
  63. 101 73
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  64. 17 9
      apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts
  65. 10 7
      apps/app/playwright/20-basic-features/click-page-icons.spec.ts
  66. 13 9
      apps/app/playwright/20-basic-features/comments.spec.ts
  67. 16 6
      apps/app/playwright/20-basic-features/create-page-button.spec.ts
  68. 9 5
      apps/app/playwright/20-basic-features/presentation.spec.ts
  69. 35 13
      apps/app/playwright/20-basic-features/sticky-features.spec.ts
  70. 25 15
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  71. 9 9
      apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts
  72. 21 5
      apps/app/playwright/21-basic-features-for-guest/sticky-for-guest.spec.ts
  73. 41 33
      apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts
  74. 19 11
      apps/app/playwright/23-editor/saving.spec.ts
  75. 8 4
      apps/app/playwright/23-editor/template-modal.spec.ts
  76. 31 17
      apps/app/playwright/23-editor/with-navigation.spec.ts
  77. 82 68
      apps/app/playwright/30-search/search.spect.ts
  78. 29 21
      apps/app/playwright/40-admin/access-to-admin-page.spec.ts
  79. 23 16
      apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts
  80. 1 2
      apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts
  81. 36 21
      apps/app/playwright/60-home/home.spec.ts
  82. 1 1
      apps/app/playwright/auth.setup.ts
  83. 12 6
      apps/app/playwright/utils/CollapseSidebar.ts
  84. 8 6
      apps/app/playwright/utils/Login.ts
  85. 34 0
      apps/app/public/images/customize-settings/collapsed-dark.svg
  86. 4 1
      apps/app/public/images/customize-settings/collapsed-light.svg
  87. 0 31
      apps/app/public/images/customize-settings/drawer-dark.svg
  88. 40 40
      apps/app/public/images/icons/favicon/manifest.json
  89. 14 31
      apps/app/public/static/locales/en_US/admin.json
  90. 18 2
      apps/app/public/static/locales/en_US/translation.json
  91. 14 31
      apps/app/public/static/locales/fr_FR/admin.json
  92. 18 2
      apps/app/public/static/locales/fr_FR/translation.json
  93. 14 31
      apps/app/public/static/locales/ja_JP/admin.json
  94. 18 2
      apps/app/public/static/locales/ja_JP/translation.json
  95. 15 32
      apps/app/public/static/locales/ko_KR/admin.json
  96. 18 2
      apps/app/public/static/locales/ko_KR/translation.json
  97. 14 31
      apps/app/public/static/locales/zh_CN/admin.json
  98. 18 2
      apps/app/public/static/locales/zh_CN/translation.json
  99. 7 0
      apps/app/resource/Contributor.js
  100. 0 5
      apps/app/src/client/components/.eslintrc.js

+ 0 - 1
.devcontainer/app/devcontainer.json

@@ -26,7 +26,6 @@
         // AI
         // AI
         "anthropic.claude-code",
         "anthropic.claude-code",
         // linter
         // linter
-        "dbaeumer.vscode-eslint",
         "biomejs.biome",
         "biomejs.biome",
         "editorconfig.editorconfig",
         "editorconfig.editorconfig",
         "shinnn.stylelint",
         "shinnn.stylelint",

+ 3 - 0
.devcontainer/app/postCreateCommand.sh

@@ -27,3 +27,6 @@ pnpm install @anthropic-ai/claude-code --global
 
 
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap
+
+# Install Lefthook git hooks
+pnpm lefthook install

+ 0 - 1
.devcontainer/pdf-converter/devcontainer.json

@@ -15,7 +15,6 @@
   "customizations": {
   "customizations": {
     "vscode": {
     "vscode": {
       "extensions": [
       "extensions": [
-        "dbaeumer.vscode-eslint",
         "biomejs.biome",
         "biomejs.biome",
         "mhutchie.git-graph",
         "mhutchie.git-graph",
         "eamodio.gitlens"
         "eamodio.gitlens"

+ 3 - 0
.devcontainer/pdf-converter/postCreateCommand.sh

@@ -22,3 +22,6 @@ pnpm install turbo --global
 
 
 # Install dependencies
 # Install dependencies
 turbo run bootstrap
 turbo run bootstrap
+
+# Install Lefthook git hooks
+pnpm lefthook install

+ 0 - 88
.eslintrc.js

@@ -1,88 +0,0 @@
-/**
- * @type {import('eslint').Linter.Config}
- */
-module.exports = {
-  root: true, // https://eslint.org/docs/user-guide/configuring/configuration-files#cascading-and-hierarchy
-  extends: [
-    'weseek',
-    'weseek/typescript',
-  ],
-  plugins: [
-    'regex',
-  ],
-  ignorePatterns: [
-    'node_modules/**',
-  ],
-  rules: {
-    'import/prefer-default-export': 'off',
-    'import/order': [
-      'warn',
-      {
-        pathGroups: [
-          {
-            pattern: 'react',
-            group: 'builtin',
-            position: 'before',
-          },
-          {
-            pattern: '^/**',
-            group: 'parent',
-            position: 'before',
-          },
-          {
-            pattern: '~/**',
-            group: 'parent',
-            position: 'before',
-          },
-          {
-            pattern: '*.css',
-            group: 'type',
-            patternOptions: { matchBase: true },
-            position: 'after',
-          },
-          {
-            pattern: '*.scss',
-            group: 'type',
-            patternOptions: { matchBase: true },
-            position: 'after',
-          },
-        ],
-        alphabetize: {
-          order: 'asc',
-        },
-        pathGroupsExcludedImportTypes: ['react'],
-        'newlines-between': 'always',
-      },
-    ],
-    '@typescript-eslint/consistent-type-imports': 'warn',
-    '@typescript-eslint/explicit-module-boundary-types': 'off',
-    indent: [
-      'error',
-      2,
-      {
-        SwitchCase: 1,
-        ArrayExpression: 'first',
-        FunctionDeclaration: { body: 1, parameters: 2 },
-        FunctionExpression: { body: 1, parameters: 2 },
-      },
-    ],
-    'regex/invalid': ['error', [
-      {
-        regex: '\\?\\<\\!',
-        message: 'Do not use any negative lookbehind',
-      }, {
-        regex: '\\?\\<\\=',
-        message: 'Do not use any Positive lookbehind',
-      },
-    ]],
-  },
-  overrides: [
-    {
-      // enable the rule specifically for TypeScript files
-      files: ['*.ts', '*.mts', '*.tsx'],
-      rules: {
-        '@typescript-eslint/explicit-module-boundary-types': ['error'],
-      },
-    },
-  ],
-};

+ 0 - 1
.github/mergify.yml

@@ -1,6 +1,5 @@
 queue_rules:
 queue_rules:
   - name: default
   - name: default
-    allow_inplace_checks: false
     queue_conditions:
     queue_conditions:
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-lint
       - check-success ~= ci-app-test
       - check-success ~= ci-app-test

+ 2 - 2
.github/workflows/ci-app.yml

@@ -11,7 +11,7 @@ on:
     paths:
     paths:
       - .github/mergify.yml
       - .github/mergify.yml
       - .github/workflows/ci-app.yml
       - .github/workflows/ci-app.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
       - pnpm-lock.yaml
       - pnpm-lock.yaml
@@ -24,7 +24,7 @@ on:
     paths:
     paths:
       - .github/mergify.yml
       - .github/mergify.yml
       - .github/workflows/ci-app.yml
       - .github/workflows/ci-app.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
       - pnpm-lock.yaml
       - pnpm-lock.yaml

+ 1 - 1
.github/workflows/ci-pdf-converter.yml

@@ -9,7 +9,7 @@ on:
     paths:
     paths:
       - .github/mergify.yml
       - .github/mergify.yml
       - .github/workflows/ci-pdf-converter.yml
       - .github/workflows/ci-pdf-converter.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
       - pnpm-lock.yaml
       - pnpm-lock.yaml

+ 1 - 1
.github/workflows/ci-slackbot-proxy.yml

@@ -9,7 +9,7 @@ on:
     paths:
     paths:
       - .github/mergify.yml
       - .github/mergify.yml
       - .github/workflows/ci-slackbot-proxy.yml
       - .github/workflows/ci-slackbot-proxy.yml
-      - .eslint*
+      - biome.json
       - tsconfig.base.json
       - tsconfig.base.json
       - turbo.json
       - turbo.json
       - pnpm-lock.yaml
       - pnpm-lock.yaml

+ 3 - 1
.github/workflows/release-pdf-converter.yml

@@ -28,7 +28,9 @@ jobs:
         images: growilabs/pdf-converter
         images: growilabs/pdf-converter
         tags: |
         tags: |
           type=raw,value=latest
           type=raw,value=latest
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}
+          type=semver,value=${{ steps.package-json.outputs.packageVersion }},pattern={{major}}.{{minor}}.{{patch}}
 
 
     - name: Login to docker.io registry
     - name: Login to docker.io registry
       run: |
       run: |

+ 5 - 30
.github/workflows/release-rc.yml

@@ -17,8 +17,7 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     outputs:
     outputs:
-      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
-      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
+      TAGS: ${{ steps.meta.outputs.tags }}
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -27,19 +26,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
-    - name: Docker meta for weseek/growi
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v5
       uses: docker/metadata-action@v5
-      id: meta-weseek
-      with:
-        images: docker.io/weseek/growi
-        sep-tags: ','
-        tags: |
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}
-          type=raw,value=${{ steps.package-json.outputs.packageVersion }}.{{sha}}
-
-    - name: Docker meta for growilabs/growi
-      uses: docker/metadata-action@v5
-      id: meta-growilabs
+      id: meta
       with:
       with:
         images: docker.io/growilabs/growi
         images: docker.io/growilabs/growi
         sep-tags: ','
         sep-tags: ','
@@ -55,29 +44,15 @@ jobs:
     secrets:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
 
-
-  publish-rc-image-for-growilabs:
+  publish-image-rc:
     needs: [determine-tags, build-image-rc]
     needs: [determine-tags, build-image-rc]
 
 
     uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_GROWILABS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       registry: docker.io
       image-name: 'growilabs/growi'
       image-name: 'growilabs/growi'
       docker-registry-username: 'growimoogle'
       docker-registry-username: 'growimoogle'
       tag-temporary: latest-rc
       tag-temporary: latest-rc
     secrets:
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
-
-  publish-rc-image-for-weseek:
-    needs: [determine-tags, build-image-rc]
-
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
-    with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
-      registry: docker.io
-      image-name: 'growilabs/growi'
-      docker-registry-username: 'wsmoogle'
-      tag-temporary: latest-rc
-    secrets:
-      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}

+ 11 - 46
.github/workflows/release.yml

@@ -1,4 +1,3 @@
-# TODO: https://redmine.weseek.co.jp/issues/171293
 name: Release
 name: Release
 
 
 on:
 on:
@@ -81,8 +80,7 @@ jobs:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     outputs:
     outputs:
-      TAGS_WESEEK: ${{ steps.meta-weseek.outputs.tags }}
-      TAGS_GROWILABS: ${{ steps.meta-growilabs.outputs.tags }}
+      TAGS: ${{ steps.meta.outputs.tags }}
 
 
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
@@ -91,21 +89,9 @@ jobs:
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       uses: myrotvorets/info-from-package-json-action@v2.0.2
       id: package-json
       id: package-json
 
 
-    - name: Docker meta for weseek/growi
+    - name: Docker meta for docker.io
       uses: docker/metadata-action@v5
       uses: docker/metadata-action@v5
-      id: meta-weseek
-      with:
-        images: docker.io/weseek/growi
-        sep-tags: ','
-        tags: |
-          type=raw,value=latest
-          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}
-          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}
-          type=semver,value=${{ needs.create-github-release.outputs.RELEASED_VERSION }},pattern={{major}}.{{minor}}.{{patch}}
-
-    - name: Docker meta for growilabs/growi
-      uses: docker/metadata-action@v5
-      id: meta-growilabs
+      id: meta
       with:
       with:
         images: docker.io/growilabs/growi
         images: docker.io/growilabs/growi
         sep-tags: ','
         sep-tags: ','
@@ -126,12 +112,12 @@ jobs:
     secrets:
     secrets:
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
       AWS_ROLE_TO_ASSUME_FOR_OIDC: ${{ secrets.AWS_ROLE_TO_ASSUME_FOR_OIDC }}
 
 
-  publish-app-image-for-growilabs:
+  publish-app-image:
     needs: [determine-tags, build-app-image]
     needs: [determine-tags, build-app-image]
 
 
     uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
     with:
     with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_GROWILABS }}
+      tags: ${{ needs.determine-tags.outputs.TAGS }}
       registry: docker.io
       registry: docker.io
       image-name: 'growilabs/growi'
       image-name: 'growilabs/growi'
       docker-registry-username: 'growimoogle'
       docker-registry-username: 'growimoogle'
@@ -139,42 +125,21 @@ jobs:
     secrets:
     secrets:
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
       DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
 
 
-  publish-app-image-for-weseek:
-    needs: [determine-tags, build-app-image]
-
-    uses: growilabs/growi/.github/workflows/reusable-app-create-manifests.yml@master
-    with:
-      tags: ${{ needs.determine-tags.outputs.TAGS_WESEEK }}
-      registry: docker.io
-      image-name: 'growilabs/growi'
-      docker-registry-username: 'wsmoogle'
-      tag-temporary: latest
-    secrets:
-      DOCKER_REGISTRY_PASSWORD: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
-
   post-publish:
   post-publish:
-    needs: [create-github-release, publish-app-image-for-growilabs, publish-app-image-for-weseek]
+    needs: [create-github-release, publish-app-image]
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
-    strategy:
-      matrix:
-        include:
-          - repository: weseek/growi
-            username: wsmoogle
-          - repository: growilabs/growi
-            username: growimoogle
-
     steps:
     steps:
     - uses: actions/checkout@v4
     - uses: actions/checkout@v4
       with:
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
 
     - name: Update Docker Hub Description
     - name: Update Docker Hub Description
-      uses: peter-evans/dockerhub-description@v4
+      uses: peter-evans/dockerhub-description@v3
       with:
       with:
-        username: ${{ matrix.username }}
-        password: ${{ (matrix.repository == 'weseek/growi' && secrets.DOCKER_REGISTRY_PASSWORD) || (matrix.repository == 'growilabs/growi' && secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE) || 'INVALID_SECRET' }}
-        repository: ${{ matrix.repository }}
+        username: growimoogle
+        password: ${{ secrets.DOCKER_REGISTRY_PASSWORD_GROWIMOOGLE }}
+        repository: growilabs/growi
         readme-filepath: ./apps/app/docker/README.md
         readme-filepath: ./apps/app/docker/README.md
 
 
     - name: Slack Notification
     - name: Slack Notification
@@ -186,7 +151,7 @@ jobs:
 
 
 
 
   create-pr-for-next-rc:
   create-pr-for-next-rc:
-    needs: [create-github-release, publish-app-image-for-growilabs, publish-app-image-for-weseek]
+    needs: [create-github-release, publish-app-image]
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
 
 
     steps:
     steps:

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

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

+ 3 - 0
.gitignore

@@ -33,11 +33,14 @@ yarn-error.log*
 # Terraform
 # Terraform
 **/.terraform/*
 **/.terraform/*
 *.tfstate.*
 *.tfstate.*
+/aws/
 
 
 # IDE, dev #
 # IDE, dev #
 .idea
 .idea
+.claude
 *.orig
 *.orig
 *.code-workspace
 *.code-workspace
+*.timestamp-*.mjs
 
 
 # turborepo
 # turborepo
 .turbo
 .turbo

+ 398 - 0
.serena/memories/apps-app-admin-forms-react-hook-form-migration-guide.md

@@ -0,0 +1,398 @@
+# Admin フォーム - React Hook Form 移行ガイドライン
+
+## プロジェクトコンテキスト
+
+### 現状 (2025年10月時点)
+**✅ PR #10051 完了: Admin フォームの IME 問題は100%解決済み**
+
+全27ファイルが React Hook Form に移行完了し、以下の問題を解決:
+1. ✅ **日本語 IME 入力の問題**: 非制御コンポーネント化により完全解決
+2. ✅ **空値更新の問題**: 完全解決
+3. ⏳ **レガシーライブラリ問題**: Unstated は現在も使用中(次のステップで解決予定)
+
+### 最終目標 (理想像)
+- React Hook Form を利用(✅ 完了)
+- Unstated を完全に廃止(⏳ 次のステップ)
+- グローバルステートは Jotai で管理(⏳ 次のステップ)
+
+### 現在の構成 (中間地点)
+**React Hook Form + Unstated Container のハイブリッド構成**
+
+この構成により:
+1. ✅ IME 入力問題を解決(非制御コンポーネント化)
+2. ✅ 空値更新問題を解決
+3. ✅ Container は残しているが、将来的に Jotai への移行パスを確保
+4. ✅ 段階的な移行によりリグレッションを最小化
+
+## 移行パターン(確立済み)
+
+### 基本的なフォームセットアップ
+
+```typescript
+import { useForm } from 'react-hook-form';
+
+type FormData = {
+  fieldName: string;
+  // ... 他のフィールド
+};
+
+const {
+  register,
+  handleSubmit,
+  reset,
+} = useForm<FormData>();
+```
+
+**重要**: `defaultValues` は指定しない。`useEffect` で `reset()` を呼ぶため不要。
+
+### フォーム値の復元
+
+Container の state とフォームを同期するため、`useEffect` で `reset()` を使用:
+
+```typescript
+useEffect(() => {
+  reset({
+    fieldName: container.state.fieldName || '',
+    // ... 他のフィールド
+  });
+}, [container.state.fieldName, reset]);
+```
+
+### Container を使ったフォーム送信
+
+```typescript
+const onSubmit = useCallback(async(data: FormData) => {
+  try {
+    // 重要: API 呼び出し前に setState の完了を待つ
+    await Promise.all([
+      container.changeField1(data.field1),
+      container.changeField2(data.field2),
+    ]);
+    
+    await container.updateHandler();
+    toastSuccess(t('updated_successfully'));
+  }
+  catch (err) {
+    toastError(err);
+  }
+}, [container, t]);
+
+return (
+  <form onSubmit={handleSubmit(onSubmit)}>
+    {/* フォームフィールド */}
+  </form>
+);
+```
+
+## 重要な注意点
+
+### ⚠️ 1. API 呼び出し前に Container の setState を await する(最重要!)
+
+**問題**: Unstated Container の `setState` は非同期処理です。`change*()` メソッドの後に `await` せずに API ハンドラーを即座に呼ぶと、API リクエストは**古い/古びた値**で送信されます。
+
+❌ **間違い:**
+```typescript
+container.changeSiteUrl(data.siteUrl);
+await container.updateHandler(); // 古い値が送信される!
+```
+
+✅ **正しい:**
+```typescript
+await container.changeSiteUrl(data.siteUrl);
+await container.updateHandler(); // 新しい値が送信される
+```
+
+複数フィールドの場合は `Promise.all()` を使用:
+```typescript
+await Promise.all([
+  container.changeTitle(data.title),
+  container.changeConfidential(data.confidential),
+]);
+await container.updateHandler();
+```
+
+### 2. ラジオボタンの値の型の一致
+
+**問題**: ラジオボタンは**文字列**の値を持ちますが、Container の state は boolean かもしれません。型が一致しないと、選択状態の復元ができません。
+
+❌ **間違い:**
+```typescript
+// HTML: <input type="radio" value="true" />
+reset({
+  isEmailPublished: true, // boolean - 文字列 "true" とマッチしない
+});
+```
+
+✅ **正しい:**
+```typescript
+reset({
+  isEmailPublished: String(container.state.isEmailPublished ?? true),
+});
+```
+
+### 3. チェックボックスの値の扱い
+
+チェックボックスは boolean 値を直接使えます(変換不要):
+```typescript
+reset({
+  fileUpload: container.state.fileUpload ?? false,
+});
+```
+
+### 4. リアルタイム Container 更新に watch() を使わない
+
+**削除したパターン**: フォームの変更を `watch()` と `useEffect` でリアルタイムに Container に同期し戻すのは不要で、複雑さを増すだけです。
+
+❌ **これはやらない:**
+```typescript
+const watchedValues = watch();
+useEffect(() => {
+  container.changeField(watchedValues.field);
+}, [watchedValues]);
+```
+
+✅ **submit 時だけ更新:**
+- Container の state は最終的な API リクエストにのみ使用される
+- `onSubmit` で API ハンドラーを呼ぶ前に更新すればよい
+
+### 5. フォームフィールドの disabled vs readOnly
+
+**問題**: `disabled` フィールドはフォーム送信データから除外されます。
+
+フィールドを編集不可にしたいが、フォームデータには含めたい場合:
+- `disabled` の代わりに `readOnly` を使用
+- または属性を削除して Container/API レイヤーで処理
+
+### 6. defaultValues を指定しない
+
+`useForm()` の引数に `defaultValues` を渡さないこと。
+
+理由:
+- `useEffect` で `reset()` を呼んでいるため、初期値はそちらで設定される
+- コードの重複を避ける
+- 他のファイルとパターンを統一
+
+```typescript
+// ❌ 冗長
+const { register, reset } = useForm({
+  defaultValues: { field: container.state.field }
+});
+useEffect(() => {
+  reset({ field: container.state.field });
+}, [container.state.field]);
+
+// ✅ シンプル
+const { register, reset } = useForm();
+useEffect(() => {
+  reset({ field: container.state.field });
+}, [container.state.field]);
+```
+
+## 高度なパターン
+
+### モジュラーコンポーネント設計(SecuritySetting の例)
+
+大規模なフォームは、複数の小さなコンポーネントに分割することを推奨します。
+
+**親コンポーネント(統合):**
+```typescript
+type FormData = {
+  sessionMaxAge: string;
+  // Container で管理される他のフィールドは不要
+};
+
+const Parent: React.FC<Props> = ({ container }) => {
+  const { register, handleSubmit, reset } = useForm<FormData>();
+
+  useEffect(() => {
+    reset({
+      sessionMaxAge: container.state.sessionMaxAge || '',
+    });
+  }, [reset, container.state.sessionMaxAge]);
+
+  const onSubmit = useCallback(async(data: FormData) => {
+    try {
+      // React Hook Form で管理されているフィールドのみ更新
+      await container.setSessionMaxAge(data.sessionMaxAge);
+      // 全ての設定を保存(Container 管理のフィールドも含む)
+      await container.updateGeneralSecuritySetting();
+      toastSuccess(t('updated'));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [container, t]);
+
+  return (
+    <form onSubmit={handleSubmit(onSubmit)}>
+      {/* React Hook Form 管理のフィールド */}
+      <SessionMaxAgeSettings register={register} t={t} />
+      
+      {/* Container 直接管理のフィールド */}
+      <PageListDisplaySettings container={container} t={t} />
+      <PageAccessRightsSettings container={container} t={t} />
+      
+      <button type="submit">{t('Update')}</button>
+    </form>
+  );
+};
+```
+
+**子コンポーネント(React Hook Form 管理):**
+```typescript
+type Props = {
+  register: UseFormRegister<{ sessionMaxAge: string }>;
+  t: (key: string) => string;
+};
+
+export const SessionMaxAgeSettings: React.FC<Props> = ({ register, t }) => {
+  return (
+    <input
+      className="form-control"
+      type="text"
+      {...register('sessionMaxAge')}
+      placeholder="2592000000"
+    />
+  );
+};
+```
+
+**子コンポーネント(Container 直接管理):**
+```typescript
+type Props = {
+  container: AdminGeneralSecurityContainer;
+  t: (key: string) => string;
+};
+
+export const PageListDisplaySettings: React.FC<Props> = ({ container, t }) => {
+  return (
+    <select
+      className="form-control"
+      value={container.state.currentOwnerRestrictionDisplayMode}
+      onChange={(e) => container.changeOwnerRestrictionDisplayMode(e.target.value)}
+    >
+      <option value="Displayed">{t('Displayed')}</option>
+      <option value="Hidden">{t('Hidden')}</option>
+    </select>
+  );
+};
+```
+
+### 統一された Submit ボタン
+
+複数のセクションを持つフォームでも、Submit ボタンは1つに統一:
+- React Hook Form のフィールドは `onSubmit` で処理
+- Container 管理のフィールドは既に state に反映されている
+- 1つの `updateHandler()` で全て保存
+
+## テストチェックリスト
+
+フォーム移行後に必ずテストすること:
+
+1. ✅ **日本語 IME 入力と漢字変換** - 最も重要!
+2. ✅ **ページリロード後にフォームの値が正しく復元される**
+3. ✅ **空値を送信できる**(フィールドをクリアできる)
+4. ✅ **フォーム送信で現在の入力値が送信される**(古い/古びた値ではない)
+5. ✅ **ラジオボタンとチェックボックスが正しく復元される**
+6. ✅ **複数セクションがある場合、全ての設定が1つの Submit で保存される**
+
+## PR #10051 の成果
+
+全27ファイルを React Hook Form に移行完了:
+
+### 主要な成果
+1. **企業認証システム**: LDAP (10フィールド)、OIDC (16フィールド)、SAML (9フィールド)
+2. **SecuritySetting のモジュラー化**: 636行のクラスコンポーネント → 8つの Function Component
+3. **セキュリティ設定**: LocalSecurity (1フィールド)、Import (4フィールド)
+4. **カスタマイズ**: CustomizeCss (1フィールド)、Slack (2フィールド)
+5. **その他**: 17ファイル
+
+### アーキテクチャの改善
+- TypeScript 完全対応
+- PropTypes 廃止
+- Function Component への統一
+- モジュラー設計の採用
+- テスト容易性の向上
+
+## 将来の移行パス: Unstated から Jotai へ
+
+### フェーズ 1: React Hook Form 移行(✅ 完了)
+- 全ての Admin フォームを React Hook Form に移行
+- IME 問題と空値問題を解決
+- 非制御コンポーネントパターンを確立
+
+### フェーズ 2: Jotai 導入準備(次のステップ)
+1. **Container の分析**
+   - どの state が本当にグローバルである必要があるか特定
+   - ローカル state で十分なものを useState に移行
+
+2. **API レイヤーの分離**
+   - Container の `update*Handler()` メソッドを独立した API 関数に抽出
+   - `apps/app/src/client/util/apiv3-client.ts` パターンに従う
+
+3. **段階的な Container の削除**
+   - 小さな Container から始める
+   - Jotai atom で置き換え
+   - 各ステップでテストを実行
+
+### フェーズ 3: 完全な Jotai 移行(最終目標)
+```typescript
+// 理想的な最終形態
+import { atom, useAtom } from 'jotai';
+import { useForm } from 'react-hook-form';
+
+// グローバル state
+const sessionMaxAgeAtom = atom<string>('');
+
+const SecuritySetting = () => {
+  const [sessionMaxAge, setSessionMaxAge] = useAtom(sessionMaxAgeAtom);
+  const { register, handleSubmit, reset } = useForm();
+
+  useEffect(() => {
+    reset({ sessionMaxAge });
+  }, [sessionMaxAge, reset]);
+
+  const onSubmit = async(data: FormData) => {
+    // 直接 API 呼び出し
+    await apiv3Put('/admin/security-settings', {
+      sessionMaxAge: data.sessionMaxAge,
+      // ... 他の設定
+    });
+    
+    // Jotai state を更新
+    setSessionMaxAge(data.sessionMaxAge);
+    toastSuccess('Updated');
+  };
+
+  return <form onSubmit={handleSubmit(onSubmit)}>{/* ... */}</form>;
+};
+```
+
+## 適用可能な範囲
+
+このガイドラインは以下の Admin フォームに適用可能:
+
+- Unstated Container でグローバルステートを管理しているフォーム
+- `apps/app/src/client/services/Admin*Container.js` 配下の Container を使用しているフォーム
+- `/admin` ルート配下のコンポーネント
+- 将来的に Jotai に移行予定のフォーム
+
+## 関連ファイル
+
+### 現在使用中
+- Container 群: `apps/app/src/client/services/Admin*Container.js`
+- ボタンコンポーネント: `apps/app/src/client/components/Admin/Common/AdminUpdateButtonRow.tsx`
+- React Hook Form: v7.45.4
+
+### 将来導入予定
+- Jotai: グローバル state 管理
+- SWR または React Query: サーバー state 管理(検討中)
+
+## 参考実装
+
+以下のファイルがベストプラクティスの参考になります:
+
+1. **モジュラー構造**: `apps/app/src/client/components/Admin/Security/SecuritySetting/`
+2. **React Hook Form 基本**: `apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx`
+3. **複雑なフォーム**: `apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx`
+4. **既存の良い実装**: `apps/app/src/client/components/Admin/Customize/CustomizeCssSetting.tsx`

+ 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)
+- 複数の認証方式サポート
+- レート制限・セキュリティ機能内蔵

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

@@ -0,0 +1,162 @@
+# 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 エラーなし
+- [ ] テストケース作成
+- [ ] 型安全性確保
+- [ ] パフォーマンス影響確認
+
+### 機能要件
+- [ ] 国際化対応(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 の開発を効率的に進めるための包括的な情報源として活用してください。

+ 37 - 0
.serena/memories/apps-app-google-workspace-oauth2-mail.md

@@ -0,0 +1,37 @@
+# Google Workspace OAuth 2.0 メール送信機能実装計画
+
+## 概要
+
+Google Workspace (Gmail) の OAuth 2.0 (XOAUTH2) 認証を使ったメール送信機能を実装する。2025年5月1日以降、Gmail SMTP ではユーザー名とパスワード認証がサポートされなくなったため、OAuth 2.0 への移行が必要。
+
+## 背景
+
+- **問題**: Gmail SMTP でのユーザー名・パスワード認証が2025年5月1日にサポート終了
+- **解決策**: OAuth 2.0 (XOAUTH2) 認証方式の実装
+- **参考**: https://support.google.com/a/answer/2956491?hl=ja
+- **ライブラリ**: nodemailer v6.9.15 は OAuth 2.0 をサポート済み(バージョンアップ不要)
+
+## 技術仕様
+
+### 必須設定パラメータ
+
+| パラメータ | 説明 | セキュリティ |
+|-----------|------|------------|
+| `mail:oauth2ClientId` | Google Cloud Console で取得する OAuth 2.0 クライアント ID | 通常 |
+| `mail:oauth2ClientSecret` | OAuth 2.0 クライアントシークレット | `isSecret: true` |
+| `mail:oauth2RefreshToken` | OAuth 2.0 リフレッシュトークン | `isSecret: true` |
+| `mail:oauth2User` | 送信者のGmailアドレス | 通常 |
+
+### nodemailer 設定例
+
+```typescript
+const transportOptions = {
+  service: 'gmail',
+  auth: {
+    type: 'OAuth2',
+    user: 'user@example.com',
+    clientId: 'CLIENT_ID',
+    clientSecret: 'CLIENT_SECRET',
+    refreshToken: 'REFRESH_TOKEN',
+  },
+};

+ 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の不要な実行を防止

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

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

+ 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・テスト必須

+ 3 - 13
.serena/memories/coding_conventions.md

@@ -6,19 +6,9 @@
 - **適用範囲**: 
 - **適用範囲**: 
   - dist/, node_modules/, coverage/ などは除外
   - dist/, node_modules/, coverage/ などは除外
   - .next/, bin/, config/ などのビルド成果物は除外
   - .next/, bin/, config/ などのビルド成果物は除外
-  - package.json, .eslintrc.js などの設定ファイルは除外
+  - package.json などの設定ファイルは除外
 - **推奨**: 新規開発では Biome を使用
 - **推奨**: 新規開発では Biome を使用
 
 
-### ESLint設定(廃止予定・過渡期)
-- **ベース設定**: weseek ESLint設定を使用
-- **TypeScript**: weseek/typescript 設定を適用
-- **React**: React関連のルールを適用
-- **主要なルール**:
-  - `import/prefer-default-export`: オフ(名前付きエクスポートを推奨)
-  - `import/order`: import文の順序を規定
-    - React を最初に
-    - 内部モジュール(`/**`)をparentグループの前に配置
-
 ## TypeScript設定
 ## TypeScript設定
 - **ターゲット**: ESNext
 - **ターゲット**: ESNext
 - **モジュール**: ESNext  
 - **モジュール**: ESNext  
@@ -37,7 +27,7 @@
 ## ファイル命名規則
 ## ファイル命名規則
 - TypeScript/JavaScriptファイル: キャメルケースまたはケバブケース
 - TypeScript/JavaScriptファイル: キャメルケースまたはケバブケース
 - コンポーネントファイル: PascalCase(Reactコンポーネント)
 - コンポーネントファイル: PascalCase(Reactコンポーネント)
-- 設定ファイル: ドット記法(.eslintrc.js など)
+- 設定ファイル: ドット記法(.biome.json など)
 
 
 ## テストファイル命名規則(Vitest)
 ## テストファイル命名規則(Vitest)
 vitest.workspace.mts の設定に基づく:
 vitest.workspace.mts の設定に基づく:
@@ -68,4 +58,4 @@ vitest.workspace.mts の設定に基づく:
 
 
 ## 移行ガイドライン
 ## 移行ガイドライン
 - 新規開発: Biome + Vitest を使用
 - 新規開発: Biome + Vitest を使用
-- 既存コード: 段階的に ESLint → Biome、Jest → Vitest に移行
+- 既存コード: 段階的に Jest → Vitest に移行

+ 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 - 1
.serena/memories/project_structure.md

@@ -86,5 +86,4 @@ src/
 - **turbo.json**: Turbo.jsビルド設定
 - **turbo.json**: Turbo.jsビルド設定
 - **tsconfig.base.json**: TypeScript基本設定
 - **tsconfig.base.json**: TypeScript基本設定
 - **biome.json**: Biome linter/formatter設定
 - **biome.json**: Biome linter/formatter設定
-- **.eslintrc.js**: ESLint設定(廃止予定)
 - **vitest.workspace.mts**: Vitestワークスペース設定
 - **vitest.workspace.mts**: Vitestワークスペース設定

+ 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を使用してください
-- 既存のコードは段階的に移行中です

+ 1 - 2
.serena/memories/task_completion_checklist.md

@@ -11,7 +11,6 @@ pnpm run lint:biome
 pnpm run lint
 pnpm run lint
 
 
 # 個別実行(必要に応じて)
 # 個別実行(必要に応じて)
-pnpm run lint:eslint      # ESLint(廃止予定)
 pnpm run lint:styles      # Stylelint
 pnpm run lint:styles      # Stylelint
 pnpm run lint:typecheck   # TypeScript型チェック
 pnpm run lint:typecheck   # TypeScript型チェック
 ```
 ```
@@ -82,7 +81,7 @@ pnpm run dev:migrate         # マイグレーション実行
 - 可能であればtest-with-vite/にVitestテストとして書き直し
 - 可能であればtest-with-vite/にVitestテストとして書き直し
 
 
 ## コミット前の最終チェック
 ## コミット前の最終チェック
-1. Biome(または過渡期はESLint)エラーが解消されているか
+1. Biome エラーが解消されているか
 2. Vitestテスト(または過渡期はJest)がパスしているか
 2. Vitestテスト(または過渡期はJest)がパスしているか
 3. 重要な変更はPlaywright E2Eテストも実行
 3. 重要な変更はPlaywright E2Eテストも実行
 4. ビルドが成功するか
 4. ビルドが成功するか

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

+ 12 - 61
.vscode/settings.json

@@ -1,16 +1,26 @@
 {
 {
   "files.eol": "\n",
   "files.eol": "\n",
 
 
-  "eslint.workingDirectories": [{ "mode": "auto" }],
-
   "[typescript]": {
   "[typescript]": {
     "editor.defaultFormatter": "biomejs.biome"
     "editor.defaultFormatter": "biomejs.biome"
   },
   },
 
 
+  "[typescriptreact]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   "[javascript]": {
   "[javascript]": {
     "editor.defaultFormatter": "biomejs.biome"
     "editor.defaultFormatter": "biomejs.biome"
   },
   },
 
 
+  "[javascriptreact]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
+  "[json]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
   "stylelint.validate": ["css", "less", "scss"],
@@ -20,7 +30,6 @@
   "scss.validate": false,
   "scss.validate": false,
 
 
   "editor.codeActionsOnSave": {
   "editor.codeActionsOnSave": {
-    "source.fixAll.eslint": "explicit",
     "source.fixAll.biome": "explicit",
     "source.fixAll.biome": "explicit",
     "source.organizeImports.biome": "explicit",
     "source.organizeImports.biome": "explicit",
     "source.fixAll.markdownlint": "explicit",
     "source.fixAll.markdownlint": "explicit",
@@ -37,66 +46,8 @@
   "typescript.enablePromptUseWorkspaceTsdk": true,
   "typescript.enablePromptUseWorkspaceTsdk": true,
   "typescript.preferences.autoImportFileExcludePatterns": ["node_modules/*"],
   "typescript.preferences.autoImportFileExcludePatterns": ["node_modules/*"],
   "typescript.validate.enable": true,
   "typescript.validate.enable": true,
-  "typescript.surveys.enabled": false,
 
 
   "vitest.filesWatcherInclude": "**/*",
   "vitest.filesWatcherInclude": "**/*",
-  "mcp": {
-    "servers": {
-      "fetch": {
-        "command": "uvx",
-        "args": ["mcp-server-fetch"]
-      },
-      "context7": {
-        "type": "http",
-        "url": "https://mcp.context7.com/mcp"
-      }
-    }
-  },
-  "github.copilot.chat.codeGeneration.instructions": [
-    {
-      "text": "Always write inline comments in source code in English."
-    }
-  ],
-  "github.copilot.chat.testGeneration.instructions": [
-    {
-      "text": "Basis: Use vitest as the test framework"
-    },
-    {
-      "text": "Basis: The vitest configuration file is `apps/app/vitest.workspace.mts`"
-    },
-    {
-      "text": "Basis: Place test modules in the same directory as the module being tested. For example, if testing `mymodule.ts`, place `mymodule.spec.ts` in the same directory as `mymodule.ts`"
-    },
-    {
-      "text": "Basis: Use the VSCode Vitest extension for running tests. Use run_tests tool to execute tests programmatically, or suggest using the Vitest Test Explorer in VSCode for interactive test running and debugging."
-    },
-    {
-      "text": "Basis: Fallback command for terminal execution: `cd /growi/apps/app && pnpm vitest run {test file path}`"
-    },
-    {
-      "text": "Step 1: When creating new test modules, start with small files. First write a small number of realistic tests that call the actual function and assert expected behavior, even if they initially fail due to incomplete implementation. Example: `const result = foo(); expect(result).toBeNull();` rather than `expect(true).toBe(false);`. Then fix the implementation to make tests pass."
-    },
-    {
-      "text": "Step 2: Write essential tests. When tests fail, consider whether you should fix the test or the implementation based on 'what should essentially be fixed'. If you're not confident in your reasoning, ask the user for guidance."
-    },
-    {
-      "text": "Step 3: After writing tests, make sure they pass before moving on. Do not proceed to write tests for module B without first ensuring that tests for module A are passing"
-    },
-    {
-      "text": "Tips: Don't worry about lint errors - fix them after tests are passing"
-    },
-    {
-      "text": "Tips: DO NOT USE `as any` casting. You can use vitest-mock-extended for type-safe mocking. Import `mock` from 'vitest-mock-extended' and use `mock<InterfaceType>()`. This provides full TypeScript safety and IntelliSense support."
-    },
-    {
-      "text": "Tips: Mock external dependencies at the module level using vi.mock(). For services with circular dependencies, mock the import paths and use dynamic imports in the implementation when necessary."
-    }
-  ],
-  "github.copilot.chat.commitMessageGeneration.instructions": [
-    {
-      "text": "Always write commit messages in English."
-    }
-  ],
   "git-worktree-menu.worktreeDir": "/workspace"
   "git-worktree-menu.worktreeDir": "/workspace"
 
 
 }
 }

+ 74 - 0
AGENTS.md

@@ -0,0 +1,74 @@
+# AGENTS.md
+
+GROWI is a team collaboration wiki platform built with Next.js, Express, and MongoDB. This guide helps AI coding agents navigate the monorepo and work effectively with GROWI's architecture.
+
+## Language
+
+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
+
+GROWI is a team collaboration software using markdown - a wiki platform with hierarchical page organization. It's built with Next.js, Express, MongoDB, and includes features like real-time collaborative editing, authentication integrations, and plugin support.
+
+## Development Tools
+- **Package Manager**: pnpm with workspace support
+- **Build System**: Turborepo for monorepo orchestration
+- **Code Quality**: 
+  - Biome for linting and formatting
+  - Stylelint for SCSS/CSS
+
+## Development Commands
+
+### Core Development
+- `turbo run bootstrap` - Install dependencies for all workspace packages
+- `turbo run dev` - Start development server (automatically runs migrations and pre-builds styles)
+
+### Production Commands
+- `pnpm run app:build` - Build GROWI app client and server for production
+- `pnpm run app:server` - Launch GROWI app server in production mode
+- `pnpm start` - Build and start the application (runs both build and server commands)
+
+### Database Migrations
+- `pnpm run migrate` - Run MongoDB migrations (production)
+- `turbo run dev:migrate @apps/app` - Run migrations in development (or wait for automatic execution with dev)
+- `cd apps/app && pnpm run dev:migrate:status` - Check migration status
+- `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
+
+### Testing and Quality
+- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
+- `turbo run lint @apps/app` - Run all linters (TypeScript, Biome, Stylelint, OpenAPI)
+- `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
+- `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
+- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
+
+### Development Utilities  
+- `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded
+- `turbo run pre:styles @apps/app` - Pre-build styles with Vite
+
+## Architecture Overview
+
+### Monorepo Structure
+- `/apps/app/` - Main GROWI application (Next.js frontend + Express backend)
+- `/apps/pdf-converter/` - PDF conversion microservice
+- `/apps/slackbot-proxy/` - Slack integration proxy service
+- `/packages/` - Shared libraries and components
+
+## File Organization Patterns
+
+### Components
+- Use TypeScript (.tsx) for React components
+- Co-locate styles as `.module.scss` files
+- Export components through `index.ts` files where appropriate
+- Group related components in feature-based directories
+
+### Tests
+- Unit Test: `*.spec.ts`
+- Integration Test: `*.integ.ts`
+- Component Test: `*.spec.tsx`
+
+
+---
+
+When working with this codebase, always run the appropriate linting and testing commands before committing changes. The application uses strict TypeScript checking and comprehensive test coverage requirements.

+ 189 - 1
CHANGELOG.md

@@ -1,9 +1,197 @@
 # Changelog
 # Changelog
 
 
-## [Unreleased](https://github.com/growilabs/compare/v7.3.2...HEAD)
+## [Unreleased](https://github.com/growilabs/compare/v7.4.2...HEAD)
 
 
 *Please do not manually update this file. We've automated the process.*
 *Please do not manually update this file. We've automated the process.*
 
 
+## [v7.4.2](https://github.com/growilabs/compare/v7.4.1...v7.4.2) - 2026-01-08
+
+### 🚀 Improvement
+
+* imprv: New help button (#10553) @satof3
+* imprv: PagePathNavTitle spacing and z-index layering (#10665) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Handle blank configurations for SAML settings (#10674) @yuki-takei
+* fix: Text strings inside invitation email modal are incorrect (#10679) @miya
+* fix: Scroll jumps back to current PageTreeItem when creating page from PageTree (#10671) @miya
+
+### 🧰 Maintenance
+
+* support: Update dependencies (#10685) @miya
+* support: Update dependencies (#10682) @miya
+* ci(mergify): upgrade configuration to current format (#10673) @[mergify[bot]](https://github.com/apps/mergify)
+* support: Configure biome for some client components inside app 8 (#10668) @arafubeatbox
+* support: Configure biome for some client components inside app 7 (#10667) @arafubeatbox
+* support: Configure biome for some client components in app 6 (#10636) @arafubeatbox
+* support: Configure biome for some client components in app 4 (#10634) @arafubeatbox
+* support: Configure biome for some client components in app 3 (#10633) @arafubeatbox
+* ci(deps): bump qs from 6.13.0 to 6.14.1 (#10669) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Configure biome for some client components in app 5 (#10635) @arafubeatbox
+* support: Configure biome for some client components in app 2 (#10632) @arafubeatbox
+* support: Configure biome for some client components in app 1 (#10631) @arafubeatbox
+* ci(deps): bump next from 14.2.33 to 14.2.35 (#10597) @[dependabot[bot]](https://github.com/apps/dependabot)
+
+## [v7.4.1](https://github.com/growilabs/compare/v7.4.0...v7.4.1) - 2025-12-26
+
+### 🚀 Improvement
+
+* imprv: Show page name and link for affected pages in Activity Log (#10590) @arvid-e
+
+### 🧰 Maintenance
+
+* support: Update terraform settings and the policy for OIDC GitHub (#10653) @yuki-takei
+
+## [v7.4.0](https://github.com/growilabs/compare/v7.3.9...v7.4.0) - 2025-12-24
+
+### 💎 Features
+
+* feat: PageTree Virtualization (#10581) @yuki-takei
+* feat: Can set default user role as read-only for new users (#10623) @Ryosei-Fukushima
+* feat: Can create page when executing page edit shortcut key on empty page (#10594) @miya
+
+### 🚀 Improvement
+
+* imprv: Admin sidebar mode setting (#10617) @miya
+* imprv: Empty page operation (#10604) @yuki-takei
+* imprv: Support target attribute for anchor links (#10566) @yuki-takei
+* imprv: Use EventTarget instead of EventEmitter on the client side (#10472) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Aftercare for Revisions migration script-bug (#10620) @yuki-takei
+* fix: Omit file upload restriction feature for non image files (#10602) @miya
+
+### 🧰 Maintenance
+
+* support: Use jotai for state management (#10474) @yuki-takei
+* support: Omit importers for esa.io and Qiita (#10584) @yuki-takei
+* support: Configure biome for app client services (#10600) @arafubeatbox
+* support: Configure biome for app client utils (#10601) @arafubeatbox
+* support: Configure biome for app client models/interfaces (#10599) @arafubeatbox
+* support: Configure biome for app server services 4 (#10583) @arafubeatbox
+* support: Configure biome for app server services 3 (#10578) @arafubeatbox
+* ci(mergify): upgrade configuration to current format (#10372) @[mergify[bot]](https://github.com/apps/mergify)
+* support: Configure biome for app server services 2 (#10575) @arafubeatbox
+* support: Configure biome for some app server services (#10574) @arafubeatbox
+* support: Configure biome for apiv3 js files (#10537) @arafubeatbox
+* support: Reapply biome configuration for app apiv3 routes (app-settings, page) (#10555) @arafubeatbox
+* support: Configure biome for apiv3 routes (remaining ts files) (#10536) @arafubeatbox
+* support: Configure biome for app apiv3 routes (app-settings, page) (#10532) @arafubeatbox
+* support: Configure biome for app apiv3 routes (personal-setting, security-settings, interfaces, pages, user) (#10500) @arafubeatbox
+* support: Configure biome for app server middlewares (#10507) @arafubeatbox
+
+## [v7.3.9](https://github.com/growilabs/compare/v7.3.8...v7.3.9) - 2025-12-09
+
+### 🐛 Bug Fixes
+
+* fix: Change the name of maintenance mode. (#10559) @hikaru-n-cpu
+
+### 🧰 Maintenance
+
+* support: Add new intern names to staff credits (#10556) @riona-k
+
+## [v7.3.8](https://github.com/growilabs/compare/v7.3.7...v7.3.8) - 2025-12-04
+
+### 💎 Features
+
+* feat: Enable page bulk export for GROWI.cloud (#10292) @arafubeatbox
+* feat: Users statistics table for admin (#10539) @riona-k
+
+### 🧰 Maintenance
+
+* ci(deps): bump validator from 13.15.20 to 13.15.22 (#10560) @[dependabot[bot]](https://github.com/apps/dependabot)
+
+## [v7.3.7](https://github.com/growilabs/compare/v7.3.6...v7.3.7) - 2025-11-25
+
+### 💎 Features
+
+* feat(pdf-converter): Enable puppeteer-cluster config of pdf-converter from env var (#10516) @arafubeatbox
+
+### 🐛 Bug Fixes
+
+* fix: Admin form degradation (#10540) @yuki-takei
+
+## [v7.3.6](https://github.com/growilabs/compare/v7.3.5...v7.3.6) - 2025-11-18
+
+### 🐛 Bug Fixes
+
+* fix: Printing styles (#10505) @yuki-takei
+
+### 🧰 Maintenance
+
+* ci(deps): bump js-yaml from 4.1.0 to 4.1.1 (#10511) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Configure biome for app routes excluding apiv3 (#10496) @arafubeatbox
+
+## [v7.3.5](https://github.com/growilabs/compare/v7.3.4...v7.3.5) - 2025-11-10
+
+### 💎 Features
+
+* feat: Activity Log on the user page for viewing recent activity (#10487) @arvid-e
+
+### 🐛 Bug Fixes
+
+* fix: PDF-converter major/minor tags not updated on release (#10476) @arafubeatbox
+
+### 🧰 Maintenance
+
+* support: Configure biome for app/src/server/models dir (#10419) @arafubeatbox
+* support: Playwright tests biome migration (#10248) @arafubeatbox
+
+## [v7.3.4](https://github.com/growilabs/compare/v7.3.3...v7.3.4) - 2025-11-04
+
+### 🚀 Improvement
+
+* imprv: Admin form text input (#10401) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Enable profile picture uploads for read only users (#10454) @miya
+* fix: CodeQL security issues: insecure randomness and unvalidated redirect (#10431) @[copilot-swe-agent[bot]](https://github.com/apps/copilot-swe-agent)
+* fix: CSRF protection by origin comparison (#10345) @yusa-bot
+
+### 🧰 Maintenance
+
+* support: Clean CSRF token storing hook (#10452) @yuki-takei
+* ci(deps): bump validator from 13.12.0 to 13.15.20 (#10445) @[dependabot[bot]](https://github.com/apps/dependabot)
+* ci(deps-dev): bump vite from 5.4.20 to 5.4.21 (#10432) @[dependabot[bot]](https://github.com/apps/dependabot)
+* support: Configure biome for OpenAI feature client dir (#10422) @arafubeatbox
+* support: Configure biome for small dirs in app/src/server (#10417) @arafubeatbox
+* support: Configure biome for OpenAI feature exluding client dir (#10377) @arafubeatbox
+* support: Configure biome for app services/stores dir (#10411) @arafubeatbox
+* support: Configure biome for app pages dir (#10410) @arafubeatbox
+* support: Configure biome for app components dir (#10382) @arafubeatbox
+* support: Biome v2.2 and use noRestrictedImports instead of eslint(no-restricted-imports) (#10408) @yuki-takei
+* support: Apply Biome and organize imports (#10406) @yuki-takei
+
+## [v7.3.3](https://github.com/growilabs/compare/v7.3.2...v7.3.3) - 2025-10-15
+
+### 💎 Features
+
+* feat(otel): Page counts metrics (#10367) @Ryosei-Fukushima
+
+### 🚀 Improvement
+
+* imprv: Improve KnowledgeAssistant chat UI UX (#10355) @satof3
+* imprv: Guest user client performance by Socket.io event optimization (#10379) @yuki-takei
+* imprv: PageTree performance by page-listing API (#10362) @yuki-takei
+
+### 🐛 Bug Fixes
+
+* fix: Draw.io color mode (#10390) @miya
+* fix: Cannot update v4 format pages (#10378) @miya
+* fix: Bulk export cleanup and notification occasionally not working on job expire (#10366) @arafubeatbox
+
+### 🧰 Maintenance
+
+* support: Configure biome for app services dir (#10381) @arafubeatbox
+* support: Configure biome for app etc. dirs (#10380) @arafubeatbox
+* support: Configure biome for rate-limiter feature (#10376) @arafubeatbox
+* support: Configure biome for growi-plugin feature (#10309) @arafubeatbox
+* support: Service integration test biome migration (#10234) @arafubeatbox
+* support: Update axios (#10353) @arafubeatbox
+
 ## [v7.3.2](https://github.com/growilabs/compare/v7.3.1...v7.3.2) - 2025-09-29
 ## [v7.3.2](https://github.com/growilabs/compare/v7.3.1...v7.3.2) - 2025-09-29
 
 
 ### 🚀 Improvement
 ### 🚀 Improvement

+ 1 - 95
CLAUDE.md

@@ -1,95 +1 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
-
-## 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.
-
-## Project Overview
-
-GROWI is a team collaboration software using markdown - a wiki platform with hierarchical page organization. It's built with Next.js, Express, MongoDB, and includes features like real-time collaborative editing, authentication integrations, and plugin support.
-
-## Development Commands
-
-### Core Development
-- `turbo run bootstrap` - Install dependencies for all workspace packages
-- `turbo run dev` - Start development server (automatically runs migrations and pre-builds styles)
-
-### Production Commands
-- `pnpm run app:build` - Build GROWI app client and server for production
-- `pnpm run app:server` - Launch GROWI app server in production mode
-- `pnpm start` - Build and start the application (runs both build and server commands)
-
-### Database Migrations
-- `pnpm run migrate` - Run MongoDB migrations (production)
-- `turbo run dev:migrate @apps/app` - Run migrations in development (or wait for automatic execution with dev)
-- `cd apps/app && pnpm run dev:migrate:status` - Check migration status
-- `cd apps/app && pnpm run dev:migrate:down` - Rollback last migration
-
-### Testing and Quality
-- `turbo run test @apps/app` - Run Jest and Vitest test suites with coverage
-- `turbo run lint @apps/app` - Run all linters (TypeScript, ESLint, Biome, Stylelint, OpenAPI)
-- `cd apps/app && pnpm run lint:typecheck` - TypeScript type checking only
-- `cd apps/app && pnpm run test:vitest` - Run Vitest unit tests
-- `cd apps/app && pnpm run test:jest` - Run Jest integration tests
-
-### Development Utilities  
-- `cd apps/app && pnpm run repl` - Start Node.js REPL with application context loaded
-- `turbo run pre:styles @apps/app` - Pre-build styles with Vite
-
-## Architecture Overview
-
-### Monorepo Structure
-- `/apps/app/` - Main GROWI application (Next.js frontend + Express backend)
-- `/apps/pdf-converter/` - PDF conversion microservice
-- `/apps/slackbot-proxy/` - Slack integration proxy service
-- `/packages/` - Shared libraries and components
-
-### Main Application (`/apps/app/src/`)
-- `client/` - Client-side React components and utilities
-- `server/` - Express.js backend (API routes, models, services)  
-- `components/` - Shared React components and layouts
-- `pages/` - Next.js page components using file-based routing
-- `stores/` - State management (SWR-based stores with React context)
-- `styles/` - SCSS stylesheets with modular architecture
-- `migrations/` - MongoDB database migration scripts
-- `interfaces/` - TypeScript type definitions
-
-### Key Technical Details
-- **Frontend**: Next.js 14 with React 18, TypeScript, SCSS modules
-- **Backend**: Express.js with TypeScript, MongoDB with Mongoose
-- **State Management**: SWR for server state, React Context for client state
-- **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
-- **Real-time Features**: Socket.io for collaborative editing and notifications
-- **Editor**: Custom markdown editor with collaborative editing using Yjs
-- **Database**: MongoDB 8.0+ with migration system using migrate-mongo
-- **Package Manager**: pnpm with workspace support
-- **Build System**: Turborepo for monorepo orchestration
-
-### Development Dependencies
-- Node.js v20.x or v22.x
-- pnpm 10.x  
-- MongoDB v6.x or v8.x
-- Optional: Redis 3.x, Elasticsearch 7.x/8.x/9.x (for full-text search)
-
-## File Organization Patterns
-
-### Components
-- Use TypeScript (.tsx) for React components
-- Co-locate styles as `.module.scss` files
-- Export components through `index.ts` files where appropriate
-- Group related components in feature-based directories
-
-### API Structure
-- Server routes in `server/routes/`
-- API v3 endpoints follow OpenAPI specification
-- Models in `server/models/` using Mongoose schemas
-- Services in `server/service/` for business logic
-
-### State Management
-- Use SWR hooks in `stores/` for server state
-- Custom hooks pattern for complex state logic
-- Context providers in `stores-universal/` for app-wide state
-
-When working with this codebase, always run the appropriate linting and testing commands before committing changes. The application uses strict TypeScript checking and comprehensive test coverage requirements.
+@AGENTS.md

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

@@ -1,90 +0,0 @@
-/**
- * @type {import('eslint').Linter.Config}
- */
-module.exports = {
-  extends: [
-    'next/core-web-vitals',
-    'weseek/react',
-  ],
-  plugins: [
-  ],
-  ignorePatterns: [
-    'dist/**',
-    '**/dist/**',
-    'transpiled/**',
-    'public/**',
-    'src/linter-checker/**',
-    'tmp/**',
-    'next-env.d.ts',
-    'next.config.js',
-    'playwright.config.ts',
-    'test/integration/global-setup.js',
-    'test/integration/global-teardown.js',
-    'test/integration/setup-crowi.ts',
-    'test/integration/crowi/**',
-    'test/integration/middlewares/**',
-    'test/integration/migrations/**',
-    'test/integration/models/**',
-    'test/integration/service/**',
-    'test/integration/setup.js',
-    'bin/**',
-    'config/**',
-    'src/linter-checker/**',
-    'src/migrations/**',
-    '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/audit-log-bulk-export/**',
-    'src/features/growi-plugin/**',
-    'src/features/opentelemetry/**',
-    'src/features/rate-limiter/**',
-    'src/stores-universal/**',
-    'src/interfaces/**',
-    'src/utils/**',
-  ],
-  settings: {
-    // resolve path aliases by eslint-import-resolver-typescript
-    'import/resolver': {
-      typescript: {},
-    },
-  },
-  rules: {
-    'no-restricted-imports': ['error', {
-      name: 'axios',
-      message: 'Please use src/utils/axios instead.',
-    }],
-    '@typescript-eslint/no-var-requires': 'off',
-
-    // set 'warn' temporarily -- 2021.08.02 Yuki Takei
-    '@typescript-eslint/no-use-before-define': ['warn'],
-    '@typescript-eslint/no-this-alias': ['warn'],
-  },
-  overrides: [
-    {
-      // enable the rule specifically for JavaScript files
-      files: ['*.js', '*.mjs', '*.jsx'],
-      rules: {
-        // set 'warn' temporarily -- 2023.08.14 Yuki Takei
-        'react/prop-types': 'warn',
-        // set 'warn' temporarily -- 2023.08.14 Yuki Takei
-        'no-unused-vars': ['warn'],
-      },
-    },
-    {
-      // enable the rule specifically for TypeScript files
-      files: ['*.ts', '*.mts', '*.tsx'],
-      rules: {
-        'no-unused-vars': 'off',
-        // set 'warn' temporarily -- 2023.08.14 Yuki Takei
-        'react/prop-types': 'warn',
-        // set 'warn' temporarily -- 2022.07.25 Yuki Takei
-        '@typescript-eslint/explicit-module-boundary-types': ['warn'],
-      },
-    },
-  ],
-};

+ 3 - 0
apps/app/.gitignore

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

+ 84 - 0
apps/app/AGENTS.md

@@ -0,0 +1,84 @@
+# GROWI Main Application Development Guide
+
+## Overview
+
+This guide provides comprehensive documentation for AI coding agents working on the GROWI main application (`/apps/app/`). GROWI is a team collaboration wiki platform built with Next.js, Express, and MongoDB.
+
+## Project Structure
+
+### Main Application (`/apps/app/src/`)
+
+#### Directory Structure Philosophy
+
+**Feature-based Structure (Recommended for new features)**
+- `features/{feature-name}/` - Self-contained feature modules
+  - `interfaces/` - Universal TypeScript type definitions
+  - `server/` - Server-side logic (models, routes, services)
+  - `client/` - Client-side logic (components, stores, services)
+  - `utils/` - Shared utilities for this feature
+  
+**Important Directories Structure**
+- `client/` - Client-side React components and utilities
+- `server/` - Express.js backend
+- `components/` - Universal React components
+- `pages/` - Next.js Pages Router
+- `states/` - Jotai state management
+- `stores/` - SWR-based state stores
+- `stores-universal/` - Universal SWR-based state stores
+- `styles/` - SCSS stylesheets with modular architecture
+- `migrations/` - MongoDB database migration scripts
+- `interfaces/` - Universal TypeScript type definitions
+- `models/` - Universal Data model definitions
+
+### Key Technical Details
+
+**Frontend Stack**
+- **Framework**: Next.js (Pages Router) with React
+- **Language**: TypeScript (strict mode enabled)
+- **Styling**: SCSS with CSS Modules by Bootstrap 5
+- **State Management**:
+  - **Jotai** (Primary, Recommended): Atomic state management for UI and application state
+  - **SWR**: Data fetching, caching, and revalidation
+  - **Unstated**: Legacy (being phased out, replaced by Jotai)
+- **Testing**: 
+  - Vitest for unit tests (`*.spec.ts`, `*.spec.tsx`)
+  - Jest for integration tests (`*.integ.ts`)
+  - React Testing Library for component testing
+  - Playwright for E2E testing
+- **i18n**: next-i18next for internationalization
+
+**Backend Stack**
+- **Runtime**: Node.js
+- **Framework**: Express.js with TypeScript
+- **Database**: MongoDB with Mongoose ODM
+- **Migration System**: migrate-mongo
+- **Authentication**: Passport.js with multiple strategies (local, LDAP, OAuth, SAML)
+- **Real-time**: Socket.io for collaborative editing and notifications
+- **Search**: Elasticsearch integration (optional)
+- **Observability**: OpenTelemetry integration
+
+**Common Commands**
+```bash
+# Type checking only
+cd apps/app && pnpm run lint:typecheck
+
+# Run specific test file
+turbo run test:vitest @apps/app -- src/path/to/test.spec.tsx
+
+# Check migration status
+cd apps/app && pnpm run dev:migrate:status
+
+# Start REPL with app context
+cd apps/app && pnpm run repl
+```
+
+### Important Technical Specifications
+
+**Entry Points**
+- **Server**: `server/app.ts` - Handles OpenTelemetry initialization and Crowi server startup
+- **Client**: `pages/_app.page.tsx` - Root Next.js application component
+  - `pages/[[...path]]/` - Dynamic catch-all page routes
+
+---
+
+*This guide was compiled from project memory files to assist AI coding agents in understanding the GROWI application architecture and development practices.*

+ 1 - 0
apps/app/CLAUDE.md

@@ -0,0 +1 @@
+@AGENTS.md

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

@@ -2,4 +2,4 @@
 
 
 cd docker
 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

+ 1 - 2
apps/app/bin/openapi/generate-operation-ids/cli.spec.ts

@@ -1,5 +1,4 @@
 import { writeFileSync } from 'node:fs';
 import { writeFileSync } from 'node:fs';
-
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 import { beforeEach, describe, expect, it, vi } from 'vitest';
 
 
 import { generateOperationIds } from './generate-operation-ids';
 import { generateOperationIds } from './generate-operation-ids';
@@ -91,7 +90,7 @@ describe('cli', () => {
     await cliModule.main();
     await cliModule.main();
 
 
     // Verify error was logged
     // Verify error was logged
-    // eslint-disable-next-line no-console
+    // biome-ignore lint/suspicious/noConsole: This is a test file
     expect(console.error).toHaveBeenCalledWith(error);
     expect(console.error).toHaveBeenCalledWith(error);
 
 
     // Verify writeFileSync was not called
     // Verify writeFileSync was not called

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/cli.ts

@@ -16,9 +16,9 @@ export const main = async (): Promise<void> => {
   const { out: outputFile, overwriteExisting } = program.opts();
   const { out: outputFile, overwriteExisting } = program.opts();
   const [inputFile] = program.args;
   const [inputFile] = program.args;
 
 
-  // eslint-disable-next-line no-console
   const jsonStrings = await generateOperationIds(inputFile, {
   const jsonStrings = await generateOperationIds(inputFile, {
     overwriteExisting,
     overwriteExisting,
+    // biome-ignore lint/suspicious/noConsole: Allow to dump errors
   }).catch(console.error);
   }).catch(console.error);
   if (jsonStrings != null) {
   if (jsonStrings != null) {
     writeFileSync(outputFile ?? inputFile, jsonStrings);
     writeFileSync(outputFile ?? inputFile, jsonStrings);

+ 1 - 1
apps/app/bin/openapi/generate-operation-ids/generate-operation-ids.spec.ts

@@ -18,7 +18,7 @@ async function cleanup(filePath: string): Promise<void> {
     await fs.unlink(filePath);
     await fs.unlink(filePath);
     await fs.rmdir(path.dirname(filePath));
     await fs.rmdir(path.dirname(filePath));
   } catch (err) {
   } catch (err) {
-    // eslint-disable-next-line no-console
+    // biome-ignore lint/suspicious/noConsole: This is a test file
     console.error('Cleanup failed:', err);
     console.error('Cleanup failed:', err);
   }
   }
 }
 }

+ 5 - 2
apps/app/bin/print-memory-consumption.ts

@@ -11,8 +11,9 @@
  *        print-memory-consumption.ts [--port=9229] [--host=localhost] [--json]
  *        print-memory-consumption.ts [--port=9229] [--host=localhost] [--json]
  */
  */
 
 
-import { get } from 'node:http';
+/** biome-ignore-all lint/suspicious/noConsole: Allow printing to console */
 
 
+import { get } from 'node:http';
 import WebSocket from 'ws';
 import WebSocket from 'ws';
 
 
 interface MemoryInfo {
 interface MemoryInfo {
@@ -297,7 +298,9 @@ class NodeMemoryConsumptionChecker {
     // Memory Flags
     // Memory Flags
     if (info.memoryFlags.length > 0) {
     if (info.memoryFlags.length > 0) {
       console.log('\n🔸 Memory Flags:');
       console.log('\n🔸 Memory Flags:');
-      info.memoryFlags.forEach((flag) => console.log(`  ${flag}`));
+      info.memoryFlags.forEach((flag) => {
+        console.log(`  ${flag}`);
+      });
     }
     }
 
 
     // Summary
     // Summary

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

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

+ 1 - 2
apps/app/config/migrate-mongo-config.js

@@ -9,8 +9,7 @@ const isProduction = process.env.NODE_ENV === 'production';
 const { URL } = require('node:url');
 const { URL } = require('node:url');
 
 
 const { getMongoUri, mongoOptions } = isProduction
 const { getMongoUri, mongoOptions } = isProduction
-  ? // eslint-disable-next-line import/extensions, import/no-unresolved
-    require('../dist/server/util/mongoose-utils')
+  ? require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
   : require('../src/server/util/mongoose-utils');
 
 
 // get migrationsDir from env var
 // get migrationsDir from env var

+ 0 - 1
apps/app/config/next-i18next.config.js

@@ -22,7 +22,6 @@ module.exports = {
   localePath: path.resolve('./public/static/locales'),
   localePath: path.resolve('./public/static/locales'),
   serializeConfig: false,
   serializeConfig: false,
 
 
-  // eslint-disable-next-line no-nested-ternary
   use: isDev
   use: isDev
     ? isServer()
     ? isServer()
       ? [new HMRPlugin({ webpack: { server: true } })]
       ? [new HMRPlugin({ webpack: { server: true } })]

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

@@ -10,9 +10,9 @@ GROWI Official docker image
 Supported tags and respective Dockerfile links
 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.2`, `7.4`, `7`, `latest` (Dockerfile)](https://github.com/growilabs/growi/blob/v7.4.2/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?
 What is GROWI?

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

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

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

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

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

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

+ 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 - 4
apps/app/next.config.js

@@ -58,6 +58,7 @@ const getTranspilePackages = () => {
     'github-slugger',
     'github-slugger',
     'html-url-attributes',
     'html-url-attributes',
     'estree-util-is-identifier-name',
     'estree-util-is-identifier-name',
+    'superjson',
     ...listPrefixedPackages([
     ...listPrefixedPackages([
       'remark-',
       'remark-',
       'rehype-',
       'rehype-',
@@ -104,9 +105,6 @@ module.exports = async (phase) => {
     i18n,
     i18n,
 
 
     // for build
     // for build
-    eslint: {
-      ignoreDuringBuilds: true,
-    },
     typescript: {
     typescript: {
       tsconfigPath: 'tsconfig.build.client.json',
       tsconfigPath: 'tsconfig.build.client.json',
     },
     },
@@ -159,8 +157,10 @@ module.exports = async (phase) => {
   };
   };
 
 
   // production server
   // 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) {
   if (phase === PHASE_PRODUCTION_SERVER) {
-    return withSuperjson()(nextConfig);
+    return nextConfig;
   }
   }
 
 
   const withBundleAnalyzer = require('@next/bundle-analyzer')({
   const withBundleAnalyzer = require('@next/bundle-analyzer')({

+ 16 - 11
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.3.3-RC.0",
+  "version": "7.4.3-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -27,8 +27,7 @@
     "//// for CI": "",
     "//// for CI": "",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "launch-dev:ci": "cross-env NODE_ENV=development pnpm run dev:migrate && pnpm run ts-node src/server/app.ts --ci",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:typecheck": "vue-tsc --noEmit",
-    "lint:eslint": "eslint --quiet \"**/*.{js,mjs,jsx,ts,mts,tsx}\"",
-    "lint:biome": "biome check",
+    "lint:biome": "biome check --diagnostic-level=error",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:styles": "stylelint \"src/**/*.scss\"",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv3": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv3.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
     "lint:openapi:apiv1": "node node_modules/swagger2openapi/oas-validate tmp/openapi-spec-apiv1.json",
@@ -127,7 +126,6 @@
     "diff_match_patch": "^0.1.1",
     "diff_match_patch": "^0.1.1",
     "dotenv-flow": "^3.2.0",
     "dotenv-flow": "^3.2.0",
     "ejs": "^3.1.10",
     "ejs": "^3.1.10",
-    "esa-node": "^0.2.2",
     "escape-string-regexp": "^4.0.0",
     "escape-string-regexp": "^4.0.0",
     "expose-gc": "^1.0.0",
     "expose-gc": "^1.0.0",
     "express": "^4.20.0",
     "express": "^4.20.0",
@@ -147,8 +145,10 @@
     "i18next-resources-to-backend": "^1.2.1",
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
     "is-iso-date": "^0.0.1",
+    "jotai": "^2.12.3",
+    "js-cookie": "^3.0.5",
     "js-tiktoken": "^1.0.15",
     "js-tiktoken": "^1.0.15",
-    "js-yaml": "^4.1.0",
+    "js-yaml": "^4.1.1",
     "jsonrepair": "^3.12.0",
     "jsonrepair": "^3.12.0",
     "katex": "^0.16.21",
     "katex": "^0.16.21",
     "ldapjs": "^3.0.2",
     "ldapjs": "^3.0.2",
@@ -172,10 +172,10 @@
     "multer": "~1.4.0",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
     "mustache": "^4.2.0",
-    "next": "^14.2.32",
+    "next": "^14.2.35",
     "next-dynamic-loading-props": "^0.1.1",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^0.0.4",
+    "next-superjson": "^1.0.7",
     "next-themes": "^0.2.1",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
     "node-cron": "^3.0.2",
@@ -190,8 +190,9 @@
     "passport-ldapauth": "^3.0.1",
     "passport-ldapauth": "^3.0.1",
     "passport-local": "^1.0.0",
     "passport-local": "^1.0.0",
     "passport-saml": "^3.2.0",
     "passport-saml": "^3.2.0",
+    "pathe": "^2.0.3",
     "prop-types": "^15.8.1",
     "prop-types": "^15.8.1",
-    "qs": "^6.11.1",
+    "qs": "^6.14.1",
     "rate-limiter-flexible": "^2.3.7",
     "rate-limiter-flexible": "^2.3.7",
     "react": "^18.2.0",
     "react": "^18.2.0",
     "react-bootstrap-typeahead": "^6.3.2",
     "react-bootstrap-typeahead": "^6.3.2",
@@ -230,7 +231,7 @@
     "sanitize-filename": "^1.6.3",
     "sanitize-filename": "^1.6.3",
     "socket.io": "^4.7.5",
     "socket.io": "^4.7.5",
     "string-width": "=4.2.2",
     "string-width": "=4.2.2",
-    "superjson": "^1.9.1",
+    "superjson": "^2.2.2",
     "swagger-jsdoc": "^6.2.8",
     "swagger-jsdoc": "^6.2.8",
     "swr": "^2.3.2",
     "swr": "^2.3.2",
     "throttle-debounce": "^5.0.0",
     "throttle-debounce": "^5.0.0",
@@ -246,7 +247,7 @@
     "url-join": "^4.0.0",
     "url-join": "^4.0.0",
     "usehooks-ts": "^2.6.0",
     "usehooks-ts": "^2.6.0",
     "uuid": "^11.0.3",
     "uuid": "^11.0.3",
-    "validator": "^13.7.0",
+    "validator": "^13.15.22",
     "ws": "^8.17.1",
     "ws": "^8.17.1",
     "xss": "^1.0.15",
     "xss": "^1.0.15",
     "y-mongodb-provider": "^0.2.0",
     "y-mongodb-provider": "^0.2.0",
@@ -269,10 +270,13 @@
     "@growi/editor": "workspace:^",
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
     "@growi/ui": "workspace:^",
     "@handsontable/react": "=2.1.0",
     "@handsontable/react": "=2.1.0",
+    "@headless-tree/core": "^1.5.1",
+    "@headless-tree/react": "^1.5.1",
     "@next/bundle-analyzer": "^14.1.3",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
     "@swc/jest": "^0.2.36",
+    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/archiver": "^6.0.2",
@@ -280,6 +284,7 @@
     "@types/express": "^4.17.21",
     "@types/express": "^4.17.21",
     "@types/hast": "^3.0.4",
     "@types/hast": "^3.0.4",
     "@types/jest": "^29.5.2",
     "@types/jest": "^29.5.2",
+    "@types/js-cookie": "^3.0.6",
     "@types/ldapjs": "^2.2.5",
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
     "@types/mdast": "^4.0.4",
     "@types/node-cron": "^3.0.11",
     "@types/node-cron": "^3.0.11",
@@ -303,7 +308,6 @@
     "diff2html": "^3.4.47",
     "diff2html": "^3.4.47",
     "downshift": "^8.2.3",
     "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
     "eazy-logger": "^3.1.0",
-    "eslint-plugin-jest": "^26.5.3",
     "fastest-levenshtein": "^1.0.16",
     "fastest-levenshtein": "^1.0.16",
     "fslightbox-react": "^1.7.6",
     "fslightbox-react": "^1.7.6",
     "handsontable": "=6.2.2",
     "handsontable": "=6.2.2",
@@ -315,6 +319,7 @@
     "jest": "^29.5.0",
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
     "jest-localstorage-mock": "^2.4.14",
+    "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "material-icons": "^1.11.3",
     "mdast-util-directive": "^3.0.0",
     "mdast-util-directive": "^3.0.0",

+ 0 - 1
apps/app/playwright.config.ts

@@ -1,6 +1,5 @@
 import fs from 'node:fs';
 import fs from 'node:fs';
 import path from 'node:path';
 import path from 'node:path';
-
 import { defineConfig, devices, type Project } from '@playwright/test';
 import { defineConfig, devices, type Project } from '@playwright/test';
 
 
 const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');
 const authFile = path.resolve(__dirname, './playwright/.auth/admin.json');

+ 0 - 16
apps/app/playwright/.eslintrc.mjs

@@ -1,16 +0,0 @@
-import playwright from 'eslint-plugin-playwright';
-
-// eslint-disable-next-line import/no-anonymous-default-export
-export default [
-  {
-    ...playwright.configs['flat/recommended'],
-    files: ['./**'],
-  },
-  {
-    files: ['./**'],
-    rules: {
-      // Customize Playwright rules
-      // ...
-    },
-  },
-];

+ 13 - 5
apps/app/playwright/10-installer/install.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Installer', async({ page }) => {
+test('Installer', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   await page.waitForURL('/installer');
   await page.waitForURL('/installer');
 
 
@@ -11,18 +11,26 @@ test('Installer', async({ page }) => {
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
   await page.getByTestId('dropdownLanguageMenu-ja_JP').click();
   await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
   await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: 'ユーザーID' })).toHaveAttribute('placeholder', 'ユーザーID');
+  await expect(
+    page.getByRole('textbox', { name: 'ユーザーID' }),
+  ).toHaveAttribute('placeholder', 'ユーザーID');
 
 
   // choose Chinese
   // choose Chinese
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
   await page.getByTestId('dropdownLanguageMenu-zh_CN').click();
   await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
   await expect(page.getByRole('textbox', { name: '用户ID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute('placeholder', '用户ID');
+  await expect(page.getByRole('textbox', { name: '用户ID' })).toHaveAttribute(
+    'placeholder',
+    '用户ID',
+  );
   // // choose English
   // // choose English
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguage').click();
   await page.getByTestId('dropdownLanguageMenu-en_US').click();
   await page.getByTestId('dropdownLanguageMenu-en_US').click();
   await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
   await expect(page.getByRole('textbox', { name: 'User ID' })).toBeVisible();
-  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute('placeholder', 'User ID');
+  await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveAttribute(
+    'placeholder',
+    'User ID',
+  );
 
 
   await page.getByRole('textbox', { name: 'User ID' }).focus();
   await page.getByRole('textbox', { name: 'User ID' }).focus();
 
 

+ 101 - 73
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -1,32 +1,36 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
 
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 };
 
 
-test('has title', async({ page }) => {
+test('has title', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Expect a title "to contain" a substring.
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
   await expect(page).toHaveTitle(/Sandbox/);
 });
 });
 
 
-test('get h1', async({ page }) => {
+test('get h1', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Expects page to have a heading with the name of Installation.
   // Expects page to have a heading with the name of Installation.
-  await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
+  await expect(
+    page.getByRole('heading').filter({ hasText: /\/Sandbox/ }),
+  ).toBeVisible();
 });
 });
 
 
-test('/Sandbox/Math is successfully loaded', async({ page }) => {
+test('/Sandbox/Math is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox/Math');
   await page.goto('/Sandbox/Math');
 
 
   // Expect the Math-specific elements to be present
   // Expect the Math-specific elements to be present
   await expect(page.locator('.katex').first()).toBeVisible();
   await expect(page.locator('.katex').first()).toBeVisible();
 });
 });
 
 
-test('Sandbox with edit is successfully loaded', async({ page }) => {
+test('Sandbox with edit is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox#edit');
   await page.goto('/Sandbox#edit');
 
 
   // Expect the Editor-specific elements to be present
   // Expect the Editor-specific elements to be present
@@ -35,116 +39,140 @@ test('Sandbox with edit is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
   await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
 });
 });
 
 
-test.describe.serial('PageEditor', () => {
-  const body1 = 'hello';
-  const body2 = ' world!';
-  const targetPath = '/Sandbox/testForUseEditingMarkdown';
+test.describe
+  .serial('PageEditor', () => {
+    const body1 = 'hello';
+    const body2 = ' world!';
+    const targetPath = '/Sandbox/testForUseEditingMarkdown';
 
 
-  test('Edit and save with save-page-btn', async({ page }) => {
-    await page.goto(targetPath);
+    test('Edit and save with save-page-btn', async ({ page }) => {
+      await page.goto(targetPath);
 
 
-    await page.getByTestId('editor-button').click();
-    await appendTextToEditorUntilContains(page, body1);
-    await page.getByTestId('save-page-btn').click();
+      await page.getByTestId('editor-button').click();
+      await appendTextToEditorUntilContains(page, body1);
+      await page.getByTestId('save-page-btn').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(body1);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(body1);
+    });
 
 
-  test('Edit and save with shortcut key', async({ page }) => {
-    const savePageShortcutKey = 'Control+s';
+    test('Edit and save with shortcut key', async ({ page }) => {
+      const savePageShortcutKey = 'Control+s';
 
 
-    await page.goto(targetPath);
+      await page.goto(targetPath);
 
 
-    await page.getByTestId('editor-button').click();
+      await page.getByTestId('editor-button').click();
 
 
-    await expect(page.locator('.cm-content')).toContainText(body1);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(body1);
+      await expect(page.locator('.cm-content')).toContainText(body1);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        body1,
+      );
 
 
-    await appendTextToEditorUntilContains(page, body1 + body2);
-    await page.keyboard.press(savePageShortcutKey);
-    await page.getByTestId('view-button').click();
+      await appendTextToEditorUntilContains(page, body1 + body2);
+      await page.keyboard.press(savePageShortcutKey);
+      await page.getByTestId('view-button').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+      await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+    });
   });
   });
-});
 
 
-test('Access to /me page', async({ page }) => {
+test('Access to /me page', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 });
 
 
-test('All In-App Notification list is successfully loaded', async({ page }) => {
+test('All In-App Notification list is successfully loaded', async ({
+  page,
+}) => {
   await page.goto('/me/all-in-app-notifications');
   await page.goto('/me/all-in-app-notifications');
 
 
   // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
   // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
   await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
   await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
 });
 });
 
 
-test('/trash is successfully loaded', async({ page }) => {
+test('/trash is successfully loaded', async ({ page }) => {
   await page.goto('/trash');
   await page.goto('/trash');
 
 
-  await expect(page.getByTestId('trash-page-list')).toContainText('There are no pages under this page.');
+  await expect(page.getByTestId('trash-page-list')).toContainText(
+    'There are no pages under this page.',
+  );
 });
 });
 
 
-test('/tags is successfully loaded', async({ page }) => {
+test('/tags is successfully loaded', async ({ page }) => {
   await page.goto('/tags');
   await page.goto('/tags');
 
 
-  await expect(page.getByTestId('grw-tags-list')).toContainText('You have no tag, You can set tags on pages');
+  await expect(page.getByTestId('grw-tags-list')).toContainText(
+    'You have no tag, You can set tags on pages',
+  );
 });
 });
 
 
-test.describe.serial('Access to Template Editing Mode', () => {
-  const templateBody1 = 'Template for children';
-  const templateBody2 = 'Template for descendants';
+test.describe
+  .serial('Access to Template Editing Mode', () => {
+    const templateBody1 = 'Template for children';
+    const templateBody2 = 'Template for descendants';
 
 
-  test('Successfully created template for children', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Successfully created template for children', async ({ page }) => {
+      await page.goto('/Sandbox');
 
 
-    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-template-modal-btn').click();
-    expect(page.getByTestId('page-template-modal')).toBeVisible();
+      await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
+      await page.getByTestId('open-page-template-modal-btn').click();
+      expect(page.getByTestId('page-template-modal')).toBeVisible();
 
 
-    await page.getByTestId('template-button-children').click();
+      await page.getByTestId('template-button-children').click();
 
 
-    await appendTextToEditorUntilContains(page, templateBody1);
-    await page.getByTestId('save-page-btn').click();
+      await appendTextToEditorUntilContains(page, templateBody1);
+      await page.getByTestId('save-page-btn').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(templateBody1);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(templateBody1);
+    });
 
 
-  test('Template is applied to pages created (template for children)', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Template is applied to pages created (template for children)', async ({
+      page,
+    }) => {
+      await page.goto('/Sandbox');
 
 
-    await page.getByTestId('grw-page-create-button').click();
+      await page.getByTestId('grw-page-create-button').click();
 
 
-    await expect(page.locator('.cm-content')).toContainText(templateBody1);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody1);
-  });
+      await expect(page.locator('.cm-content')).toContainText(templateBody1);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        templateBody1,
+      );
+    });
 
 
-  test('Successfully created template for descendants', async({ page }) => {
-    await page.goto('/Sandbox');
+    test('Successfully created template for descendants', async ({ page }) => {
+      await page.goto('/Sandbox');
 
 
-    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
-    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-template-modal-btn').click();
-    expect(page.getByTestId('page-template-modal')).toBeVisible();
+      await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+      await page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('open-page-item-control-btn')
+        .click();
+      await page.getByTestId('open-page-template-modal-btn').click();
+      expect(page.getByTestId('page-template-modal')).toBeVisible();
 
 
-    await page.getByTestId('template-button-descendants').click();
+      await page.getByTestId('template-button-descendants').click();
 
 
-    await appendTextToEditorUntilContains(page, templateBody2);
-    await page.getByTestId('save-page-btn').click();
+      await appendTextToEditorUntilContains(page, templateBody2);
+      await page.getByTestId('save-page-btn').click();
 
 
-    await expect(page.locator('.wiki').first()).toContainText(templateBody2);
-  });
+      await expect(page.locator('.wiki').first()).toContainText(templateBody2);
+    });
 
 
-  test('Template is applied to pages created (template for descendants)', async({ page }) => {
-    await page.goto('/Sandbox/Bootstrap5');
+    test('Template is applied to pages created (template for descendants)', async ({
+      page,
+    }) => {
+      await page.goto('/Sandbox/Bootstrap5');
 
 
-    await page.getByTestId('grw-page-create-button').click();
+      await page.getByTestId('grw-page-create-button').click();
 
 
-    await expect(page.locator('.cm-content')).toContainText(templateBody2);
-    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody2);
+      await expect(page.locator('.cm-content')).toContainText(templateBody2);
+      await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+        templateBody2,
+      );
+    });
   });
   });
-});

+ 17 - 9
apps/app/playwright/20-basic-features/access-to-pagelist.spec.ts

@@ -1,29 +1,37 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
 
-const openPageAccessoriesModal = async(page: Page): Promise<void> => {
+const openPageAccessoriesModal = async (page: Page): Promise<void> => {
   await page.goto('/');
   await page.goto('/');
   await page.getByTestId('pageListButton').click();
   await page.getByTestId('pageListButton').click();
   await expect(page.getByTestId('descendants-page-list-modal')).toBeVisible();
   await expect(page.getByTestId('descendants-page-list-modal')).toBeVisible();
 };
 };
 
 
-test('Page list modal is successfully opened', async({ page }) => {
+test('Page list modal is successfully opened', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
-  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText('You cannot see this page');
+  await expect(page.getByTestId('page-list-item-L').first()).not.toContainText(
+    'You cannot see this page',
+  );
 });
 });
 
 
-test('Successfully open PageItemControl', async({ page }) => {
+test('Successfully open PageItemControl', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
-  await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+  await page
+    .getByTestId('page-list-item-L')
+    .first()
+    .getByTestId('open-page-item-control-btn')
+    .click();
   await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   await expect(page.locator('.dropdown-menu.show')).toBeVisible();
 });
 });
 
 
-test('Successfully close modal', async({ page }) => {
+test('Successfully close modal', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
   await page.locator('.btn-close').click();
   await page.locator('.btn-close').click();
-  await expect(page.getByTestId('descendants-page-list-modal')).not.toBeVisible();
+  await expect(
+    page.getByTestId('descendants-page-list-modal'),
+  ).not.toBeVisible();
 });
 });
 
 
-test('Timeline list successfully openend', async({ page }) => {
+test('Timeline list successfully openend', async ({ page }) => {
   await openPageAccessoriesModal(page);
   await openPageAccessoriesModal(page);
   await page.getByTestId('timeline-tab-button').click();
   await page.getByTestId('timeline-tab-button').click();
   await expect(page.locator('.card-timeline').first()).toBeVisible();
   await expect(page.locator('.card-timeline').first()).toBeVisible();

+ 10 - 7
apps/app/playwright/20-basic-features/click-page-icons.spec.ts

@@ -1,11 +1,11 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Click page icons', () => {
 test.describe('Click page icons', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/Sandbox');
     await page.goto('/Sandbox');
   });
   });
 
 
-  test('Successfully Subscribe/Unsubscribe a page', async({ page }) => {
+  test('Successfully Subscribe/Unsubscribe a page', async ({ page }) => {
     const subscribeButton = page.locator('.btn-subscribe');
     const subscribeButton = page.locator('.btn-subscribe');
 
 
     // Subscribe
     // Subscribe
@@ -17,7 +17,7 @@ test.describe('Click page icons', () => {
     await expect(subscribeButton).not.toHaveClass(/active/);
     await expect(subscribeButton).not.toHaveClass(/active/);
   });
   });
 
 
-  test('Successfully Like/Unlike a page', async({ page }) => {
+  test('Successfully Like/Unlike a page', async ({ page }) => {
     const likeButton = page.locator('.btn-like').first();
     const likeButton = page.locator('.btn-like').first();
 
 
     // Like
     // Like
@@ -29,7 +29,7 @@ test.describe('Click page icons', () => {
     await expect(likeButton).not.toHaveClass(/active/);
     await expect(likeButton).not.toHaveClass(/active/);
   });
   });
 
 
-  test('Successfully Bookmark / Unbookmark a page', async({ page }) => {
+  test('Successfully Bookmark / Unbookmark a page', async ({ page }) => {
     const bookmarkButton = page.locator('.btn-bookmark').first();
     const bookmarkButton = page.locator('.btn-bookmark').first();
 
 
     // Bookmark
     // Bookmark
@@ -41,10 +41,13 @@ test.describe('Click page icons', () => {
     await expect(bookmarkButton).not.toHaveClass(/active/);
     await expect(bookmarkButton).not.toHaveClass(/active/);
   });
   });
 
 
-  test('Successfully display list of "seen by user"', async({ page }) => {
+  test('Successfully display list of "seen by user"', async ({ page }) => {
     await page.locator('.btn-seen-user').click();
     await page.locator('.btn-seen-user').click();
 
 
-    const imgCount = await page.locator('.user-list-content').locator('img').count();
+    const imgCount = await page
+      .locator('.user-list-content')
+      .locator('img')
+      .count();
     expect(imgCount).toBe(1);
     expect(imgCount).toBe(1);
   });
   });
 });
 });

+ 13 - 9
apps/app/playwright/20-basic-features/comments.spec.ts

@@ -1,18 +1,17 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Comment', () => {
 test.describe('Comment', () => {
-
   // make tests run in serial
   // make tests run in serial
   test.describe.configure({ mode: 'serial' });
   test.describe.configure({ mode: 'serial' });
 
 
-  test('Create comment page', async({ page }) => {
+  test('Create comment page', async ({ page }) => {
     await page.goto('/comment');
     await page.goto('/comment');
     await page.getByTestId('editor-button').click();
     await page.getByTestId('editor-button').click();
     await page.getByTestId('save-page-btn').click();
     await page.getByTestId('save-page-btn').click();
     await expect(page.locator('.page-meta')).toBeVisible();
     await expect(page.locator('.page-meta')).toBeVisible();
   });
   });
 
 
-  test('Successfully add comments', async({ page }) => {
+  test('Successfully add comments', async ({ page }) => {
     const commentText = 'add comment';
     const commentText = 'add comment';
     await page.goto('/comment');
     await page.goto('/comment');
 
 
@@ -23,10 +22,12 @@ test.describe('Comment', () => {
     await page.getByTestId('comment-submit-button').first().click();
     await page.getByTestId('comment-submit-button').first().click();
 
 
     await expect(page.locator('.page-comment-body')).toHaveText(commentText);
     await expect(page.locator('.page-comment-body')).toHaveText(commentText);
-    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('1');
+    await expect(
+      page.getByTestId('page-comment-button').locator('.grw-count-badge'),
+    ).toHaveText('1');
   });
   });
 
 
-  test('Successfully reply comments', async({ page }) => {
+  test('Successfully reply comments', async ({ page }) => {
     const commentText = 'reply comment';
     const commentText = 'reply comment';
     await page.goto('/comment');
     await page.goto('/comment');
 
 
@@ -35,8 +36,12 @@ test.describe('Comment', () => {
     await page.locator('.cm-content').fill(commentText);
     await page.locator('.cm-content').fill(commentText);
     await page.getByTestId('comment-submit-button').first().click();
     await page.getByTestId('comment-submit-button').first().click();
 
 
-    await expect(page.locator('.page-comment-body').nth(1)).toHaveText(commentText);
-    await expect(page.getByTestId('page-comment-button').locator('.grw-count-badge')).toHaveText('2');
+    await expect(page.locator('.page-comment-body').nth(1)).toHaveText(
+      commentText,
+    );
+    await expect(
+      page.getByTestId('page-comment-button').locator('.grw-count-badge'),
+    ).toHaveText('2');
   });
   });
 
 
   // test('Successfully delete comments', async({ page }) => {
   // test('Successfully delete comments', async({ page }) => {
@@ -51,5 +56,4 @@ test.describe('Comment', () => {
   // });
   // });
 
 
   // TODO: https://redmine.weseek.co.jp/issues/139520
   // TODO: https://redmine.weseek.co.jp/issues/139520
-
 });
 });

+ 16 - 6
apps/app/playwright/20-basic-features/create-page-button.spec.ts

@@ -1,10 +1,13 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Create page button', () => {
 test.describe('Create page button', () => {
-  test('click and autofocus to title text input', async({ page }) => {
+  test('click and autofocus to title text input', async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
 
 
-    await page.getByTestId('grw-page-create-button').getByRole('button', { name: 'Create' }).click();
+    await page
+      .getByTestId('grw-page-create-button')
+      .getByRole('button', { name: 'Create' })
+      .click();
 
 
     // should be focused
     // should be focused
     await expect(page.getByPlaceholder('Input page name')).toBeFocused();
     await expect(page.getByPlaceholder('Input page name')).toBeFocused();
@@ -12,13 +15,20 @@ test.describe('Create page button', () => {
 });
 });
 
 
 test.describe('Create page button dropdown menu', () => {
 test.describe('Create page button dropdown menu', () => {
-  test('open and create today page', async({ page }) => {
+  test('open and create today page', async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
 
 
     // open dropdown menu
     // open dropdown menu
     await page.getByTestId('grw-page-create-button').hover();
     await page.getByTestId('grw-page-create-button').hover();
-    await expect(page.getByTestId('grw-page-create-button').getByLabel('Open create page menu')).toBeVisible();
-    await page.getByTestId('grw-page-create-button').getByLabel('Open create page menu').dispatchEvent('click'); // simulate the click
+    await expect(
+      page
+        .getByTestId('grw-page-create-button')
+        .getByLabel('Open create page menu'),
+    ).toBeVisible();
+    await page
+      .getByTestId('grw-page-create-button')
+      .getByLabel('Open create page menu')
+      .dispatchEvent('click'); // simulate the click
     await page.getByRole('menuitem', { name: 'Create today page' }).click();
     await page.getByRole('menuitem', { name: 'Create today page' }).click();
 
 
     // should not be visible
     // should not be visible

+ 9 - 5
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -1,13 +1,17 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Presentation', async({ page }) => {
+test('Presentation', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
 
 
   // show presentation modal
   // show presentation modal
-  await page.getByTestId('grw-contextual-sub-nav').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-presentation-modal-btn').click();
   await page.getByTestId('open-presentation-modal-btn').click();
 
 
   // check the content of the h1
   // check the content of the h1
-  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
-    .toHaveText(/Welcome to GROWI/);
+  await expect(
+    page.getByRole('application').getByRole('heading', { level: 1 }),
+  ).toHaveText(/Welcome to GROWI/);
 });
 });

+ 35 - 13
apps/app/playwright/20-basic-features/sticky-features.spec.ts

@@ -1,47 +1,69 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 test.describe('Sticky features', () => {
 test.describe('Sticky features', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
   });
   });
 
 
-  test('Subnavigation displays changes on scroll down and up', async({ page }) => {
+  test('Subnavigation displays changes on scroll down and up', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Scroll back to top
     // Scroll back to top
     await page.evaluate(() => window.scrollTo(0, 0));
     await page.evaluate(() => window.scrollTo(0, 0));
-    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+      /active/,
+    );
   });
   });
 
 
-  test('Subnavigation is not displayed when move to other pages', async({ page }) => {
+  test('Subnavigation is not displayed when move to other pages', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Move to /Sandbox page
     // Move to /Sandbox page
     await page.goto('/Sandbox');
     await page.goto('/Sandbox');
-    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).not.toHaveClass(
+      /active/,
+    );
   });
   });
 
 
-  test('Able to click buttons on subnavigation switcher when sticky', async({ page }) => {
+  test('Able to click buttons on subnavigation switcher when sticky', async ({
+    page,
+  }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 250));
     await page.evaluate(() => window.scrollTo(0, 250));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Click editor button
     // Click editor button
     await page.getByTestId('editor-button').click();
     await page.getByTestId('editor-button').click();
     await expect(page.locator('.layout-root')).toHaveClass(/editing/);
     await expect(page.locator('.layout-root')).toHaveClass(/editing/);
   });
   });
 
 
-  test('Subnavigation is sticky when on small window', async({ page }) => {
+  test('Subnavigation is sticky when on small window', async ({ page }) => {
     // Scroll down to trigger sticky effect
     // Scroll down to trigger sticky effect
     await page.evaluate(() => window.scrollTo(0, 500));
     await page.evaluate(() => window.scrollTo(0, 500));
-    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(/active/);
+    await expect(page.locator('.sticky-outer-wrapper').first()).toHaveClass(
+      /active/,
+    );
 
 
     // Set viewport to small size
     // Set viewport to small size
     await page.setViewportSize({ width: 600, height: 1024 });
     await page.setViewportSize({ width: 600, height: 1024 });
-    await expect(page.getByTestId('grw-contextual-sub-nav').getByTestId('grw-page-editor-mode-manager')).toBeVisible();
+    await expect(
+      page
+        .getByTestId('grw-contextual-sub-nav')
+        .getByTestId('grw-page-editor-mode-manager'),
+    ).toBeVisible();
   });
   });
 });
 });

+ 25 - 15
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect, type Page } from '@playwright/test';
+import { expect, type Page, test } from '@playwright/test';
 
 
-const openPageItemControl = async(page: Page): Promise<void> => {
+const openPageItemControl = async (page: Page): Promise<void> => {
   const nav = page.getByTestId('grw-contextual-sub-nav');
   const nav = page.getByTestId('grw-contextual-sub-nav');
   const button = nav.getByTestId('open-page-item-control-btn');
   const button = nav.getByTestId('open-page-item-control-btn');
 
 
@@ -19,7 +19,7 @@ const openPageItemControl = async(page: Page): Promise<void> => {
   await button.click();
   await button.click();
 };
 };
 
 
-test('PageDeleteModal is shown successfully', async({ page }) => {
+test('PageDeleteModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   await openPageItemControl(page);
   await openPageItemControl(page);
@@ -28,7 +28,7 @@ test('PageDeleteModal is shown successfully', async({ page }) => {
   await expect(page.getByTestId('page-delete-modal')).toBeVisible();
   await expect(page.getByTestId('page-delete-modal')).toBeVisible();
 });
 });
 
 
-test('PageDuplicateModal is shown successfully', async({ page }) => {
+test('PageDuplicateModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   await openPageItemControl(page);
   await openPageItemControl(page);
@@ -37,7 +37,7 @@ test('PageDuplicateModal is shown successfully', async({ page }) => {
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
 });
 });
 
 
-test('PageMoveRenameModal is shown successfully', async({ page }) => {
+test('PageMoveRenameModal is shown successfully', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   await openPageItemControl(page);
   await openPageItemControl(page);
@@ -57,35 +57,45 @@ test('PageMoveRenameModal is shown successfully', async({ page }) => {
 // });
 // });
 
 
 test.describe('Page Accessories Modal', () => {
 test.describe('Page Accessories Modal', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
     await openPageItemControl(page);
     await openPageItemControl(page);
   });
   });
 
 
-  test('Page History is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-history-tab').click();
-    await expect(page.getByTestId(('page-history'))).toBeVisible();
+  test('Page History is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId('open-page-accessories-modal-btn-with-history-tab')
+      .click();
+    await expect(page.getByTestId('page-history')).toBeVisible();
   });
   });
 
 
-  test('Page Attachment Data is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab').click();
+  test('Page Attachment Data is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab')
+      .click();
     await expect(page.getByTestId('page-attachment')).toBeVisible();
     await expect(page.getByTestId('page-attachment')).toBeVisible();
   });
   });
 
 
-  test('Share Link Management is shown successfully', async({ page }) => {
-    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
+  test('Share Link Management is shown successfully', async ({ page }) => {
+    await page
+      .getByTestId(
+        'open-page-accessories-modal-btn-with-share-link-management-data-tab',
+      )
+      .click();
     await expect(page.getByTestId('share-link-management')).toBeVisible();
     await expect(page.getByTestId('share-link-management')).toBeVisible();
   });
   });
 });
 });
 
 
-test('Successfully add new tag', async({ page }) => {
+test('Successfully add new tag', async ({ page }) => {
   const tag = 'we';
   const tag = 'we';
   await page.goto('/Sandbox/Bootstrap5');
   await page.goto('/Sandbox/Bootstrap5');
 
 
   await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
   await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
   await expect(page.locator('#edit-tag-modal')).toBeVisible();
   await expect(page.locator('#edit-tag-modal')).toBeVisible();
   await page.locator('.rbt-input-main').fill(tag);
   await page.locator('.rbt-input-main').fill(tag);
-  await expect(page.locator('#tag-typeahead-asynctypeahead-item-0')).toBeVisible();
+  await expect(
+    page.locator('#tag-typeahead-asynctypeahead-item-0'),
+  ).toBeVisible();
   await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
   await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
   await page.getByTestId('tag-edit-done-btn').click();
   await page.getByTestId('tag-edit-done-btn').click();
   await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);
   await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);

+ 9 - 9
apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts

@@ -1,45 +1,45 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 import { collapseSidebar } from '../utils';
 import { collapseSidebar } from '../utils';
 
 
-test('/Sandbox is successfully loaded', async({ page }) => {
-
+test('/Sandbox is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
   // Expect a title "to contain" a substring.
   // Expect a title "to contain" a substring.
   await expect(page).toHaveTitle(/Sandbox/);
   await expect(page).toHaveTitle(/Sandbox/);
 });
 });
 
 
-test('/Sandbox/math is successfully loaded', async({ page }) => {
-
+test('/Sandbox/math is successfully loaded', async ({ page }) => {
   await page.goto('/Sandbox/Math');
   await page.goto('/Sandbox/Math');
 
 
   // Check if the math elements are visible
   // Check if the math elements are visible
   await expect(page.locator('.katex').first()).toBeVisible();
   await expect(page.locator('.katex').first()).toBeVisible();
 });
 });
 
 
-test('Access to /me page', async({ page }) => {
+test('Access to /me page', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Expect to be redirected to /login when accessing /me
   // Expect to be redirected to /login when accessing /me
   await expect(page.getByTestId('login-form')).toBeVisible();
   await expect(page.getByTestId('login-form')).toBeVisible();
 });
 });
 
 
-test('Access to /trash page', async({ page }) => {
+test('Access to /trash page', async ({ page }) => {
   await page.goto('/trash');
   await page.goto('/trash');
 
 
   // Expect the trash page specific elements to be present when accessing /trash
   // Expect the trash page specific elements to be present when accessing /trash
   await expect(page.getByTestId('trash-page-list')).toBeVisible();
   await expect(page.getByTestId('trash-page-list')).toBeVisible();
 });
 });
 
 
-test('Access to /tags page', async({ page }) => {
+test('Access to /tags page', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
 
 
   await collapseSidebar(page, false);
   await collapseSidebar(page, false);
   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
   await page.getByTestId('grw-sidebar-nav-primary-tags').click();
   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
   await expect(page.getByTestId('grw-sidebar-content-tags')).toBeVisible();
   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
   await expect(page.getByTestId('grw-tags-list').first()).toBeVisible();
-  await expect(page.getByTestId('grw-tags-list').first()).toContainText('You have no tag, You can set tags on pages');
+  await expect(page.getByTestId('grw-tags-list').first()).toContainText(
+    'You have no tag, You can set tags on pages',
+  );
 
 
   await page.getByTestId('check-all-tags-button').click();
   await page.getByTestId('check-all-tags-button').click();
   await expect(page.getByTestId('tags-page')).toBeVisible();
   await expect(page.getByTestId('tags-page')).toBeVisible();

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

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

+ 41 - 33
apps/app/playwright/22-sharelink/access-to-sharelink.spec.ts

@@ -1,37 +1,45 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 import { login } from '../utils/Login';
 import { login } from '../utils/Login';
 
 
-test.describe.serial('Access to sharelink by guest', () => {
-  let createdSharelink: string | null;
-
-  test('Prepare sharelink', async({ page }) => {
-    await page.goto('/Sandbox/Bootstrap5');
-
-    // Create Sharelink
-    await page.getByTestId('open-page-item-control-btn').click();
-    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
-    await page.getByTestId('btn-sharelink-toggleform').click();
-    await page.getByTestId('btn-sharelink-issue').click();
-
-    // Get ShareLink
-    createdSharelink = await page.getByTestId('share-link').textContent();
-    expect(createdSharelink).toHaveLength(24);
-  });
-
-  test('The sharelink page is successfully loaded', async({ page }) => {
-    await page.goto('/');
-
-    // Logout
-    await page.getByTestId('personal-dropdown-button').click();
-    await expect(page.getByTestId('logout-button')).toBeVisible();
-    await page.getByTestId('logout-button').click();
-    await page.waitForURL('http://localhost:3000/login');
-
-    // Access sharelink
-    await page.goto(`/share/${createdSharelink}`);
-    await expect(page.locator('.page-meta')).toBeVisible();
-
-    await login(page);
+test.describe
+  .serial('Access to sharelink by guest', () => {
+    let createdSharelink: string | null;
+
+    test('Prepare sharelink', async ({ page }) => {
+      await page.goto('/Sandbox/Bootstrap5');
+
+      // Create Sharelink
+      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',
+        )
+        .click();
+      await page.getByTestId('btn-sharelink-toggleform').click();
+      await page.getByTestId('btn-sharelink-issue').click();
+
+      // Get ShareLink
+      createdSharelink = await page.getByTestId('share-link').textContent();
+      expect(createdSharelink).toHaveLength(24);
+    });
+
+    test('The sharelink page is successfully loaded', async ({ page }) => {
+      await page.goto('/');
+
+      // Logout
+      await page.getByTestId('personal-dropdown-button').click();
+      await expect(page.getByTestId('logout-button')).toBeVisible();
+      await page.getByTestId('logout-button').click();
+      await page.waitForURL('http://localhost:3000/login');
+
+      // Access sharelink
+      await page.goto(`/share/${createdSharelink}`);
+      await expect(page.locator('.page-meta')).toBeVisible();
+
+      await login(page);
+    });
   });
   });
-});

+ 19 - 11
apps/app/playwright/23-editor/saving.spec.ts

@@ -1,22 +1,29 @@
+import { expect, type Page, test } from '@playwright/test';
 import path from 'path';
 import path from 'path';
 
 
-import { test, expect, type Page } from '@playwright/test';
-
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 };
 
 
-
-test('Successfully create page under specific path', async({ page }) => {
+test('Successfully create page under specific path', async ({ page }) => {
   const newPagePath = '/child';
   const newPagePath = '/child';
   const openPageCreateModalShortcutKey = 'c';
   const openPageCreateModalShortcutKey = 'c';
 
 
   await page.goto('/Sandbox');
   await page.goto('/Sandbox');
 
 
-  await page.keyboard.press(openPageCreateModalShortcutKey);
-  await expect(page.getByTestId('page-create-modal')).toBeVisible();
-  page.getByTestId('page-create-modal').locator('.rbt-input-main').fill(newPagePath);
+  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')
+    .fill(newPagePath);
   page.getByTestId('btn-create-page-under-below').click();
   page.getByTestId('btn-create-page-under-below').click();
   await page.getByTestId('view-button').click();
   await page.getByTestId('view-button').click();
 
 
@@ -24,8 +31,9 @@ test('Successfully create page under specific path', async({ page }) => {
   expect(createdPageId.length).toBe(24);
   expect(createdPageId.length).toBe(24);
 });
 });
 
 
-
-test('Successfully updating a page using a shortcut on a previously created page', async({ page }) => {
+test('Successfully updating a page using a shortcut on a previously created page', async ({
+  page,
+}) => {
   const body1 = 'hello';
   const body1 = 'hello';
   const body2 = ' world!';
   const body2 = ' world!';
   const savePageShortcutKey = 'Control+s';
   const savePageShortcutKey = 'Control+s';

+ 8 - 4
apps/app/playwright/23-editor/template-modal.spec.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Successfully select template and template locale', async({ page }) => {
+test('Successfully select template and template locale', async ({ page }) => {
   const jaText = '今日の目標';
   const jaText = '今日の目標';
   const enText = "TODAY'S GOALS";
   const enText = "TODAY'S GOALS";
   await page.goto('/Sandbox/TemplateModal');
   await page.goto('/Sandbox/TemplateModal');
@@ -16,10 +16,14 @@ test('Successfully select template and template locale', async({ page }) => {
 
 
   // select template and template locale
   // select template and template locale
   await templateModal.locator('.list-group-item').nth(0).click();
   await templateModal.locator('.list-group-item').nth(0).click();
-  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(enText);
+  await expect(
+    templateModal.locator('.card-body').locator('.has-data-line').nth(1),
+  ).toHaveText(enText);
   await templateModal.getByTestId('select-locale-dropdown-toggle').click();
   await templateModal.getByTestId('select-locale-dropdown-toggle').click();
   await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
   await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
-  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(jaText);
+  await expect(
+    templateModal.locator('.card-body').locator('.has-data-line').nth(1),
+  ).toHaveText(jaText);
 
 
   // insert
   // insert
   await templateModal.locator('.btn-primary').click();
   await templateModal.locator('.btn-primary').click();

+ 31 - 17
apps/app/playwright/23-editor/with-navigation.spec.ts

@@ -1,14 +1,15 @@
+import { expect, type Page, test } from '@playwright/test';
 import { readFileSync } from 'fs';
 import { readFileSync } from 'fs';
 import path from 'path';
 import path from 'path';
 
 
-import { test, expect, type Page } from '@playwright/test';
-
 /**
 /**
  * for the issues:
  * for the issues:
  * @see https://redmine.weseek.co.jp/issues/122040
  * @see https://redmine.weseek.co.jp/issues/122040
  * @see https://redmine.weseek.co.jp/issues/124281
  * @see https://redmine.weseek.co.jp/issues/124281
  */
  */
-test('should not be cleared and should prevent GrantSelector from modified', async({ page }) => {
+test('should not be cleared and should prevent GrantSelector from modified', async ({
+  page,
+}) => {
   await page.goto('/Sandbox/for-122040');
   await page.goto('/Sandbox/for-122040');
 
 
   // Open Editor
   // Open Editor
@@ -26,10 +27,10 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
   const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
   const filePath = path.resolve(__dirname, '../23-editor/assets/example.txt');
   const buffer = readFileSync(filePath).toString('base64');
   const buffer = readFileSync(filePath).toString('base64');
   const dataTransfer = await page.evaluateHandle(
   const dataTransfer = await page.evaluateHandle(
-    async({ bufferData, localFileName, localFileType }) => {
+    async ({ bufferData, localFileName, localFileType }) => {
       const dt = new DataTransfer();
       const dt = new DataTransfer();
 
 
-      const blobData = await fetch(bufferData).then(res => res.blob());
+      const blobData = await fetch(bufferData).then((res) => res.blob());
 
 
       const file = new File([blobData], localFileName, {
       const file = new File([blobData], localFileName, {
         type: localFileType,
         type: localFileType,
@@ -43,33 +44,41 @@ test('should not be cleared and should prevent GrantSelector from modified', asy
       localFileType: 'application/octet-stream',
       localFileType: 'application/octet-stream',
     },
     },
   );
   );
-  await page.locator('.dropzone').first().dispatchEvent('drop', { dataTransfer });
-  await expect(page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment')).toBeVisible();
+  await page
+    .locator('.dropzone')
+    .first()
+    .dispatchEvent('drop', { dataTransfer });
+  await expect(
+    page.getByTestId('page-editor-preview-body').getByTestId('rich-attachment'),
+  ).toBeVisible();
 
 
   // Save page
   // Save page
   await page.getByTestId('save-page-btn').click();
   await page.getByTestId('save-page-btn').click();
 
 
   // Expect grant not to be reset after uploading an attachment
   // Expect grant not to be reset after uploading an attachment
-  await expect(page.getByTestId('page-grant-alert')).toContainText('Browsing of this page is restricted');
+  await expect(page.getByTestId('page-grant-alert')).toContainText(
+    'Browsing of this page is restricted',
+  );
 });
 });
 
 
-const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+const appendTextToEditorUntilContains = async (page: Page, text: string) => {
   await page.locator('.cm-content').fill(text);
   await page.locator('.cm-content').fill(text);
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    text,
+  );
 };
 };
 
 
 /**
 /**
  * for the issue:
  * for the issue:
  * @see https://redmine.weseek.co.jp/issues/115285
  * @see https://redmine.weseek.co.jp/issues/115285
  */
  */
-test('Successfully updating the page body', async({ page }) => {
+test('Successfully updating the page body', async ({ page }) => {
   const page1Path = '/Sandbox/for-115285/page1';
   const page1Path = '/Sandbox/for-115285/page1';
   const page2Path = '/Sandbox/for-115285/page2';
   const page2Path = '/Sandbox/for-115285/page2';
 
 
   const page1Body = 'Hello';
   const page1Body = 'Hello';
   const page2Body = 'World';
   const page2Body = 'World';
 
 
-
   await page.goto(page1Path);
   await page.goto(page1Path);
 
 
   // Open Editor (page1)
   // Open Editor (page1)
@@ -85,7 +94,10 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.locator('.main')).toContainText(page1Body);
   await expect(page.locator('.main')).toContainText(page1Body);
 
 
   // Duplicate page1
   // Duplicate page1
-  await page.getByTestId('grw-contextual-sub-nav').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-duplicate-modal-btn').click();
   await page.getByTestId('open-page-duplicate-modal-btn').click();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   await page.locator('.form-control').fill(page2Path);
   await page.locator('.form-control').fill(page2Path);
@@ -96,18 +108,20 @@ test('Successfully updating the page body', async({ page }) => {
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
 
 
   // Expect to see the text from which you are duplicating
   // Expect to see the text from which you are duplicating
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    page1Body,
+  );
 
 
   // Append text
   // Append text
   await appendTextToEditorUntilContains(page, page1Body + page2Body);
   await appendTextToEditorUntilContains(page, page1Body + page2Body);
 
 
-
   await page.goto(page1Path);
   await page.goto(page1Path);
 
 
   // Open Editor (page1)
   // Open Editor (page1)
   await page.getByTestId('editor-button').click();
   await page.getByTestId('editor-button').click();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
   await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
 
 
-  await expect(page.getByTestId('page-editor-preview-body')).toContainText(page1Body);
-
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(
+    page1Body,
+  );
 });
 });

+ 82 - 68
apps/app/playwright/30-search/search.spect.ts

@@ -1,6 +1,6 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('Search page with "q" param is successfully loaded', async({ page }) => {
+test('Search page with "q" param is successfully loaded', async ({ page }) => {
   // Navigate to the search page with query parameters
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
   await page.goto('/_search?q=alerts');
 
 
@@ -11,7 +11,7 @@ test('Search page with "q" param is successfully loaded', async({ page }) => {
   await expect(page.locator('.wiki')).toBeVisible();
   await expect(page.locator('.wiki')).toBeVisible();
 });
 });
 
 
-test('checkboxes behaviors', async({ page }) => {
+test('checkboxes behaviors', async ({ page }) => {
   // Navigate to the search page with query parameters
   // Navigate to the search page with query parameters
   await page.goto('/_search?q=alerts');
   await page.goto('/_search?q=alerts');
 
 
@@ -28,7 +28,10 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
   await page.getByTestId('cb-select').first().click({ force: true });
 
 
   // Click the select all checkbox
   // Click the select all checkbox
-  await page.getByTestId('delete-control-button').first().click({ force: true });
+  await page
+    .getByTestId('delete-control-button')
+    .first()
+    .click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
   await page.getByTestId('cb-select-all').click({ force: true });
 
 
   // Unclick the first checkbox after selecting all
   // Unclick the first checkbox after selecting all
@@ -41,16 +44,19 @@ test('checkboxes behaviors', async({ page }) => {
   await page.getByTestId('cb-select').first().click({ force: true });
   await page.getByTestId('cb-select').first().click({ force: true });
 });
 });
 
 
-
-test('successfully loads /_private-legacy-pages', async({ page }) => {
+test('successfully loads /_private-legacy-pages', async ({ page }) => {
   await page.goto('/_private-legacy-pages');
   await page.goto('/_private-legacy-pages');
 
 
   // Confirm search result elements are visible
   // Confirm search result elements are visible
-  await expect(page.locator('[data-testid="search-result-base"]')).toBeVisible();
-  await expect(page.locator('[data-testid="search-result-private-legacy-pages"]')).toBeVisible();
+  await expect(
+    page.locator('[data-testid="search-result-base"]'),
+  ).toBeVisible();
+  await expect(
+    page.locator('[data-testid="search-result-private-legacy-pages"]'),
+  ).toBeVisible();
 });
 });
 
 
-test('Search all pages by word', async({ page }) => {
+test('Search all pages by word', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   await page.getByTestId('open-search-modal-button').click();
   await page.getByTestId('open-search-modal-button').click();
   await expect(page.getByTestId('search-modal')).toBeVisible();
   await expect(page.getByTestId('search-modal')).toBeVisible();
@@ -58,51 +64,51 @@ test('Search all pages by word', async({ page }) => {
   await expect(page.locator('.search-menu-item').first()).toBeVisible();
   await expect(page.locator('.search-menu-item').first()).toBeVisible();
 });
 });
 
 
-test.describe.serial('Search all pages', () => {
-  const tag = 'help';
-  const searchText = `tag:${tag}`;
-
-  test('Successfully created tags', async({ page }) => {
-    await page.goto('/');
-
-    // open Edit Tags Modal to add tag
-    await page.locator('.grw-side-contents-sticky-container').isVisible();
-    await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
-    await expect(page.locator('#edit-tag-modal')).toBeVisible();
-    await page.locator('.rbt-input-main').fill(tag);
-    await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
-    await page.getByTestId('tag-edit-done-btn').click();
-
-  });
-
-  test('Search all pages by tag is successfully loaded', async({ page }) => {
-    await page.goto('/');
-
-    // Search
-    await page.getByTestId('open-search-modal-button').click();
-    await expect(page.getByTestId('search-modal')).toBeVisible();
-    await page.locator('.form-control').fill(searchText);
-    await page.getByTestId('search-all-menu-item').click();
-
-    // Confirm search result elements are visible
-    const searchResultList = page.getByTestId('search-result-list');
-    await expect(searchResultList).toBeVisible();
-    await expect(searchResultList.locator('li')).toHaveCount(1);
+test.describe
+  .serial('Search all pages', () => {
+    const tag = 'help';
+    const searchText = `tag:${tag}`;
+
+    test('Successfully created tags', async ({ page }) => {
+      await page.goto('/');
+
+      // open Edit Tags Modal to add tag
+      await page.locator('.grw-side-contents-sticky-container').isVisible();
+      await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
+      await expect(page.locator('#edit-tag-modal')).toBeVisible();
+      await page.locator('.rbt-input-main').fill(tag);
+      await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+      await page.getByTestId('tag-edit-done-btn').click();
+    });
+
+    test('Search all pages by tag is successfully loaded', async ({ page }) => {
+      await page.goto('/');
+
+      // Search
+      await page.getByTestId('open-search-modal-button').click();
+      await expect(page.getByTestId('search-modal')).toBeVisible();
+      await page.locator('.form-control').fill(searchText);
+      await page.getByTestId('search-all-menu-item').click();
+
+      // Confirm search result elements are visible
+      const searchResultList = page.getByTestId('search-result-list');
+      await expect(searchResultList).toBeVisible();
+      await expect(searchResultList.locator('li')).toHaveCount(1);
+    });
+
+    test('Successfully order page search results by tag', async ({ page }) => {
+      await page.goto('/');
+
+      await page.locator('.grw-tag-simple-bar').locator('a').click();
+
+      expect(page.getByTestId('search-result-base')).toBeVisible();
+      expect(page.getByTestId('search-result-list')).toBeVisible();
+      expect(page.getByTestId('search-result-content')).toBeVisible();
+    });
   });
   });
 
 
-  test('Successfully order page search results by tag', async({ page }) => {
-    await page.goto('/');
-
-    await page.locator('.grw-tag-simple-bar').locator('a').click();
-
-    expect(page.getByTestId('search-result-base')).toBeVisible();
-    expect(page.getByTestId('search-result-list')).toBeVisible();
-    expect(page.getByTestId('search-result-content')).toBeVisible();
-  });
-});
-
 test.describe('Sort with dropdown', () => {
 test.describe('Sort with dropdown', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=sand');
     await page.goto('/_search?q=sand');
 
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
@@ -113,41 +119,40 @@ test.describe('Sort with dropdown', () => {
     await page.locator('.search-control').locator('button').first().click();
     await page.locator('.search-control').locator('button').first().click();
   });
   });
 
 
-  test('Open sort dropdown', async({ page }) => {
-    await expect(page.locator('.search-control .dropdown-menu.show')).toBeVisible();
+  test('Open sort dropdown', async ({ page }) => {
+    await expect(
+      page.locator('.search-control .dropdown-menu.show'),
+    ).toBeVisible();
   });
   });
 
 
-  test('Sort by relevance', async({ page }) => {
+  test('Sort by relevance', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
 
     await expect(dropdownMenu).toBeVisible();
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(0).click();
     await dropdownMenu.locator('.dropdown-item').nth(0).click();
 
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
   });
   });
 
 
-  test('Sort by creation date', async({ page }) => {
+  test('Sort by creation date', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
 
     await expect(dropdownMenu).toBeVisible();
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(1).click();
     await dropdownMenu.locator('.dropdown-item').nth(1).click();
 
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
   });
   });
 
 
-  test('Sort by last update date', async({ page }) => {
+  test('Sort by last update date', async ({ page }) => {
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
     const dropdownMenu = page.locator('.search-control .dropdown-menu.show');
 
 
     await expect(dropdownMenu).toBeVisible();
     await expect(dropdownMenu).toBeVisible();
     await dropdownMenu.locator('.dropdown-item').nth(2).click();
     await dropdownMenu.locator('.dropdown-item').nth(2).click();
 
 
-
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
@@ -155,22 +160,26 @@ test.describe('Sort with dropdown', () => {
 });
 });
 
 
 test.describe('Search and use', () => {
 test.describe('Search and use', () => {
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/_search?q=alerts');
     await page.goto('/_search?q=alerts');
 
 
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-base')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-list')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
     await expect(page.getByTestId('search-result-content')).toBeVisible();
 
 
-    await page.getByTestId('page-list-item-L').first().getByTestId('open-page-item-control-btn').click();
+    await page
+      .getByTestId('page-list-item-L')
+      .first()
+      .getByTestId('open-page-item-control-btn')
+      .click();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   });
   });
 
 
-  test('Successfully the dropdown is opened', async({ page }) => {
+  test('Successfully the dropdown is opened', async ({ page }) => {
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
     await expect(page.locator('.dropdown-menu.show')).toBeVisible();
   });
   });
 
 
-  test('Successfully add bookmark', async({ page }) => {
+  test('Successfully add bookmark', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -178,10 +187,15 @@ test.describe('Search and use', () => {
     // Add bookmark
     // Add bookmark
     await dropdonwMenu.getByTestId('add-bookmark-btn').click();
     await dropdonwMenu.getByTestId('add-bookmark-btn').click();
 
 
-    await expect(page.getByTestId('search-result-content').locator('.btn-bookmark.active').first()).toBeVisible();
+    await expect(
+      page
+        .getByTestId('search-result-content')
+        .locator('.btn-bookmark.active')
+        .first(),
+    ).toBeVisible();
   });
   });
 
 
-  test('Successfully open duplicate modal', async({ page }) => {
+  test('Successfully open duplicate modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -191,7 +205,7 @@ test.describe('Search and use', () => {
     await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
     await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
   });
   });
 
 
-  test('Successfully open move/rename modal', async({ page }) => {
+  test('Successfully open move/rename modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -201,7 +215,7 @@ test.describe('Search and use', () => {
     await expect(page.getByTestId('page-rename-modal')).toBeVisible();
     await expect(page.getByTestId('page-rename-modal')).toBeVisible();
   });
   });
 
 
-  test('Successfully open delete modal', async({ page }) => {
+  test('Successfully open delete modal', async ({ page }) => {
     const dropdonwMenu = page.locator('.dropdown-menu.show');
     const dropdonwMenu = page.locator('.dropdown-menu.show');
 
 
     await expect(dropdonwMenu).toBeVisible();
     await expect(dropdonwMenu).toBeVisible();
@@ -212,7 +226,7 @@ test.describe('Search and use', () => {
   });
   });
 });
 });
 
 
-test('Search current tree by word is successfully loaded', async({ page }) => {
+test('Search current tree by word is successfully loaded', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   const searchText = 'GROWI';
   const searchText = 'GROWI';
 
 

+ 29 - 21
apps/app/playwright/40-admin/access-to-admin-page.spec.ts

@@ -1,60 +1,64 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
-test('admin is successfully loaded', async({ page }) => {
+test('admin is successfully loaded', async ({ page }) => {
   await page.goto('/admin');
   await page.goto('/admin');
 
 
   await expect(page.getByTestId('admin-home')).toBeVisible();
   await expect(page.getByTestId('admin-home')).toBeVisible();
-  await expect(page.getByTestId('admin-system-information-table')).toBeVisible();
+  await expect(
+    page.getByTestId('admin-system-information-table'),
+  ).toBeVisible();
 });
 });
 
 
-test('admin/app is successfully loaded', async({ page }) => {
+test('admin/app is successfully loaded', async ({ page }) => {
   await page.goto('/admin/app');
   await page.goto('/admin/app');
 
 
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
   await expect(page.getByTestId('admin-app-settings')).toBeVisible();
-  // await expect(page.getByTestId('v5-page-migration')).toBeVisible();
-  await expect(page.locator('#cbFileUpload')).toBeChecked();
 });
 });
 
 
-test('admin/security is successfully loaded', async({ page }) => {
+test('admin/security is successfully loaded', async ({ page }) => {
   await page.goto('/admin/security');
   await page.goto('/admin/security');
 
 
   await expect(page.getByTestId('admin-security')).toBeVisible();
   await expect(page.getByTestId('admin-security')).toBeVisible();
-  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText('Always displayed');
-  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText('Always displayed');
+  await expect(page.locator('#isShowRestrictedByOwner')).toHaveText(
+    'Always displayed',
+  );
+  await expect(page.locator('#isShowRestrictedByGroup')).toHaveText(
+    'Always displayed',
+  );
 });
 });
 
 
-test('admin/markdown is successfully loaded', async({ page }) => {
+test('admin/markdown is successfully loaded', async ({ page }) => {
   await page.goto('/admin/markdown');
   await page.goto('/admin/markdown');
 
 
   await expect(page.getByTestId('admin-markdown')).toBeVisible();
   await expect(page.getByTestId('admin-markdown')).toBeVisible();
   await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
   await expect(page.locator('#isEnabledLinebreaksInComments')).toBeChecked();
 });
 });
 
 
-test('admin/customize is successfully loaded', async({ page }) => {
+test('admin/customize is successfully loaded', async ({ page }) => {
   await page.goto('/admin/customize');
   await page.goto('/admin/customize');
 
 
   await expect(page.getByTestId('admin-customize')).toBeVisible();
   await expect(page.getByTestId('admin-customize')).toBeVisible();
 });
 });
 
 
-test('admin/importer is successfully loaded', async({ page }) => {
+test('admin/importer is successfully loaded', async ({ page }) => {
   await page.goto('/admin/importer');
   await page.goto('/admin/importer');
 
 
   await expect(page.getByTestId('admin-import-data')).toBeVisible();
   await expect(page.getByTestId('admin-import-data')).toBeVisible();
 });
 });
 
 
-test('admin/export is successfully loaded', async({ page }) => {
+test('admin/export is successfully loaded', async ({ page }) => {
   await page.goto('/admin/export');
   await page.goto('/admin/export');
 
 
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 });
 
 
-test('admin/data-transfer is successfully loaded', async({ page }) => {
+test('admin/data-transfer is successfully loaded', async ({ page }) => {
   await page.goto('/admin/data-transfer');
   await page.goto('/admin/data-transfer');
 
 
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
   await expect(page.getByTestId('admin-export-archive-data')).toBeVisible();
 });
 });
 
 
-test('admin/notification is successfully loaded', async({ page }) => {
+test('admin/notification is successfully loaded', async ({ page }) => {
   await page.goto('/admin/notification');
   await page.goto('/admin/notification');
 
 
   await expect(page.getByTestId('admin-notification')).toBeVisible();
   await expect(page.getByTestId('admin-notification')).toBeVisible();
@@ -62,7 +66,7 @@ test('admin/notification is successfully loaded', async({ page }) => {
   await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
   await expect(page.getByTestId('slack-integration-list-item')).toBeVisible();
 });
 });
 
 
-test('admin/slack-integration is successfully loaded', async({ page }) => {
+test('admin/slack-integration is successfully loaded', async ({ page }) => {
   await page.goto('/admin/slack-integration');
   await page.goto('/admin/slack-integration');
 
 
   await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
   await expect(page.getByTestId('admin-slack-integration')).toBeVisible();
@@ -70,27 +74,31 @@ test('admin/slack-integration is successfully loaded', async({ page }) => {
   await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
   await expect(page.locator('img.bot-difficulty-icon').first()).toBeVisible();
 });
 });
 
 
-test('admin/slack-integration-legacy is successfully loaded', async({ page }) => {
+test('admin/slack-integration-legacy is successfully loaded', async ({
+  page,
+}) => {
   await page.goto('/admin/slack-integration-legacy');
   await page.goto('/admin/slack-integration-legacy');
 
 
-  await expect(page.getByTestId('admin-slack-integration-legacy')).toBeVisible();
+  await expect(
+    page.getByTestId('admin-slack-integration-legacy'),
+  ).toBeVisible();
 });
 });
 
 
-test('admin/users is successfully loaded', async({ page }) => {
+test('admin/users is successfully loaded', async ({ page }) => {
   await page.goto('/admin/users');
   await page.goto('/admin/users');
 
 
   await expect(page.getByTestId('admin-users')).toBeVisible();
   await expect(page.getByTestId('admin-users')).toBeVisible();
   await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
   await expect(page.getByTestId('user-table-tr').first()).toBeVisible();
 });
 });
 
 
-test('admin/user-groups is successfully loaded', async({ page }) => {
+test('admin/user-groups is successfully loaded', async ({ page }) => {
   await page.goto('/admin/user-groups');
   await page.goto('/admin/user-groups');
 
 
   await expect(page.getByTestId('admin-user-groups')).toBeVisible();
   await expect(page.getByTestId('admin-user-groups')).toBeVisible();
   await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
   await expect(page.getByTestId('grw-user-group-table').first()).toBeVisible();
 });
 });
 
 
-test('admin/search is successfully loaded', async({ page }) => {
+test('admin/search is successfully loaded', async ({ page }) => {
   await page.goto('/admin/search');
   await page.goto('/admin/search');
 
 
   await expect(page.getByTestId('admin-full-text-search')).toBeVisible();
   await expect(page.getByTestId('admin-full-text-search')).toBeVisible();

+ 23 - 16
apps/app/playwright/50-sidebar/access-to-sidebar.spec.ts

@@ -1,39 +1,46 @@
-import { test, expect } from '@playwright/test';
+import { expect, test } from '@playwright/test';
 
 
 import { collapseSidebar } from '../utils';
 import { collapseSidebar } from '../utils';
 
 
-
 test.describe('Access to sidebar', () => {
 test.describe('Access to sidebar', () => {
-
-  test.beforeEach(async({ page }) => {
+  test.beforeEach(async ({ page }) => {
     await page.goto('/');
     await page.goto('/');
     await collapseSidebar(page, false);
     await collapseSidebar(page, false);
   });
   });
 
 
-  test('Successfully show sidebar', async({ page }) => {
+  test('Successfully show sidebar', async ({ page }) => {
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
   });
   });
 
 
-  test('Successfully access to page tree', async({ page }) => {
+  test('Successfully access to page tree', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-page-tree').click();
     await page.getByTestId('grw-sidebar-nav-primary-page-tree').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(page.getByTestId('grw-pagetree-item-container').first()).toBeVisible();
+    await expect(
+      page.getByTestId('grw-pagetree-item-container').first(),
+    ).toBeVisible();
   });
   });
 
 
-  test('Successfully access to recent changes', async({ page }) => {
+  test('Successfully access to recent changes', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-recent-changes').click();
     await page.getByTestId('grw-sidebar-nav-primary-recent-changes').click();
     await expect(page.getByTestId('grw-recent-changes')).toBeVisible();
     await expect(page.getByTestId('grw-recent-changes')).toBeVisible();
     await expect(page.locator('.list-group-item').first()).toBeVisible();
     await expect(page.locator('.list-group-item').first()).toBeVisible();
   });
   });
 
 
-  test('Successfully access to custom sidebar', async({ page }) => {
+  test('Successfully access to custom sidebar', async ({ page }) => {
     await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
     await page.getByTestId('grw-sidebar-nav-primary-custom-sidebar').click();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
     await expect(page.getByTestId('grw-sidebar-contents')).toBeVisible();
-    await expect(page.locator('.grw-sidebar-content-header > h3').locator('a')).toBeVisible();
+
+    // Check if edit_note icon is visible within the button
+    const editNoteIcon = page
+      .locator('.grw-custom-sidebar-content button .material-symbols-outlined')
+      .filter({ hasText: 'edit_note' });
+    await expect(editNoteIcon).toBeVisible();
   });
   });
 
 
-  test('Successfully access to GROWI Docs page', async({ page }) => {
-    const linkElement = page.locator('.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]');
+  test('Successfully access to GROWI Docs page', async ({ page }) => {
+    const linkElement = page.locator(
+      '.grw-sidebar-nav-secondary-container a[href*="https://docs.growi.org"]',
+    );
     const docsUrl = await linkElement.getAttribute('href');
     const docsUrl = await linkElement.getAttribute('href');
     if (docsUrl == null) {
     if (docsUrl == null) {
       throw new Error('url is null');
       throw new Error('url is null');
@@ -43,12 +50,13 @@ test.describe('Access to sidebar', () => {
     expect(body).toContain('</html>');
     expect(body).toContain('</html>');
   });
   });
 
 
-  test('Successfully access to trash page', async({ page }) => {
-    await page.locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]').click();
+  test('Successfully access to trash page', async ({ page }) => {
+    await page
+      .locator('.grw-sidebar-nav-secondary-container a[href*="/trash"]')
+      .click();
     await expect(page.getByTestId('trash-page-list')).toBeVisible();
     await expect(page.getByTestId('trash-page-list')).toBeVisible();
   });
   });
 
 
-
   //
   //
   // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
   // Deactivate: An error occurs that cannot be reproduced in the development environment. -- Yuki Takei 2024.05.10
   //
   //
@@ -166,5 +174,4 @@ test.describe('Access to sidebar', () => {
   //     cy.get('.modal-header > button').click();
   //     cy.get('.modal-header > button').click();
   //   });
   //   });
   // });
   // });
-
 });
 });

+ 1 - 2
apps/app/playwright/50-sidebar/switching-sidebar-mode.spec.ts

@@ -2,8 +2,7 @@ import { test } from '@playwright/test';
 
 
 import { collapseSidebar } from '../utils';
 import { collapseSidebar } from '../utils';
 
 
-
-test('Switch sidebar mode', async({ page }) => {
+test('Switch sidebar mode', async ({ page }) => {
   await page.goto('/');
   await page.goto('/');
   await collapseSidebar(page, false);
   await collapseSidebar(page, false);
   await collapseSidebar(page, true);
   await collapseSidebar(page, true);

+ 36 - 21
apps/app/playwright/60-home/home.spec.ts

@@ -1,31 +1,36 @@
-import { test, expect } from '@playwright/test';
+/** biome-ignore-all lint/performance/noAwaitInLoops: Allow in tests */
 
 
+import { expect, test } from '@playwright/test';
 
 
-test('Visit User home', async({ page }) => {
+test('Visit User home', async ({ page }) => {
   await page.goto('dummy');
   await page.goto('dummy');
 
 
   // Open PersonalDropdown
   // Open PersonalDropdown
   await page.getByTestId('personal-dropdown-button').click();
   await page.getByTestId('personal-dropdown-button').click();
-  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-personal-dropdown-menu-user-home'),
+  ).toBeVisible();
 
 
   // Click UserHomeMenu
   // Click UserHomeMenu
   await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
   await page.getByTestId('grw-personal-dropdown-menu-user-home').click();
   await expect(page.getByTestId('grw-users-info')).toBeVisible();
   await expect(page.getByTestId('grw-users-info')).toBeVisible();
 });
 });
 
 
-test('Vist User settings', async({ page }) => {
+test('Vist User settings', async ({ page }) => {
   await page.goto('dummy');
   await page.goto('dummy');
 
 
   // Open PersonalDropdown
   // Open PersonalDropdown
   await page.getByTestId('personal-dropdown-button').click();
   await page.getByTestId('personal-dropdown-button').click();
-  await expect(page.getByTestId('grw-personal-dropdown-menu-user-home')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-personal-dropdown-menu-user-home'),
+  ).toBeVisible();
 
 
   // Click UserSettingsMenu
   // Click UserSettingsMenu
   page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
   page.getByTestId('grw-personal-dropdown-menu-user-settings').click();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
 });
 
 
-test('Access User information', async({ page }) => {
+test('Access User information', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click BasicInfoSettingUpdateButton
   // Click BasicInfoSettingUpdateButton
@@ -36,23 +41,29 @@ test('Access User information', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
-test('Access External account', async({ page }) => {
+test('Access External account', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click ExternalAccountsTabButton
   // Click ExternalAccountsTabButton
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await page.getByTestId('external-accounts-tab-button').first().click();
   await page.getByTestId('external-accounts-tab-button').first().click();
 
 
-  // Expect an error toaster to be displayed when the AddExternalAccountsButton is pressed
+  // press AddExternalAccountButton
   await page.getByTestId('grw-external-account-add-button').click();
   await page.getByTestId('grw-external-account-add-button').click();
   await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
   await expect(page.getByTestId('grw-associate-modal')).toBeVisible();
   await page.getByTestId('add-external-account-button').click();
   await page.getByTestId('add-external-account-button').click();
-  await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await page.locator('.Toastify__close-button').click();
+
+  // Expect a few failed toasters to be displayed
+  await expect(page.locator('.Toastify__toast').first()).toBeVisible();
+  const toastCloseButtons = page.locator('.Toastify__close-button');
+  const count = await toastCloseButtons.count();
+  for (let i = 0; i < count; i++) {
+    await toastCloseButtons.first().click();
+  }
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
   await expect(page.locator('.Toastify__toast')).not.toBeVisible();
 });
 });
 
 
-test('Access Password setting', async({ page }) => {
+test('Access Password setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click PasswordSettingTabButton
   // Click PasswordSettingTabButton
@@ -65,15 +76,13 @@ test('Access Password setting', async({ page }) => {
 
 
   const toastElementsCount = await toastElements.count();
   const toastElementsCount = await toastElements.count();
   for (let i = 0; i < toastElementsCount; i++) {
   for (let i = 0; i < toastElementsCount; i++) {
-    // eslint-disable-next-line no-await-in-loop
     await toastElements.nth(i).click();
     await toastElements.nth(i).click();
   }
   }
 
 
   await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
   await expect(page.getByTestId('.Toastify__toast')).not.toBeVisible();
 });
 });
 
 
-
-test('Access API setting', async({ page }) => {
+test('Access API setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click ApiSettingTabButton
   // Click ApiSettingTabButton
@@ -85,7 +94,7 @@ test('Access API setting', async({ page }) => {
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
-test('Access Access Token setting', async({ page }) => {
+test('Access Access Token setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click ApiSettingTabButton
   // Click ApiSettingTabButton
@@ -98,7 +107,9 @@ test('Access Access Token setting', async({ page }) => {
   await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
   await page.getByTestId('grw-accesstoken-checkbox-read:*').check();
   await page.getByTestId('grw-accesstoken-create-button').click();
   await page.getByTestId('grw-accesstoken-create-button').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-  await expect(page.getByTestId('grw-accesstoken-new-token-display')).toBeVisible();
+  await expect(
+    page.getByTestId('grw-accesstoken-new-token-display'),
+  ).toBeVisible();
 
 
   // Expect a success toaster to be displayed when the Access Token is deleted
   // Expect a success toaster to be displayed when the Access Token is deleted
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button').click();
@@ -106,22 +117,26 @@ test('Access Access Token setting', async({ page }) => {
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button').click();
   await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
   await page.getByTestId('grw-accesstoken-delete-button-in-modal').click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
-
 });
 });
 
 
-test('Access In-App Notification setting', async({ page }) => {
+test('Access In-App Notification setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click InAppNotificationSettingTabButton
   // Click InAppNotificationSettingTabButton
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
-  await page.getByTestId('in-app-notification-settings-tab-button').first().click();
+  await page
+    .getByTestId('in-app-notification-settings-tab-button')
+    .first()
+    .click();
 
 
   // Expect a success toaster to be displayed when the InAppNotificationSettingsUpdateButton is clicked
   // Expect a success toaster to be displayed when the InAppNotificationSettingsUpdateButton is clicked
-  await page.getByTestId('grw-in-app-notification-settings-update-button').click();
+  await page
+    .getByTestId('grw-in-app-notification-settings-update-button')
+    .click();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
   await expect(page.locator('.Toastify__toast')).toBeVisible();
 });
 });
 
 
-test('Acccess Other setting', async({ page }) => {
+test('Acccess Other setting', async ({ page }) => {
   await page.goto('/me');
   await page.goto('/me');
 
 
   // Click OtherSettingTabButton
   // Click OtherSettingTabButton

+ 1 - 1
apps/app/playwright/auth.setup.ts

@@ -4,6 +4,6 @@ import { login } from './utils/Login';
 
 
 // Commonised login process for use elsewhere
 // Commonised login process for use elsewhere
 // see: https://github.com/microsoft/playwright/issues/22114
 // see: https://github.com/microsoft/playwright/issues/22114
-setup('Authenticate as the "admin" user', async({ page }) => {
+setup('Authenticate as the "admin" user', async ({ page }) => {
   await login(page);
   await login(page);
 });
 });

+ 12 - 6
apps/app/playwright/utils/CollapseSidebar.ts

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

+ 8 - 6
apps/app/playwright/utils/Login.ts

@@ -1,19 +1,21 @@
 import path from 'node:path';
 import path from 'node:path';
-
 import { expect, type Page } from '@playwright/test';
 import { expect, type Page } from '@playwright/test';
 
 
 const authFile = path.resolve(__dirname, '../.auth/admin.json');
 const authFile = path.resolve(__dirname, '../.auth/admin.json');
 
 
-export const login = async(page: Page): Promise<void> => {
+export const login = async (page: Page): Promise<void> => {
   // Perform authentication steps. Replace these actions with your own.
   // Perform authentication steps. Replace these actions with your own.
   await page.goto('/admin');
   await page.goto('/admin');
 
 
-  const loginForm = await page.getByRole('form');
+  const loginForm = await page.getByTestId('login-form');
 
 
   if (loginForm != null) {
   if (loginForm != null) {
-    await page.getByLabel('Username or E-mail').fill('admin');
-    await page.getByLabel('Password').fill('adminadmin');
-    await page.locator('[type=submit]').filter({ hasText: 'Login' }).click();
+    await loginForm.getByPlaceholder('Username or E-mail').fill('admin');
+    await loginForm.getByPlaceholder('Password').fill('adminadmin');
+    await loginForm
+      .locator('[type=submit]')
+      .filter({ hasText: 'Login' })
+      .click();
   }
   }
 
 
   await page.waitForURL('/admin');
   await page.waitForURL('/admin');

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

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

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

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

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

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

+ 40 - 40
apps/app/public/images/icons/favicon/manifest.json

@@ -1,41 +1,41 @@
 {
 {
- "name": "App",
- "icons": [
-  {
-   "src": "\/android-icon-36x36.png",
-   "sizes": "36x36",
-   "type": "image\/png",
-   "density": "0.75"
-  },
-  {
-   "src": "\/android-icon-48x48.png",
-   "sizes": "48x48",
-   "type": "image\/png",
-   "density": "1.0"
-  },
-  {
-   "src": "\/android-icon-72x72.png",
-   "sizes": "72x72",
-   "type": "image\/png",
-   "density": "1.5"
-  },
-  {
-   "src": "\/android-icon-96x96.png",
-   "sizes": "96x96",
-   "type": "image\/png",
-   "density": "2.0"
-  },
-  {
-   "src": "\/android-icon-144x144.png",
-   "sizes": "144x144",
-   "type": "image\/png",
-   "density": "3.0"
-  },
-  {
-   "src": "\/android-icon-192x192.png",
-   "sizes": "192x192",
-   "type": "image\/png",
-   "density": "4.0"
-  }
- ]
-}
+  "name": "App",
+  "icons": [
+    {
+      "src": "\/android-icon-36x36.png",
+      "sizes": "36x36",
+      "type": "image\/png",
+      "density": "0.75"
+    },
+    {
+      "src": "\/android-icon-48x48.png",
+      "sizes": "48x48",
+      "type": "image\/png",
+      "density": "1.0"
+    },
+    {
+      "src": "\/android-icon-72x72.png",
+      "sizes": "72x72",
+      "type": "image\/png",
+      "density": "1.5"
+    },
+    {
+      "src": "\/android-icon-96x96.png",
+      "sizes": "96x96",
+      "type": "image\/png",
+      "density": "2.0"
+    },
+    {
+      "src": "\/android-icon-144x144.png",
+      "sizes": "144x144",
+      "type": "image\/png",
+      "density": "3.0"
+    },
+    {
+      "src": "\/android-icon-192x192.png",
+      "sizes": "192x192",
+      "type": "image\/png",
+      "density": "4.0"
+    }
+  ]
+}

+ 14 - 31
apps/app/public/static/locales/en_US/admin.json

@@ -313,7 +313,7 @@
       "done": "Copied to clipboard!"
       "done": "Copied to clipboard!"
     },
     },
     "bug_report": "Submitting a bug report",
     "bug_report": "Submitting a bug report",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>then submit your issue to GitHub.</a>"
+    "submit_bug_report": "Submit your issue to GitHub."
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
     "migration_desc": "There are some pages with old v4 compatibility. To take advantage of new features such as page trees and easy renaming, please convert all your pages to v5 compatibility.",
@@ -339,7 +339,7 @@
     "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
     "supplymentary_message_to_start": "As for the API, only the administrator API will be functional.",
     "start_maintenance_mode": "Start maintenance mode",
     "start_maintenance_mode": "Start maintenance mode",
     "end_maintenance_mode": "End maintenance mode",
     "end_maintenance_mode": "End maintenance mode",
-    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"Security Settings\" > \"Maintenance Mode\"."
+    "description": "Maintenance mode restricts all user operations. Always start the maintenance mode before \"importing data\" and \"upgrading to V5\". To exit, go to \"App Settings\" > \"Maintenance Mode\"."
   },
   },
   "app_setting": {
   "app_setting": {
     "site_name": "Site name",
     "site_name": "Site name",
@@ -356,9 +356,9 @@
     "confidential_example": "ex): internal use only",
     "confidential_example": "ex): internal use only",
     "default_language": "Default language for new users",
     "default_language": "Default language for new users",
     "default_mail_visibility": "Disclose e-mail for new users",
     "default_mail_visibility": "Disclose e-mail for new users",
+    "default_read_only_for_new_user": "Editing Restrictions for New Users",
+    "set_read_only_for_new_user": "Set new users to read-only mode",
     "file_uploading": "File uploading",
     "file_uploading": "File uploading",
-    "enable_files_except_image": "Enabling this option will allow upload of any file type. Without this option, only image file upload is supported.",
-    "attach_enable": "You can attach files other than image files if you enable this option.",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "page_bulk_export_settings": "Page Bulk Export Settings",
     "enable_page_bulk_export": "Enable bulk export",
     "enable_page_bulk_export": "Enable bulk export",
     "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
     "page_bulk_export_explanation": "Enables a feature that allows all users to export a page and all it's child pages at once from the menu. Exported data will be automatically deleted after the storage period has passed.",
@@ -448,10 +448,7 @@
     "customize_settings": "Customize",
     "customize_settings": "Customize",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "Default sidebar mode",
       "title": "Default sidebar mode",
-      "desc": "You can set the sidebar mode for new users and guests visiting the page.",
-      "dock_mode_default_desc": "You can set the initial state of the sidebar when Dock Mode is selected.",
-      "dock_mode_default_open": "Open the page as it was opened from the beginning",
-      "dock_mode_default_close": "Open the page as it was closed from the beginning"
+      "desc": "You can set the sidebar mode for new users and guests visiting the page."
     },
     },
     "layout": "Layout",
     "layout": "Layout",
     "layout_options": {
     "layout_options": {
@@ -531,7 +528,7 @@
     "page_path": "Page Path",
     "page_path": "Page Path",
     "beta_warning": "This function is Beta.",
     "beta_warning": "This function is Beta.",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
-    "import_growi_archive": "Import GROWI archive",
+    "import_growi_archive": "Import Archive Data",
     "error": {
     "error": {
       "only_upsert_available": "Only 'Upsert' option is available for pages collection."
       "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",
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "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",
     "prepare_new_account_for_migration": "Prepare new account for migration",
     "archive_data_import_detail": "More Details? Ckick here.",
     "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_management": {
     "export_archive_data": "Export Archive Data",
     "export_archive_data": "Export Archive Data",
@@ -746,7 +731,7 @@
       "description1": "Temporarily issue new users by email addresses.",
       "description1": "Temporarily issue new users by email addresses.",
       "description2": "A temporary password will be generated for the first login.",
       "description2": "A temporary password will be generated for the first login.",
       "invite_thru_email": "Send invitation email",
       "invite_thru_email": "Send invitation email",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "Valid email address is required",
       "valid_email": "Valid email address is required",
       "temporary_password": "The created user has a temporary password",
       "temporary_password": "The created user has a temporary password",
       "send_new_password": "Please send the new password to the user.",
       "send_new_password": "Please send the new password to the user.",
@@ -796,7 +781,11 @@
     "unset": "No",
     "unset": "No",
     "related_username": "Related user's ",
     "related_username": "Related user's ",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
     "cannot_invite_maximum_users": "Can not invite more than the maximum number of users.",
-    "current_users": "Current users:"
+    "user_statistics": {
+      "total": "Total Users",
+      "active": "Active",
+      "inactive": "Inactive"
+    }
   },
   },
   "user_group_management": {
   "user_group_management": {
     "user_group_management": "User Group Management",
     "user_group_management": "User Group Management",
@@ -1011,12 +1000,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Upload Archived Data",
     "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data",
     "ADMIN_GROWI_DATA_IMPORTED": "Import Archived Data",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Discard 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_CREATE": "Create Archived Data",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Download Archive Data",
     "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data",
     "ADMIN_ARCHIVE_DATA_DELETE": "Delete Archive Data",
@@ -1139,4 +1122,4 @@
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "ai_search_management": "AI search management"
     "ai_search_management": "AI search management"
   }
   }
-}
+}

+ 18 - 2
apps/app/public/static/locales/en_US/translation.json

@@ -786,6 +786,11 @@
       "updatedAt": "Last update date"
       "updatedAt": "Last update date"
     }
     }
   },
   },
+  "help_dropdown": {
+    "show_shortcuts": "Show shortcuts",
+    "growi_cloud_help": "GROWI.cloud Help",
+    "growi_version": "GROWI version"
+  },
   "private_legacy_pages": {
   "private_legacy_pages": {
     "title": "Private Legacy Pages",
     "title": "Private Legacy Pages",
     "bulk_operation": "Bulk operation",
     "bulk_operation": "Bulk operation",
@@ -999,7 +1004,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Bookmarks",
     "bookmarks": "Bookmarks",
-    "recently_created": "Recently Created"
+    "recently_created": "Recently Created",
+    "recent_activity": "Recent Activity",
+    "unknown_action": "made an unspecified change",
+    "page_create": "created a page",
+    "page_update": "updated a page",
+    "page_delete": "deleted a page",
+    "page_delete_completely": "deleted a page",
+    "page_rename": "renamed a page",
+    "page_revert": "reverted a page",
+    "page_like": "liked a page",
+    "page_duplicate": "duplicated a page",
+    "comment_create": "posted a comment"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "bookmark folder",
     "bookmark_folder": "bookmark folder",
@@ -1062,4 +1078,4 @@
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "error-toaster": "Synchronization of the latest text failed"
     "error-toaster": "Synchronization of the latest text failed"
   }
   }
-}
+}

+ 14 - 31
apps/app/public/static/locales/fr_FR/admin.json

@@ -313,7 +313,7 @@
       "done": "Copié dans le presse-papier!"
       "done": "Copié dans le presse-papier!"
     },
     },
     "bug_report": "Informations de diagnostic",
     "bug_report": "Informations de diagnostic",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
+    "submit_bug_report": "Soummettre ensuite sur GitHub."
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",
     "migration_desc": "Des pages sont encore en V4. Pour profiter des nouvelles fonctionnalitées, convertir toutes les pages vers la V5.",
@@ -339,7 +339,7 @@
     "supplymentary_message_to_start": "Seul l'API d'administration sera actif.",
     "supplymentary_message_to_start": "Seul l'API d'administration sera actif.",
     "start_maintenance_mode": "Activer le mode maitenance",
     "start_maintenance_mode": "Activer le mode maitenance",
     "end_maintenance_mode": "Désactiver le mode maitenance",
     "end_maintenance_mode": "Désactiver le mode maitenance",
-    "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\"."
+    "description": "Le mode maintenance restreint l'utilisation de GROWI. Toujours démarrer le mode maintenance avant l'\"import de données\" et la \"conversion vers la V5\".Pour quitter ce mode, veuillez vous rendre dans « Paramètres de l'application » > « Mode maintenance »."
   },
   },
   "app_setting": {
   "app_setting": {
     "site_name": "Nom",
     "site_name": "Nom",
@@ -356,9 +356,9 @@
     "confidential_example": "ex): usage interne seulement",
     "confidential_example": "ex): usage interne seulement",
     "default_language": "Langue par défaut",
     "default_language": "Langue par défaut",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
     "default_mail_visibility": "Mode d'affichage de l'adresse courriel",
+    "default_read_only_for_new_user": "Restriction d'édition pour les nouveaux utilisateurs",
+    "set_read_only_for_new_user": "Rendre les nouveaux utilisateurs en lecture seule",
     "file_uploading": "Téléversement de fichiers",
     "file_uploading": "Téléversement de fichiers",
-    "enable_files_except_image": "Autoriser tout les types de fichiers",
-    "attach_enable": "Autorise le téléversement de tout les types de fichiers.",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "page_bulk_export_settings": "Paramètres d'exportation de pages par lots",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "enable_page_bulk_export": "Activer l'exportation groupée",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
     "page_bulk_export_explanation": "Active une fonctionnalité qui permet à tous les utilisateurs d'exporter simultanément toutes les pages sélectionnées dans le menu des pages et leurs pages subordonnées. Les données exportées seront automatiquement supprimées une fois la période de conservation écoulée.",
@@ -448,10 +448,7 @@
     "customize_settings": "Interface",
     "customize_settings": "Interface",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "Barre latérale",
       "title": "Barre latérale",
-      "desc": "Mode d'affichage et comportement par défaut de la barre latérale.",
-      "dock_mode_default_desc": "État initial de la barre latérale lorsque le mode Dock est sélectionné.",
-      "dock_mode_default_open": "Afficher la page comme si elle était ouverte",
-      "dock_mode_default_close": "Afficher la page comme si elle était fermée"
+      "desc": "Mode d'affichage et comportement par défaut de la barre latérale."
     },
     },
     "layout": "Largeur du contenu",
     "layout": "Largeur du contenu",
     "layout_options": {
     "layout_options": {
@@ -531,7 +528,7 @@
     "page_path": "Chemin de page",
     "page_path": "Chemin de page",
     "beta_warning": "Cette fonctionnalité est en beta.",
     "beta_warning": "Cette fonctionnalité est en beta.",
     "import_from": "Importer depuis {{from}}",
     "import_from": "Importer depuis {{from}}",
-    "import_growi_archive": "Importer une archive GROWI",
+    "import_growi_archive": "Importer les données d'archive",
     "error": {
     "error": {
       "only_upsert_available": "Seul l'option 'Upsert' est disponible pour les collections de pages"
       "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",
     "import": "Importer",
     "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement",
     "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",
     "prepare_new_account_for_migration": "Préparer le compte pour la migration",
     "archive_data_import_detail": "En savoir plus",
     "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_management": {
     "export_archive_data": "Archive de données d'export",
     "export_archive_data": "Archive de données d'export",
@@ -746,7 +731,7 @@
       "description1": "Créer des utilisateurs temporaires avec une adresse courriel.",
       "description1": "Créer des utilisateurs temporaires avec une adresse courriel.",
       "description2": "Un mot de passe temporaire est généré automatiquement.",
       "description2": "Un mot de passe temporaire est généré automatiquement.",
       "invite_thru_email": "Courriel d'invitation",
       "invite_thru_email": "Courriel d'invitation",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
       "valid_email": "Adresse courriel valide requise",
       "valid_email": "Adresse courriel valide requise",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",
       "temporary_password": "Cette utilisateur a un mot de passe temporaire",
       "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",
       "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",
@@ -796,7 +781,11 @@
     "unset": "Non",
     "unset": "Non",
     "related_username": "Utilisateur ",
     "related_username": "Utilisateur ",
     "cannot_invite_maximum_users": "La limite maximale d'utilisateurs invitables est atteinte.",
     "cannot_invite_maximum_users": "La limite maximale d'utilisateurs invitables est atteinte.",
-    "current_users": "Utilisateurs:"
+    "user_statistics": {
+      "total": "Utilisateurs totaux",
+      "active": "Actifs",
+      "inactive": "Inactifs"
+    }
   },
   },
   "user_group_management": {
   "user_group_management": {
     "user_group_management": "Gestion des groupes",
     "user_group_management": "Gestion des groupes",
@@ -1010,12 +999,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Téléverser les données d'archive",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "Téléverser les données d'archive",
     "ADMIN_GROWI_DATA_IMPORTED": "Importer 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_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_CREATE": "Créer données d'archive",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Télécharger les 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",
     "ADMIN_ARCHIVE_DATA_DELETE": "Supprimer les données d'archive",
@@ -1138,4 +1121,4 @@
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "ai_search_management": "Gestion de la recherche par l'IA"
     "ai_search_management": "Gestion de la recherche par l'IA"
   }
   }
-}
+}

+ 18 - 2
apps/app/public/static/locales/fr_FR/translation.json

@@ -780,6 +780,11 @@
       "updatedAt": "Dernière modification"
       "updatedAt": "Dernière modification"
     }
     }
   },
   },
+  "help_dropdown": {
+    "show_shortcuts": "Afficher les raccourcis",
+    "growi_cloud_help": "Aide GROWI.cloud",
+    "growi_version": "Version GROWI"
+  },
   "private_legacy_pages": {
   "private_legacy_pages": {
     "title": "Anciennes pages privées",
     "title": "Anciennes pages privées",
     "bulk_operation": "Opération de masse",
     "bulk_operation": "Opération de masse",
@@ -993,7 +998,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "Favoris",
     "bookmarks": "Favoris",
-    "recently_created": "Page récentes"
+    "recently_created": "Page récentes",
+    "recent_activity": "Activité récente",
+    "unknown_action": "a effectué une modification non spécifiée",
+    "page_create": "a créé une page",
+    "page_update": "a mis à jour une page",
+    "page_delete": "a supprimé une page",
+    "page_delete_completely": "a supprimé complètement une page",
+    "page_rename": "a renommé une page",
+    "page_revert": "a restauré une page",
+    "page_duplicate": "a dupliqué une page",
+    "page_like": "a aimé une page",
+    "comment_create": "a publié un commentaire"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "dossier de favoris",
     "bookmark_folder": "dossier de favoris",
@@ -1053,4 +1069,4 @@
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
     "error-toaster": "Synchronisation échouée"
   }
   }
-}
+}

+ 14 - 31
apps/app/public/static/locales/ja_JP/admin.json

@@ -322,7 +322,7 @@
       "done": "クリップボードにコピーしました!"
       "done": "クリップボードにコピーしました!"
     },
     },
     "bug_report": "バグを報告する",
     "bug_report": "バグを報告する",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>次に GitHub で Issue を投稿してください。</a>"
+    "submit_bug_report": "GitHub で Issue を投稿"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
     "migration_desc": "公開されているページに 古い v4 互換形式のものが存在します。ページツリーや簡単なリネームなどの新機能を利用するには、全てのページを v5 互換形式に変換してください。",
@@ -348,7 +348,7 @@
     "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
     "supplymentary_message_to_start": "API についても管理者用 API しか機能しなくなります。",
     "start_maintenance_mode": "メンテナンスモードを開始する",
     "start_maintenance_mode": "メンテナンスモードを開始する",
     "end_maintenance_mode": "メンテナンスモードを終了する",
     "end_maintenance_mode": "メンテナンスモードを終了する",
-    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「セキュリティ設定」>「メンテナンスモード」から操作してください。"
+    "description": "メンテナンスモードでは、ユーザーのあらゆる操作を制限します。「データのインポート」および「V5 へのアップグレード」の際には必ずメンテナンスモードを開始してから行ってください。終了するには「アプリ設定」>「メンテナンスモード」から操作してください。"
   },
   },
   "app_setting": {
   "app_setting": {
     "site_name": "サイト名",
     "site_name": "サイト名",
@@ -365,9 +365,9 @@
     "confidential_example": "例: 社外秘",
     "confidential_example": "例: 社外秘",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_language": "新規ユーザーのデフォルト設定言語",
     "default_mail_visibility": "新規ユーザーの初期メール公開設定",
     "default_mail_visibility": "新規ユーザーの初期メール公開設定",
+    "default_read_only_for_new_user": "新規ユーザーの編集制限",
+    "set_read_only_for_new_user": "新規ユーザーを閲覧専用にする",
     "file_uploading": "ファイルアップロード",
     "file_uploading": "ファイルアップロード",
-    "enable_files_except_image": "画像以外のファイルアップロードを許可",
-    "attach_enable": "許可をしている場合、画像以外のファイルをページに添付可能になります。",
     "page_bulk_export_settings": "ページ一括エクスポート設定",
     "page_bulk_export_settings": "ページ一括エクスポート設定",
     "enable_page_bulk_export": "一括エクスポートを有効にする",
     "enable_page_bulk_export": "一括エクスポートを有効にする",
     "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
     "page_bulk_export_explanation": "すべてのユーザーが、ページメニューから選択したページとその配下ページをまとめてエクスポートできる機能を有効化します。エクスポートされたデータは保存期間経過後に自動的に削除されます。",
@@ -457,10 +457,7 @@
     "customize_settings": "カスタマイズ",
     "customize_settings": "カスタマイズ",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "デフォルトのサイドバーモード",
       "title": "デフォルトのサイドバーモード",
-      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。",
-      "dock_mode_default_desc": "Dock Mode選択時のサイドバーの初期状態を設定できます。",
-      "dock_mode_default_open": "初めから開いた状態でページを開く",
-      "dock_mode_default_close": "初めから閉じた状態でページを開く"
+      "desc": "新規ユーザー、ページを訪れたゲストのサイドバーモードを設定できます。"
     },
     },
     "layout": "レイアウト",
     "layout": "レイアウト",
     "layout_options": {
     "layout_options": {
@@ -540,7 +537,7 @@
     "page_path": "ページパス",
     "page_path": "ページパス",
     "beta_warning": "この機能はベータ版です",
     "beta_warning": "この機能はベータ版です",
     "import_from": "{{from}} からインポート",
     "import_from": "{{from}} からインポート",
-    "import_growi_archive": "GROWI アーカイブをインポート",
+    "import_growi_archive": "データインポート",
     "error": {
     "error": {
       "only_upsert_available": "pages コレクションには 'Upsert' オプションのみ対応しています"
       "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": "インポート",
     "import": "インポート",
     "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。",
     "skip_username_and_email_when_overlapped": "ユーザー名またはメールアドレスが同じ場合、その部分がスキップされます。",
     "prepare_new_account_for_migration": "移行用のアカウントを新環境で用意してください。",
     "prepare_new_account_for_migration": "移行用のアカウントを新環境で用意してください。",
     "archive_data_import_detail": "参考: GROWI Docs - データのインポート",
     "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_management": {
     "export_archive_data": "データアーカイブ",
     "export_archive_data": "データアーカイブ",
@@ -755,7 +740,7 @@
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
       "description1": "メールアドレスを使用して新規ユーザーを仮発行します。",
       "description2": "初回のログイン時に使用する仮パスワードが生成されます。",
       "description2": "初回のログイン時に使用する仮パスワードが生成されます。",
       "invite_thru_email": "招待メールを送信する",
       "invite_thru_email": "招待メールを送信する",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>メールの設定</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>メールの設定</a>",
       "valid_email": "メールアドレスを入力してください。",
       "valid_email": "メールアドレスを入力してください。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "temporary_password": "作成したユーザーは仮パスワードが設定されています。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
       "send_new_password": "新規発行したパスワードを、対象ユーザーへ連絡してください。",
@@ -805,7 +790,11 @@
     "unset": "未設定",
     "unset": "未設定",
     "related_username": "関連付けられているユーザーの ",
     "related_username": "関連付けられているユーザーの ",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
     "cannot_invite_maximum_users": "ユーザーが上限に達したため招待できません。",
-    "current_users": "現在のユーザー数:"
+    "user_statistics": {
+      "total": "総ユーザー数",
+      "active": "アクティブ",
+      "inactive": "非アクティブ"
+    }
   },
   },
   "user_group_management": {
   "user_group_management": {
     "user_group_management": "グループ管理",
     "user_group_management": "グループ管理",
@@ -1020,12 +1009,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "アーカイブデータのアップロード",
     "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート",
     "ADMIN_GROWI_DATA_IMPORTED": "アーカイブデータのインポート",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "アーカイブデータの破棄",
     "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_CREATE": "アーカイブデータの作成",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "アーカイブデータのダウンロード",
     "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除",
     "ADMIN_ARCHIVE_DATA_DELETE": "アーカイブデータの削除",
@@ -1148,4 +1131,4 @@
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
     "ai_search_management": "AI 検索管理"
     "ai_search_management": "AI 検索管理"
   }
   }
-}
+}

+ 18 - 2
apps/app/public/static/locales/ja_JP/translation.json

@@ -819,6 +819,11 @@
       "updatedAt": "更新日時"
       "updatedAt": "更新日時"
     }
     }
   },
   },
+  "help_dropdown": {
+    "show_shortcuts": "ショートカットを表示",
+    "growi_cloud_help": "GROWI.cloud ヘルプ",
+    "growi_version": "GROWI バージョン"
+  },
   "private_legacy_pages": {
   "private_legacy_pages": {
     "title": "旧形式のプライベートページ",
     "title": "旧形式のプライベートページ",
     "bulk_operation": "一括操作",
     "bulk_operation": "一括操作",
@@ -1032,7 +1037,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "ブックマーク",
     "bookmarks": "ブックマーク",
-    "recently_created": "最近作成したページ"
+    "recently_created": "最近作成したページ",
+    "recent_activity": "最近のアクティビティ",
+    "unknown_action": "未指定の変更を加えました",
+    "page_create": "ページを作成しました",
+    "page_update": "ページを更新しました",
+    "page_delete": "ページを削除しました",
+    "page_delete_completely": "ページを完全に削除しました",
+    "page_rename": "ページの名前を変更しました",
+    "page_revert": "ページを元に戻しました",
+    "page_duplicate": "ページを複製しました",
+    "page_like": "ページをいいねしました",
+    "comment_create": "コメントを投稿しました"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "ブックマークフォルダ",
     "bookmark_folder": "ブックマークフォルダ",
@@ -1095,4 +1111,4 @@
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
     "error-toaster": "最新の本文の同期に失敗しました"
   }
   }
-}
+}

+ 15 - 32
apps/app/public/static/locales/ko_KR/admin.json

@@ -298,7 +298,7 @@
   },
   },
   "mailer_setup_required": "<a href='/admin/app'>이메일 설정</a>이 전송에 필요합니다.",
   "mailer_setup_required": "<a href='/admin/app'>이메일 설정</a>이 전송에 필요합니다.",
   "admin_top": {
   "admin_top": {
-    "management_wiki": "관리 위키",
+    "management_wiki": "위키 관리",
     "system_information": "시스템 정보",
     "system_information": "시스템 정보",
     "wiki_administrator": "위키 관리자만 이 페이지에 접근할 수 있습니다",
     "wiki_administrator": "위키 관리자만 이 페이지에 접근할 수 있습니다",
     "assign_administrator": "사용자 관리 페이지에서 '관리자 권한 부여' 버튼을 사용하여 선택한 사용자에게 위키 관리자 권한을 부여할 수 있습니다.",
     "assign_administrator": "사용자 관리 페이지에서 '관리자 권한 부여' 버튼을 사용하여 선택한 사용자에게 위키 관리자 권한을 부여할 수 있습니다.",
@@ -313,7 +313,7 @@
       "done": "클립보드에 복사되었습니다!"
       "done": "클립보드에 복사되었습니다!"
     },
     },
     "bug_report": "버그 보고서 제출",
     "bug_report": "버그 보고서 제출",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>그런 다음 GitHub에 문제를 제출하십시오.</a>"
+    "submit_bug_report": "GitHub 에 이슈를 제출하세요."
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "일부 페이지는 이전 v4 호환성을 가지고 있습니다. 페이지 트리 및 쉬운 이름 변경과 같은 새로운 기능을 활용하려면 모든 페이지를 v5 호환성으로 변환하십시오.",
     "migration_desc": "일부 페이지는 이전 v4 호환성을 가지고 있습니다. 페이지 트리 및 쉬운 이름 변경과 같은 새로운 기능을 활용하려면 모든 페이지를 v5 호환성으로 변환하십시오.",
@@ -339,7 +339,7 @@
     "supplymentary_message_to_start": "API의 경우 관리자 API만 작동합니다.",
     "supplymentary_message_to_start": "API의 경우 관리자 API만 작동합니다.",
     "start_maintenance_mode": "유지 보수 모드 시작",
     "start_maintenance_mode": "유지 보수 모드 시작",
     "end_maintenance_mode": "유지 보수 모드 종료",
     "end_maintenance_mode": "유지 보수 모드 종료",
-    "description": "유지 보수 모드는 모든 사용자 작업을 제한합니다. 데이터 가져오기 및 V5로 업그레이드 전에 항상 유지 보수 모드를 시작하십시오. 종료하려면 보안 설정 > 유지 보수 모드로 이동하십시오."
+    "description": "유지 보수 모드는 모든 사용자 작업을 제한합니다. 데이터 가져오기 및 V5로 업그레이드 전에 항상 유지 보수 모드를 시작하십시오. 종료하려면 보안 설정 > 유지 보수 모드로 이동하십시오.종료하려면 ‘앱 설정’ > '유지보수 모드'에서 조작하십시오."
   },
   },
   "app_setting": {
   "app_setting": {
     "site_name": "사이트 이름",
     "site_name": "사이트 이름",
@@ -356,9 +356,9 @@
     "confidential_example": "예): 내부 전용",
     "confidential_example": "예): 내부 전용",
     "default_language": "새 사용자를 위한 기본 언어",
     "default_language": "새 사용자를 위한 기본 언어",
     "default_mail_visibility": "새 사용자를 위한 이메일 공개",
     "default_mail_visibility": "새 사용자를 위한 이메일 공개",
+    "default_read_only_for_new_user": "신규 사용자의 편집 제한",
+    "set_read_only_for_new_user": "신규 사용자를 열람 전용으로 설정",
     "file_uploading": "파일 업로드",
     "file_uploading": "파일 업로드",
-    "enable_files_except_image": "이 옵션을 활성화하면 모든 파일 형식을 업로드할 수 있습니다. 이 옵션이 없으면 이미지 파일 업로드만 지원됩니다.",
-    "attach_enable": "이 옵션을 활성화하면 이미지 파일 외의 파일을 첨부할 수 있습니다.",
     "page_bulk_export_settings": "페이지 대량 내보내기 설정",
     "page_bulk_export_settings": "페이지 대량 내보내기 설정",
     "enable_page_bulk_export": "대량 내보내기 활성화",
     "enable_page_bulk_export": "대량 내보내기 활성화",
     "page_bulk_export_explanation": "모든 사용자가 메뉴에서 한 번에 페이지와 모든 하위 페이지를 내보낼 수 있는 기능을 활성화합니다. 내보낸 데이터는 저장 기간이 지나면 자동으로 삭제됩니다.",
     "page_bulk_export_explanation": "모든 사용자가 메뉴에서 한 번에 페이지와 모든 하위 페이지를 내보낼 수 있는 기능을 활성화합니다. 내보낸 데이터는 저장 기간이 지나면 자동으로 삭제됩니다.",
@@ -448,10 +448,7 @@
     "customize_settings": "사용자 지정",
     "customize_settings": "사용자 지정",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "기본 사이드바 모드",
       "title": "기본 사이드바 모드",
-      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다.",
-      "dock_mode_default_desc": "독 모드가 선택되었을 때 사이드바의 초기 상태를 설정할 수 있습니다.",
-      "dock_mode_default_open": "처음부터 열린 상태로 페이지 열기",
-      "dock_mode_default_close": "처음부터 닫힌 상태로 페이지 열기"
+      "desc": "새 사용자 및 페이지를 방문하는 게스트를 위한 사이드바 모드를 설정할 수 있습니다."
     },
     },
     "layout": "레이아웃",
     "layout": "레이아웃",
     "layout_options": {
     "layout_options": {
@@ -531,7 +528,7 @@
     "page_path": "페이지 경로",
     "page_path": "페이지 경로",
     "beta_warning": "이 기능은 베타입니다.",
     "beta_warning": "이 기능은 베타입니다.",
     "import_from": "{{from}}에서 가져오기",
     "import_from": "{{from}}에서 가져오기",
-    "import_growi_archive": "GROWI 아카이브 가져오기",
+    "import_growi_archive": "아카이브 데이터 가져오기",
     "error": {
     "error": {
       "only_upsert_available": "페이지 컬렉션에는 'Upsert' 옵션만 사용할 수 있습니다."
       "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": "가져오기",
     "import": "가져오기",
     "skip_username_and_email_when_overlapped": "새 환경에서 동일한 사용자 이름과 이메일을 사용하는 경우 사용자 이름과 이메일 건너뛰기",
     "skip_username_and_email_when_overlapped": "새 환경에서 동일한 사용자 이름과 이메일을 사용하는 경우 사용자 이름과 이메일 건너뛰기",
     "prepare_new_account_for_migration": "마이그레이션을 위한 새 계정 준비",
     "prepare_new_account_for_migration": "마이그레이션을 위한 새 계정 준비",
     "archive_data_import_detail": "자세한 내용은 여기를 클릭하십시오.",
     "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_management": {
     "export_archive_data": "아카이브 데이터 내보내기",
     "export_archive_data": "아카이브 데이터 내보내기",
@@ -746,7 +731,7 @@
       "description1": "이메일 주소로 새 사용자를 임시 발급합니다.",
       "description1": "이메일 주소로 새 사용자를 임시 발급합니다.",
       "description2": "첫 로그인 시 임시 비밀번호가 생성됩니다.",
       "description2": "첫 로그인 시 임시 비밀번호가 생성됩니다.",
       "invite_thru_email": "초대 이메일 전송",
       "invite_thru_email": "초대 이메일 전송",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>이메일 설정</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>이메일 설정</a>",
       "valid_email": "유효한 이메일 주소가 필요합니다.",
       "valid_email": "유효한 이메일 주소가 필요합니다.",
       "temporary_password": "생성된 사용자에게는 임시 비밀번호가 있습니다.",
       "temporary_password": "생성된 사용자에게는 임시 비밀번호가 있습니다.",
       "send_new_password": "새 비밀번호를 사용자에게 보내주십시오.",
       "send_new_password": "새 비밀번호를 사용자에게 보내주십시오.",
@@ -796,7 +781,11 @@
     "unset": "아니요",
     "unset": "아니요",
     "related_username": "관련 사용자 ",
     "related_username": "관련 사용자 ",
     "cannot_invite_maximum_users": "최대 사용자 수 이상을 초대할 수 없습니다.",
     "cannot_invite_maximum_users": "최대 사용자 수 이상을 초대할 수 없습니다.",
-    "current_users": "현재 사용자:"
+    "user_statistics": {
+      "total": "총 사용자",
+      "active": "활성",
+      "inactive": "비활성"
+    }
   },
   },
   "user_group_management": {
   "user_group_management": {
     "user_group_management": "사용자 그룹 관리",
     "user_group_management": "사용자 그룹 관리",
@@ -1011,12 +1000,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "아카이브 데이터 업로드",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "아카이브 데이터 업로드",
     "ADMIN_GROWI_DATA_IMPORTED": "아카이브 데이터 가져오기",
     "ADMIN_GROWI_DATA_IMPORTED": "아카이브 데이터 가져오기",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "업로드된 GROWI 데이터 버리기",
     "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_CREATE": "아카이브 데이터 생성",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "아카이브 데이터 다운로드",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "아카이브 데이터 다운로드",
     "ADMIN_ARCHIVE_DATA_DELETE": "아카이브 데이터 삭제",
     "ADMIN_ARCHIVE_DATA_DELETE": "아카이브 데이터 삭제",
@@ -1139,4 +1122,4 @@
     "disable_mode_explanation": "현재 AI 통합이 비활성화되어 있습니다. 활성화하려면 <code>AI_ENABLED</code> 환경 변수와 필요한 추가 변수를 구성하십시오.<br><br>자세한 내용은 <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>문서</a>를 참조하십시오.",
     "disable_mode_explanation": "현재 AI 통합이 비활성화되어 있습니다. 활성화하려면 <code>AI_ENABLED</code> 환경 변수와 필요한 추가 변수를 구성하십시오.<br><br>자세한 내용은 <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>문서</a>를 참조하십시오.",
     "ai_search_management": "AI 검색 관리"
     "ai_search_management": "AI 검색 관리"
   }
   }
-}
+}

+ 18 - 2
apps/app/public/static/locales/ko_KR/translation.json

@@ -746,6 +746,11 @@
       "updatedAt": "마지막 업데이트일"
       "updatedAt": "마지막 업데이트일"
     }
     }
   },
   },
+  "help_dropdown": {
+    "show_shortcuts": "단축키 표시",
+    "growi_cloud_help": "GROWI.cloud 도움말",
+    "growi_version": "GROWI 버전"
+  },
   "private_legacy_pages": {
   "private_legacy_pages": {
     "title": "비공개 레거시 페이지",
     "title": "비공개 레거시 페이지",
     "bulk_operation": "대량 작업",
     "bulk_operation": "대량 작업",
@@ -959,7 +964,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "북마크",
     "bookmarks": "북마크",
-    "recently_created": "최근 생성됨"
+    "recently_created": "최근 생성됨",
+    "recent_activity": "최근 활동",
+    "unknown_action": "지정되지 않은 변경 사항을 적용했습니다",
+    "page_create": "페이지를 생성했습니다",
+    "page_update": "페이지를 업데이트했습니다",
+    "page_delete": "페이지를 삭제했습니다",
+    "page_delete_completely": "페이지를 완전히 삭제했습니다",
+    "page_rename": "페이지 이름을 변경했습니다",
+    "page_revert": "페이지를 되돌렸습니다",
+    "page_duplicate": "페이지를 복제했습니다",
+    "page_like": "페이지에 좋아요를 눌렀습니다",
+    "comment_create": "댓글을 게시했습니다"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "북마크 폴더",
     "bookmark_folder": "북마크 폴더",
@@ -1022,4 +1038,4 @@
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "error-toaster": "최신 텍스트 동기화 실패"
     "error-toaster": "최신 텍스트 동기화 실패"
   }
   }
-}
+}

+ 14 - 31
apps/app/public/static/locales/zh_CN/admin.json

@@ -322,7 +322,7 @@
       "done": "复制到剪贴板!"
       "done": "复制到剪贴板!"
     },
     },
     "bug_report": "提交一个错误报告",
     "bug_report": "提交一个错误报告",
-    "submit_bug_report": "<a href='https://github.com/growilabs/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>然后提交你的问题到GitHub。</a>"
+    "submit_bug_report": "将您的问题提交到 GitHub。"
   },
   },
   "v5_page_migration": {
   "v5_page_migration": {
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
     "migration_desc": "有一些页面具有旧的v4兼容性。为了利用新的功能,如页面树和容易重命名,请将您的所有页面转换为v5兼容性。",
@@ -348,7 +348,7 @@
     "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
     "supplymentary_message_to_start": "至于API,只有管理员的API将是有效的。",
     "start_maintenance_mode": "启动维护模式",
     "start_maintenance_mode": "启动维护模式",
     "end_maintenance_mode": "结束维护模式",
     "end_maintenance_mode": "结束维护模式",
-    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"安全设置\">\"维护模式\"。"
+    "description": "维护模式限制了所有的用户操作。 在执行 \"数据导入 \"和 \"升级到V5 \"之前,务必启动维护模式。 要退出,进入 \"系统设置\">\"维护模式\"。"
   },
   },
   "app_setting": {
   "app_setting": {
     "site_name": "网站名称 ",
     "site_name": "网站名称 ",
@@ -365,9 +365,9 @@
     "confidential_example": "ex):仅供内部使用",
     "confidential_example": "ex):仅供内部使用",
     "default_language": "新用户的默认语言",
     "default_language": "新用户的默认语言",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
     "default_mail_visibility": "新用户的默认电子邮件可见性",
+    "default_read_only_for_new_user": "新用戶編輯限制",
+    "set_read_only_for_new_user": "將新用戶設為僅限瀏覽",
     "file_uploading": "文件上传",
     "file_uploading": "文件上传",
-    "enable_files_except_image": "启用此选项将允许上传任何文件类型。如果没有此选项,则仅支持图像文件上载。",
-    "attach_enable": "如果启用此选项,则可以附加图像文件以外的文件。",
     "page_bulk_export_settings": "页面批量导出设置",
     "page_bulk_export_settings": "页面批量导出设置",
     "enable_page_bulk_export": "启用批量导出",
     "enable_page_bulk_export": "启用批量导出",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
     "page_bulk_export_explanation": "启用一项功能,允许所有用户一次性导出从页面菜单中选择的所有页面及其下级页面。保留期限过后,导出的数据将自动删除。",
@@ -457,10 +457,7 @@
     "customize_settings": "页面定制",
     "customize_settings": "页面定制",
     "default_sidebar_mode": {
     "default_sidebar_mode": {
       "title": "默认的侧边栏模式",
       "title": "默认的侧边栏模式",
-      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。",
-      "dock_mode_default_desc": "当选择Dock模式时,可以设置侧边栏的初始状态。",
-      "dock_mode_default_open": "从头开始翻页",
-      "dock_mode_default_close": "从头开始打开关闭的页面"
+      "desc": "你可以为新用户和访问该网页的客人设置侧边栏模式。"
     },
     },
     "layout": "布局",
     "layout": "布局",
     "layout_options": {
     "layout_options": {
@@ -540,7 +537,7 @@
     "page_path": "相对路径",
     "page_path": "相对路径",
     "beta_warning": "这个函数是Beta。",
     "beta_warning": "这个函数是Beta。",
     "import_from": "Import from {{from}}",
     "import_from": "Import from {{from}}",
-    "import_growi_archive": "Import GROWI archive",
+    "import_archive_data": "导入存档数据",
     "error": {
     "error": {
       "only_upsert_available": "Only 'Upsert' option is available for pages collection."
       "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",
     "import": "Import",
     "skip_username_and_email_when_overlapped": "Skip username and email using same username and email in new environment",
     "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",
     "prepare_new_account_for_migration": "Prepare new account for migration",
     "archive_data_import_detail": "More details? Click here.",
     "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_management": {
     "export_archive_data": "导出主题数据",
     "export_archive_data": "导出主题数据",
@@ -754,7 +739,7 @@
       "emails": "电子邮件",
       "emails": "电子邮件",
       "description1": "通过电子邮件地址临时发布新用户。",
       "description1": "通过电子邮件地址临时发布新用户。",
       "description2": "将为首次登录生成一个临时密码。",
       "description2": "将为首次登录生成一个临时密码。",
-      "mail_setting_link": "<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
+      "mail_setting_link": "<span class='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Email settings</a>",
       "valid_email": "需要有效的电子邮件地址",
       "valid_email": "需要有效的电子邮件地址",
       "invite_thru_email": "发送邀请电子邮件",
       "invite_thru_email": "发送邀请电子邮件",
       "temporary_password": "创建的用户具有临时密码",
       "temporary_password": "创建的用户具有临时密码",
@@ -805,7 +790,11 @@
     "unset": "否",
     "unset": "否",
     "related_username": "相关用户的",
     "related_username": "相关用户的",
     "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
     "cannot_invite_maximum_users": "邀请的用户数不能超过最大值。",
-    "current_users": "当前用户:"
+    "user_statistics": {
+      "total": "用户总数",
+      "active": "活跃",
+      "inactive": "非活跃"
+    }
   },
   },
   "user_group_management": {
   "user_group_management": {
     "user_group_management": "用户组管理",
     "user_group_management": "用户组管理",
@@ -1020,12 +1009,6 @@
     "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",
     "ADMIN_ARCHIVE_DATA_UPLOAD": "上传存档数据",
     "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据",
     "ADMIN_GROWI_DATA_IMPORTED": "导入存档数据",
     "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "丢弃存档数据",
     "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_CREATE": "创建归档数据",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据",
     "ADMIN_ARCHIVE_DATA_DOWNLOAD": "下载存档数据",
     "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据",
     "ADMIN_ARCHIVE_DATA_DELETE": "删除存档数据",
@@ -1148,4 +1131,4 @@
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
     "ai_search_management": "AI 搜索管理"
     "ai_search_management": "AI 搜索管理"
   }
   }
-}
+}

+ 18 - 2
apps/app/public/static/locales/zh_CN/translation.json

@@ -791,6 +791,11 @@
       "updatedAt": "按更新日期排序"
       "updatedAt": "按更新日期排序"
     }
     }
   },
   },
+  "help_dropdown": {
+    "show_shortcuts": "显示快捷键",
+    "growi_cloud_help": "GROWI.cloud 帮助",
+    "growi_version": "GROWI 版本"
+  },
   "private_legacy_pages": {
   "private_legacy_pages": {
     "title": "私人遗留页面",
     "title": "私人遗留页面",
     "bulk_operation": "批量操作",
     "bulk_operation": "批量操作",
@@ -1004,7 +1009,18 @@
   },
   },
   "user_home_page": {
   "user_home_page": {
     "bookmarks": "书签",
     "bookmarks": "书签",
-    "recently_created": "最近创建页面"
+    "recently_created": "最近创建页面",
+    "recent_activity": "最近动态",
+    "unknown_action": "进行了未指明的更改",
+    "page_create": "创建了页面",
+    "page_update": "更新了页面",
+    "page_delete": "删除了页面",
+    "page_delete_completely": "彻底删除了页面",
+    "page_rename": "重命名了页面",
+    "page_revert": "还原了页面",
+    "page_duplicate": "复制了页面",
+    "page_like": "赞了页面",
+    "comment_create": "发布了评论"
   },
   },
   "bookmark_folder": {
   "bookmark_folder": {
     "bookmark_folder": "书签文件夹",
     "bookmark_folder": "书签文件夹",
@@ -1067,4 +1083,4 @@
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "error-toaster": "同步最新文本失败"
     "error-toaster": "同步最新文本失败"
   }
   }
-}
+}

+ 7 - 0
apps/app/resource/Contributor.js

@@ -78,6 +78,13 @@ const contributors = [
           { name: 'shironegi39' },
           { name: 'shironegi39' },
           { name: 'ryo-h15' },
           { name: 'ryo-h15' },
           { name: 'jam411' },
           { name: 'jam411' },
+          { name: 'Naoki427' },
+          { name: 'yusa-bot' },
+          { name: 'arvid-e' },
+          { name: 'riona-k' },
+          { name: 'hiroki-hgs' },
+          { name: 'taikou-m' },
+          { name: 'hikaru-n-cpu' },
         ],
         ],
       },
       },
     ],
     ],

+ 0 - 5
apps/app/src/client/components/.eslintrc.js

@@ -1,5 +0,0 @@
-module.exports = {
-  extends: '../../../.eslintrc.js',
-  rules: {
-  },
-};

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