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

Merge pull request #8661 from weseek/master

Release v7.0.2
Yuki Takei 2 лет назад
Родитель
Сommit
566ca4090c
100 измененных файлов с 4526 добавлено и 739 удалено
  1. 2 2
      .github/workflows/ci-app-prod.yml
  2. 16 16
      .github/workflows/ci-app.yml
  3. 15 15
      .github/workflows/ci-slackbot-proxy.yml
  4. 1 1
      .github/workflows/codeql-analysis.yml
  5. 2 2
      .github/workflows/draft-release.yml
  6. 2 2
      .github/workflows/list-unhealthy-branches.yml
  7. 1 1
      .github/workflows/release-rc-scheduled.yml
  8. 1 1
      .github/workflows/release-rc.yml
  9. 3 3
      .github/workflows/release-slackbot-proxy.yml
  10. 6 6
      .github/workflows/release.yml
  11. 1 1
      .github/workflows/reusable-app-build-image.yml
  12. 19 19
      .github/workflows/reusable-app-prod.yml
  13. 7 6
      .github/workflows/reusable-app-reg-suit.yml
  14. 710 0
      apps/app/_obsolete/src/styles/theme/apply-colors.scss
  15. 4 3
      apps/app/package.json
  16. 4 2
      apps/app/public/static/locales/en_US/commons.json
  17. 18 3
      apps/app/public/static/locales/en_US/translation.json
  18. 1135 0
      apps/app/public/static/locales/fr_FR/admin.json
  19. 161 0
      apps/app/public/static/locales/fr_FR/commons.json
  20. 867 0
      apps/app/public/static/locales/fr_FR/translation.json
  21. 4 2
      apps/app/public/static/locales/ja_JP/commons.json
  22. 19 4
      apps/app/public/static/locales/ja_JP/translation.json
  23. 4 2
      apps/app/public/static/locales/zh_CN/commons.json
  24. 18 3
      apps/app/public/static/locales/zh_CN/translation.json
  25. 14 0
      apps/app/resource/locales/fr_FR/admin/userInvitation.ejs
  26. 11 0
      apps/app/resource/locales/fr_FR/admin/userResetPassword.ejs
  27. 20 0
      apps/app/resource/locales/fr_FR/admin/userWaitingActivation.ejs
  28. 9 0
      apps/app/resource/locales/fr_FR/notifications/comment.ejs
  29. 13 0
      apps/app/resource/locales/fr_FR/notifications/notActiveUser.ejs
  30. 5 0
      apps/app/resource/locales/fr_FR/notifications/pageCreate.ejs
  31. 5 0
      apps/app/resource/locales/fr_FR/notifications/pageDelete.ejs
  32. 5 0
      apps/app/resource/locales/fr_FR/notifications/pageEdit.ejs
  33. 5 0
      apps/app/resource/locales/fr_FR/notifications/pageLike.ejs
  34. 5 0
      apps/app/resource/locales/fr_FR/notifications/pageMove.ejs
  35. 12 0
      apps/app/resource/locales/fr_FR/notifications/passwordReset.ejs
  36. 8 0
      apps/app/resource/locales/fr_FR/notifications/passwordResetSuccessful.ejs
  37. 12 0
      apps/app/resource/locales/fr_FR/notifications/userActivation.ejs
  38. 169 0
      apps/app/resource/locales/fr_FR/sandbox-bootstrap5.md
  39. 7 0
      apps/app/resource/locales/fr_FR/sandbox-diagrams.md
  40. 71 0
      apps/app/resource/locales/fr_FR/sandbox-math.md
  41. 158 0
      apps/app/resource/locales/fr_FR/sandbox.md
  42. 48 0
      apps/app/resource/locales/fr_FR/welcome.md
  43. 7 0
      apps/app/src/client/services/create-page/create-page.ts
  44. 1 0
      apps/app/src/client/services/create-page/index.ts
  45. 3 1
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  46. 10 10
      apps/app/src/client/services/page-operation.ts
  47. 1 0
      apps/app/src/client/services/upload-attachments/index.ts
  48. 39 0
      apps/app/src/client/services/upload-attachments/upload-attachments.ts
  49. 1 1
      apps/app/src/client/util/apiv3-client.ts
  50. 8 12
      apps/app/src/client/util/bookmark-utils.ts
  51. 2 0
      apps/app/src/client/util/locale-utils.ts
  52. 2 1
      apps/app/src/client/util/toastr.ts
  53. 4 2
      apps/app/src/components/AuthorInfo/AuthorInfo.tsx
  54. 31 29
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  55. 1 1
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  56. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss
  57. 2 2
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  58. 3 3
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  59. 8 9
      apps/app/src/components/Comments.tsx
  60. 1 1
      apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx
  61. 1 9
      apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss
  62. 62 15
      apps/app/src/components/Common/PagePathNav/PagePathNav.tsx
  63. 2 2
      apps/app/src/components/ContentLinkButtons.tsx
  64. 3 1
      apps/app/src/components/FontFamily/use-growi-custom-icons.tsx
  65. 1 1
      apps/app/src/components/FontFamily/use-lato.tsx
  66. 2 1
      apps/app/src/components/FontFamily/use-material-symbols-outlined.tsx
  67. 1 1
      apps/app/src/components/FontFamily/use-source-han-code-jp.tsx
  68. 2 2
      apps/app/src/components/Icons/FolderIcon.tsx
  69. 14 0
      apps/app/src/components/Layout/RawLayout.module.scss
  70. 8 3
      apps/app/src/components/Layout/RawLayout.tsx
  71. 8 6
      apps/app/src/components/LoginForm/LoginForm.tsx
  72. 9 0
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.module.scss
  73. 68 46
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  74. 8 2
      apps/app/src/components/Navbar/GrowiNavbarBottom.tsx
  75. 3 3
      apps/app/src/components/Page/DisplaySwitcher.tsx
  76. 15 6
      apps/app/src/components/PageAlert/FixPageGrantAlert.tsx
  77. 29 2
      apps/app/src/components/PageComment.module.scss
  78. 18 15
      apps/app/src/components/PageComment.tsx
  79. 32 23
      apps/app/src/components/PageComment/Comment.module.scss
  80. 16 20
      apps/app/src/components/PageComment/Comment.tsx
  81. 2 2
      apps/app/src/components/PageComment/CommentControl.tsx
  82. 21 20
      apps/app/src/components/PageComment/CommentEditor.module.scss
  83. 184 247
      apps/app/src/components/PageComment/CommentEditor.tsx
  84. 0 7
      apps/app/src/components/PageComment/CommentPreview.module.scss
  85. 3 1
      apps/app/src/components/PageComment/CommentPreview.tsx
  86. 9 5
      apps/app/src/components/PageComment/DeleteCommentModal.tsx
  87. 0 6
      apps/app/src/components/PageComment/ReplyComments.module.scss
  88. 1 1
      apps/app/src/components/PageComment/ReplyComments.tsx
  89. 46 0
      apps/app/src/components/PageComment/SwitchingButtonGroup.module.scss
  90. 73 0
      apps/app/src/components/PageComment/SwitchingButtonGroup.tsx
  91. 19 8
      apps/app/src/components/PageComment/_comment-inheritance.scss
  92. 68 55
      apps/app/src/components/PageControls/PageControls.tsx
  93. 1 1
      apps/app/src/components/PageControls/SearchButton.tsx
  94. 1 1
      apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  95. 3 3
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  96. 43 43
      apps/app/src/components/PageEditor/PageEditor.tsx
  97. 1 0
      apps/app/src/components/PageEditor/Preview.tsx
  98. 22 8
      apps/app/src/components/PageEditor/ScrollSyncHelper.tsx
  99. 1 1
      apps/app/src/components/PageEditor/_page-editor-inheritance.scss
  100. 1 2
      apps/app/src/components/PageHeader/PageHeader.tsx

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

@@ -62,7 +62,7 @@ jobs:
     with:
       node-version: 20.x
       skip-cypress: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-      cypress-report-artifact-name: Cypress report
+      cypress-report-artifact-name-prefix: cypress-report-
       cypress-config-video: ${{ inputs.cypress-config-video || false }}
     secrets:
       SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
@@ -78,7 +78,7 @@ jobs:
     with:
       node-version: 20.x
       skip-reg-suit: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}
-      cypress-report-artifact-name: Cypress report
+      cypress-report-artifact-name-pattern: cypress-report-*
     secrets:
       REG_NOTIFY_GITHUB_PLUGIN_CLIENTID: ${{ secrets.REG_NOTIFY_GITHUB_PLUGIN_CLIENTID }}
       AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}

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

@@ -30,16 +30,16 @@ jobs:
         node-version: [20.x]
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
-      - uses: actions/setup-node@v3
+      - uses: actions/setup-node@v4
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
           cache-dependency-path: '**/yarn.lock'
 
       - name: Cache/Restore node_modules
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         with:
           path: |
             **/node_modules
@@ -49,7 +49,7 @@ jobs:
             node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Restore dist
-        uses: actions/cache/restore@v3
+        uses: actions/cache/restore@v4
         with:
           path: |
             **/.turbo
@@ -79,7 +79,7 @@ jobs:
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
       - name: Cache dist
-        uses: actions/cache/save@v3
+        uses: actions/cache/save@v4
         with:
           path: |
             **/.turbo
@@ -101,9 +101,9 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
-      - uses: actions/setup-node@v3
+      - uses: actions/setup-node@v4
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -111,7 +111,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         with:
           path: |
             **/node_modules
@@ -121,7 +121,7 @@ jobs:
             node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Restore dist
-        uses: actions/cache/restore@v3
+        uses: actions/cache/restore@v4
         with:
           path: |
             **/.turbo
@@ -143,7 +143,7 @@ jobs:
           MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_test
 
       - name: Upload coverage report as artifact
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
           name: Coverage Report
           path: |
@@ -161,7 +161,7 @@ jobs:
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
       - name: Cache dist
-        uses: actions/cache/save@v3
+        uses: actions/cache/save@v4
         with:
           path: |
             **/.turbo
@@ -183,9 +183,9 @@ jobs:
           - 27017/tcp
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
-      - uses: actions/setup-node@v3
+      - uses: actions/setup-node@v4
         with:
           node-version: ${{ matrix.node-version }}
           cache: 'yarn'
@@ -193,7 +193,7 @@ jobs:
 
       - name: Cache/Restore node_modules
         id: cache-dependencies
-        uses: actions/cache@v3
+        uses: actions/cache@v4
         with:
           path: |
             **/node_modules
@@ -203,7 +203,7 @@ jobs:
             node_modules-7.x-${{ runner.OS }}-node${{ matrix.node-version }}-
 
       - name: Restore dist
-        uses: actions/cache/restore@v3
+        uses: actions/cache/restore@v4
         with:
           path: |
             **/.turbo
@@ -238,7 +238,7 @@ jobs:
           url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
       - name: Cache dist
-        uses: actions/cache/save@v3
+        uses: actions/cache/save@v4
         with:
           path: |
             **/.turbo

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

@@ -32,16 +32,16 @@ jobs:
         node-version: [20.x]
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
     - name: Cache/Restore node_modules
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -51,7 +51,7 @@ jobs:
           node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
     - name: Restore dist
-      uses: actions/cache/restore@v3
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/.turbo
@@ -81,7 +81,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
     - name: Cache dist
-      uses: actions/cache/save@v3
+      uses: actions/cache/save@v4
       with:
         path: |
           **/.turbo
@@ -107,16 +107,16 @@ jobs:
           MYSQL_DATABASE: growi-slackbot-proxy
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
         cache-dependency-path: '**/yarn.lock'
 
     - name: Cache/Restore node_modules
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -126,7 +126,7 @@ jobs:
           node_modules-${{ runner.OS }}-node${{ matrix.node-version }}-
 
     - name: Restore dist
-      uses: actions/cache/restore@v3
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/.turbo
@@ -166,7 +166,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
     - name: Cache dist
-      uses: actions/cache/save@v3
+      uses: actions/cache/save@v4
       with:
         path: |
           **/.turbo
@@ -192,9 +192,9 @@ jobs:
           MYSQL_DATABASE: growi-slackbot-proxy
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ matrix.node-version }}
         cache: 'yarn'
@@ -212,7 +212,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -226,7 +226,7 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Restore dist
-      uses: actions/cache/restore@v3
+      uses: actions/cache/restore@v4
       with:
         path: |
           **/.turbo
@@ -269,7 +269,7 @@ jobs:
         url: ${{ secrets.SLACK_WEBHOOK_URL }}
 
     - name: Cache dist
-      uses: actions/cache/save@v3
+      uses: actions/cache/save@v4
       with:
         path: |
           **/.turbo

+ 1 - 1
.github/workflows/codeql-analysis.yml

@@ -43,7 +43,7 @@ jobs:
 
     steps:
     - name: Checkout repository
-      uses: actions/checkout@v3
+      uses: actions/checkout@v4
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL

+ 2 - 2
.github/workflows/draft-release.yml

@@ -23,7 +23,7 @@ jobs:
       RELEASE_DRAFT_BODY: ${{ steps.release-drafter.outputs.body }}
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
 
       - name: Retrieve information from package.json
         uses: myrotvorets/info-from-package-json-action@1.2.0
@@ -47,7 +47,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
         with:
           fetch-depth: 0
 

+ 2 - 2
.github/workflows/list-unhealthy-branches.yml

@@ -10,11 +10,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         fetch-depth: 0
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: '18'
 

+ 1 - 1
.github/workflows/release-rc-scheduled.yml

@@ -20,7 +20,7 @@ jobs:
       TAGS_GHCR: ${{ steps.meta-ghcr.outputs.tags }}
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0

+ 1 - 1
.github/workflows/release-rc.yml

@@ -20,7 +20,7 @@ jobs:
       TAGS: ${{ steps.meta.outputs.tags }}
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0

+ 3 - 3
.github/workflows/release-slackbot-proxy.yml

@@ -12,7 +12,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
@@ -89,11 +89,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: '18'
         cache: 'yarn'

+ 6 - 6
.github/workflows/release.yml

@@ -18,11 +18,11 @@ jobs:
       RELEASED_VERSION: ${{ steps.package-json.outputs.packageVersion }}
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         ref: ${{ github.event.pull_request.base.ref }}
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: '20'
         cache: 'yarn'
@@ -84,7 +84,7 @@ jobs:
       TAGS: ${{ steps.meta.outputs.tags }}
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
@@ -133,7 +133,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
@@ -158,11 +158,11 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         ref: v${{ needs.create-github-release.outputs.RELEASED_VERSION }}
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: '20'
         cache: 'yarn'

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

@@ -33,7 +33,7 @@ jobs:
         platform: [amd64, arm64]
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
     - name: Configure AWS Credentials
       uses: aws-actions/configure-aws-credentials@v4

+ 19 - 19
.github/workflows/reusable-app-prod.yml

@@ -8,7 +8,7 @@ on:
         type: string
       skip-cypress:
         type: boolean
-      cypress-report-artifact-name:
+      cypress-report-artifact-name-prefix:
         type: string
       cypress-config-video:
         type: boolean
@@ -26,12 +26,12 @@ jobs:
       PROD_FILES: ${{ steps.archive-prod-files.outputs.file }}
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         # retrieve local font files
         lfs: true
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -49,7 +49,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -63,7 +63,7 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Restore dist
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           node_modules/.cache/turbo
@@ -100,15 +100,15 @@ jobs:
         echo "file=production.tar.gz" >> $GITHUB_OUTPUT
 
     - name: Upload production files as artifact
-      uses: actions/upload-artifact@v3
+      uses: actions/upload-artifact@v4
       with:
         name: Production Files (node${{ inputs.node-version }})
         path: ${{ steps.archive-prod-files.outputs.file }}
 
     - name: Upload report as artifact
-      uses: actions/upload-artifact@v3
+      uses: actions/upload-artifact@v4
       with:
-        name: Bundle Analyzing Report
+        name: Bundle Analyzing Report (node${{ inputs.node-version }})
         path: |
           apps/app/.next/analyze/client.html
           apps/app/.next/analyze/server.html
@@ -141,9 +141,9 @@ jobs:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -161,7 +161,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -174,7 +174,7 @@ jobs:
         yarn --production
 
     - name: Download production files artifact
-      uses: actions/download-artifact@v3
+      uses: actions/download-artifact@v4
       with:
         name: Production Files (node${{ inputs.node-version }})
 
@@ -229,12 +229,12 @@ jobs:
           discovery.type: single-node
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
 
     - name: Install fonts
       run: sudo apt install fonts-noto
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -252,7 +252,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -261,7 +261,7 @@ jobs:
           node_modules-app-7.x-build-prod-${{ runner.OS }}-node${{ inputs.node-version }}-
 
     - name: Cache/Restore Cypress files
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           ~/.cache/Cypress
@@ -276,7 +276,7 @@ jobs:
         yarn cypress install
 
     - name: Download production files artifact
-      uses: actions/download-artifact@v3
+      uses: actions/download-artifact@v4
       with:
         name: Production Files (node${{ inputs.node-version }})
 
@@ -323,9 +323,9 @@ jobs:
 
     - name: Upload results
       if: always()
-      uses: actions/upload-artifact@v3
+      uses: actions/upload-artifact@v4
       with:
-        name: ${{ inputs.cypress-report-artifact-name }}
+        name: ${{ inputs.cypress-report-artifact-name-prefix }}${{ matrix.spec-group }}
         path: |
           apps/app/test/cypress/screenshots
           apps/app/test/cypress/videos

+ 7 - 6
.github/workflows/reusable-app-reg-suit.yml

@@ -11,7 +11,7 @@ on:
         default: ${{ github.head_ref }}
       skip-reg-suit:
         type: boolean
-      cypress-report-artifact-name:
+      cypress-report-artifact-name-pattern:
         required: true
         type: string
     secrets:
@@ -49,12 +49,12 @@ jobs:
       EXPECTED_IMAGES_EXIST: ${{ steps.check-expected-images.outputs.EXPECTED_IMAGES_EXIST }}
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
       with:
         ref: ${{ inputs.checkout-ref }}
         fetch-depth: 0
 
-    - uses: actions/setup-node@v3
+    - uses: actions/setup-node@v4
       with:
         node-version: ${{ inputs.node-version }}
         cache: 'yarn'
@@ -72,7 +72,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -86,10 +86,11 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Download screenshots taken by cypress
-      uses: actions/download-artifact@v3
+      uses: actions/download-artifact@v4
       with:
-        name: ${{ inputs.cypress-report-artifact-name }}
         path: apps/app/test/cypress
+        pattern: ${{ inputs.cypress-report-artifact-name-pattern }}
+        merge-multiple: true
 
     - name: Run reg-suit
       working-directory: ./apps/app

+ 710 - 0
apps/app/_obsolete/src/styles/theme/apply-colors.scss

@@ -0,0 +1,710 @@
+@use '@growi/core/scss/bootstrap/init' as *;
+
+@use '../variables' as var;
+@use '../mixins';
+@use '../atoms/mixins/code';
+@use './mixins/hsl-button';
+@use './hsl-functions' as hsl;
+
+@import 'apply-colors-dark';
+@import 'apply-colors-light';
+
+//
+//== Apply to Bootstrap
+//
+
+// determine optional variables
+$bgcolor-search-top-dropdown: var(--bgcolor-search-top-dropdown,var(--secondary));
+$bgcolor-sidebar-nav-item-active: var(--bgcolor-sidebar-nav-item-active,#{hsl.darken(var(--primary),10%)});
+$text-shadow-sidebar-nav-item-active: var(--text-shadow-sidebar-nav-item-active,1px 1px 2px var(--primary));
+$bgcolor-inline-code: var(--bgcolor-inline-code, #{$gray-100});
+$color-inline-code: var(--color-inline-code, #{darken($red, 15%)});
+$bordercolor-inline-code: var(--bordercolor-inline-code, #{$gray-400});
+$bordercolor-nav-tabs: var(--bordercolor-nav-tabs, #{$gray-300});
+$bordercolor-nav-tabs-hover: var(--bordercolor-nav-tabs-hover,#{$gray-200} #{$gray-200} #{$bordercolor-nav-tabs});
+$border-nav-tabs-link-active: var(--border-nav-tabs-link-active, #{$gray-600});
+$bordercolor-nav-tabs-active: var(--bordercolor-nav-tabs-active,$bordercolor-nav-tabs $bordercolor-nav-tabs var(--bgcolor-global));
+$color-btn-reload-in-sidebar: var(--color-btn-reload-in-sidebar,#{$gray-500});
+$bgcolor-keyword-highlighted: var(--bgcolor-keyword-highlighted,#{var.$grw-marker-yellow});
+$color-page-list-group-item-meta: var(--color-page-list-group-item-meta,#{$gray-500});
+$color-search-page-list-title: var(--color-search-page-list-title,var(--color-global));
+
+// override bootstrap variables
+$body-bg: var(--bgcolor-global);
+$body-color: var(--color-global);
+$link-color: var(--color-link);
+$link-hover-color: var(--color-link-hover);
+$input-focus-color: var(--color-global);
+$nav-tabs-border-color: $bordercolor-nav-tabs;
+$nav-tabs-link-hover-border-color: $bordercolor-nav-tabs-hover;
+$nav-tabs-link-active-color: var(--color-nav-tabs-link-active);
+$nav-tabs-link-active-bg: var(--bgcolor-global);
+$nav-tabs-link-active-border-color: $bordercolor-nav-tabs-active;
+$theme-colors: map-merge($theme-colors, ( primary: $primary ));
+
+// TODO: activate (https://redmine.weseek.co.jp/issues/128307)
+// @import 'reboot-bootstrap-buttons';
+// @import 'reboot-bootstrap-colors';
+// @import 'reboot-bootstrap-theme-colors';
+// @import 'hsl-reboot-bootstrap-theme-colors';
+// @import 'reboot-bootstrap-nav';
+// @import 'reboot-toastr-colors';
+
+// determine variables with bootstrap function (These variables can be used after importing bootstrap above)
+$color-modal-header: var(--color-modal-header,#{hsl.contrast(var(--primary))});
+
+code:not([class^='language-']) {
+  @include code.code-inline-color($color-inline-code, $bgcolor-inline-code, $bordercolor-inline-code);
+}
+
+.code-highlighted {
+  border-color: $bordercolor-inline-code;
+}
+
+//
+//== Apply to Bootstrap Elements
+//
+
+// TODO: activate (https://redmine.weseek.co.jp/issues/128307)
+// theme-color-level() dropped in bootstrap v5
+// Alert link
+// @each $color, $value in $theme-colors {
+//   .alert.alert-#{$color} {
+//     a,
+//     a:hover {
+//       color: theme-color-level($color, $alert-color-level - 2);
+//     }
+//   }
+// }
+
+// Dropdown
+.grw-apperance-mode-dropdown {
+  .grw-sidebar-mode-icon svg {
+    fill: var(--secondary);
+  }
+  .grw-color-mode-icon svg {
+    fill: var(--color-global);
+  }
+  .grw-color-mode-icon-muted svg {
+    fill: var(--secondary);
+  }
+}
+
+// TODO: activate (https://redmine.weseek.co.jp/issues/128307)
+// form-control-focus() dropped in bootstrap v5
+// Form
+// .form-control {
+//   @include form-control-focus();
+// }
+
+// Tabs
+.nav.nav-tabs .nav-link.active {
+  color: var(--color-link);
+  background: transparent;
+
+  &:hover,
+  &:focus {
+    color: var(--color-link-hover);
+  }
+}
+
+// Pagination
+ul.pagination {
+  li.page-item.disabled {
+    button.page-link {
+      color: $gray-400;
+    }
+  }
+  li.page-item.active {
+    button.page-link {
+      color: hsl.contrast(var(--primary));
+      background-color: var(--primary);
+      &:hover,
+      &:focus {
+        color: hsl.contrast(var(--primary));
+        background-color: var(--primary);
+      }
+    }
+  }
+  li.page-item {
+    button.page-link {
+      color: var(--primary);
+      border-color: var(--secondary) !important;
+      &:hover,
+      &:active,
+      &:focus {
+        color: var(--primary);
+      }
+    }
+  }
+}
+
+//
+//== Apply to Handsontable
+//
+.handsontable {
+  color: initial;
+}
+
+//
+//== Apply to GROWI Elements
+//
+
+.grw-logo {
+  // set transition for fill
+  svg, svg * {
+    transition: fill 0.8s ease-out;
+  }
+
+  svg {
+    fill: var(--fillcolor-logo-mark);
+  }
+
+  &:hover {
+    svg {
+      .group1 {
+        fill: var.$growi-green;
+      }
+
+      .group2 {
+        fill: var.$growi-blue;
+      }
+    }
+  }
+}
+
+.grw-navbar {
+  background: var(--bgcolor-navbar);
+  .nav-item .nav-link {
+    color: var(--color-link-nabvar);
+  }
+
+  border-image: var(--border-image-navbar) !important;
+  border-image-slice: 1 !important;
+
+  .grw-app-title {
+    color: var(--fillcolor-logo-mark);
+  }
+}
+
+.grw-global-search {
+  .btn-secondary.dropdown-toggle {
+    @include hsl-button.button-variant(var(--bgcolor-search-top-dropdown), var(--bgcolor-search-top-dropdown));
+  }
+
+  // for https://youtrack.weseek.co.jp/issue/GW-2603
+  .search-typeahead {
+    background-color: hsl.alpha(var(--bgcolor-global),10%);
+  }
+  input.form-control {
+    border: none;
+  }
+}
+
+.grw-sidebar {
+  $color-resize-button: var(--color-resize-button,var(--color-global));
+  $bgcolor-resize-button: var(--bgcolor-resize-button,white);
+  $color-resize-button-hover: var(--color-resize-button-hover,var(--color-reversal));
+  $bgcolor-resize-button-hover: var(--bgcolor-resize-button-hover,#{hsl.lighten(var(--bgcolor-resize-button), 5%)});
+  // .grw-navigation-resize-button {
+  //   .hexagon-container svg {
+  //     .background {
+  //       fill: var(--bgcolor-resize-button);
+  //     }
+  //     .icon {
+  //       fill: var(--color-resize-button);
+  //     }
+  //   }
+  //   &:hover .hexagon-container svg {
+  //     .background {
+  //       fill: var(--bgcolor-resize-button-hover);
+  //     }
+  //     .icon {
+  //       fill: var(--color-resize-button-hover);
+  //     }
+  //   }
+  // }
+  div.grw-contextual-navigation {
+    > div {
+      color: var(--color-sidebar-context);
+      background-color: var(--bgcolor-sidebar-context);
+    }
+  }
+
+  .grw-sidebar-nav {
+    .btn {
+      @include hsl-button.button-variant(
+        var(--bgcolor-sidebar),
+        var(--bgcolor-sidebar),
+      );
+    }
+  }
+  .grw-sidebar-nav-primary-container {
+    .btn.active {
+      i {
+        text-shadow: $text-shadow-sidebar-nav-item-active;
+      }
+      // fukidashi
+      &:after {
+        border-right-color: var(--bgcolor-sidebar-context) !important;
+      }
+    }
+  }
+
+  .grw-sidebar-content-header {
+    .grw-btn-reload {
+      color: $color-btn-reload-in-sidebar;
+    }
+
+    .grw-recent-changes-resize-button {
+      .form-check-label::before {
+        background-color: var(--primary);
+      }
+
+      .form-check-label::after {
+        background-color: var(--bgcolor-global);
+      }
+
+      .form-check-input:not(:checked) + .form-check-label::before {
+        color: var(--bgcolor-global);
+      }
+
+      .form-check-input:checked + .form-check-label::before {
+        color: var(--bgcolor-global);
+        background-color: var(--primary);
+        border-color: var(--primary);
+      }
+      .form-check-input:checked + .form-check-label::after {
+        color: var(--bgcolor-global);
+      }
+    }
+  }
+
+  .grw-pagetree, .grw-foldertree {
+    .list-group-item {
+      .grw-pagetree-title-anchor, .grw-foldertree-title-anchor {
+        color: inherit;
+      }
+    }
+  }
+
+  .grw-pagetree-footer {
+    .h5.grw-private-legacy-pages-anchor {
+      color: inherit;
+    }
+  }
+
+  .grw-recent-changes {
+    .list-group {
+      .list-group-item {
+        background-color: transparent !important;
+
+        .icon-lock {
+          color: var(--color-link);
+        }
+
+        .grw-recent-changes-item-lower {
+          color: $gray-500;
+
+          svg {
+            fill: $gray-500;
+          }
+        }
+      }
+    }
+  }
+
+}
+
+/*
+ * Icon
+ */
+.editor-container .navbar-editor svg {
+  fill: var(--color-editor-icons);
+}
+
+// page preview button in link form
+.btn-page-preview svg {
+  fill: white;
+}
+
+/*
+ * Modal
+ */
+.modal {
+  .modal-header {
+    border-bottom-color: var(--border-color-theme);
+    .modal-title {
+      color: $color-modal-header;
+    }
+    .btn-close {
+      color: $color-modal-header;
+      opacity: 0.5;
+
+      &:hover {
+        opacity: 0.9;
+      }
+    }
+  }
+
+  .modal-content {
+    background-color: var(--bgcolor-global);
+  }
+
+  .modal-footer {
+    border-top-color: var(--border-color-theme);
+  }
+}
+
+.grw-page-accessories-modal,.grw-descendants-page-list-modal {
+  .modal-header {
+    .btn-close {
+      color: #{hsl.contrast(var(--bgcolor-global))};
+    }
+  }
+}
+
+.grw-custom-nav-tab {
+  .nav-item {
+    &:hover,
+    &:focus {
+      background-color: hsl.alpha(var(--color-link),10%);
+    }
+    .nav-link {
+      -webkit-appearance: none;
+      color: var(--color-link);
+      svg {
+        fill: var(--color-link);
+      }
+
+      // Disabled state lightens text
+      &.disabled {
+        color: $nav-link-disabled-color;
+        svg {
+          fill: $nav-link-disabled-color;
+        }
+      }
+    }
+  }
+
+  .grw-nav-slide-hr {
+    border-color: var(--color-link) !important;
+  }
+}
+
+/*
+ * cards
+ */
+.card.custom-card {
+  color: var(--color-global);
+  background-color: var(--bgcolor-card);
+  border-color: var(--light);
+  box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05);
+}
+
+/*
+ * Form Slider
+ */
+.admin-page {
+  span.slider {
+    background-color: $gray-300;
+
+    &:before {
+      background-color: white;
+    }
+  }
+
+  input:checked + .slider {
+    background-color: #007bff;
+  }
+
+  input:focus + .slider {
+    box-shadow: 0 0 1px #007bff;
+  }
+}
+
+/*
+ * GROWI wiki
+ */
+.wiki {
+  h1,
+  h2,
+  h3,
+  h4,
+  h5,
+  h6,
+  h7 {
+    &.blink {
+      @include mixins.blink-bgcolor(var(--bgcolor-blinked-section));
+    }
+  }
+
+  .highlighted-keyword {
+    background: linear-gradient(transparent 60%, $bgcolor-keyword-highlighted 60%);
+  }
+
+  a {
+    color: var(--color-link-wiki);
+
+    &:hover {
+      color: var(--color-link-wiki-hover);
+    }
+  }
+
+  // table with handsontable modal button
+  .editable-with-handsontable {
+    button {
+      color: var(--color-link-wiki);
+    }
+
+    button:hover {
+      color: var(--color-link-wiki-hover);
+    }
+  }
+}
+
+/*
+ * GROWI page-list
+ */
+.page-list {
+  // List group
+  .list-group {
+    .list-group-item {
+      background-color: var(--bgcolor-global) !important;
+      a {
+        svg {
+          fill: var(--color-global);
+        }
+
+        &:hover {
+          svg {
+            fill: var(--color-global);
+          }
+        }
+      }
+
+      .page-list-meta {
+        color: $color-page-list-group-item-meta;
+        svg {
+          fill: $color-page-list-group-item-meta;
+        }
+      }
+
+      &.list-group-item-action {
+        background-color: var(--bgcolor-list);
+        &.active {
+          border-left-color: var(--primary);
+        }
+      }
+    }
+  }
+}
+
+/*
+ * GROWI Editor
+ */
+.layout-root.editing {
+  background-color: hsl.darken(var(--bgcolor-global),2%);
+
+  &.builtin-editor {
+    .page-editor-editor-container {
+      border-right-color: var(--border-color-theme);
+    }
+  }
+
+  .navbar-editor {
+    background-color: var(--bgcolor-global); // same color with active tab
+    border-bottom-color: var(--border-color-theme);
+  }
+
+  .page-editor-preview-container {
+    background-color: var(--bgcolor-global);
+  }
+}
+
+
+/*
+ * Preview for editing /Sidebar
+ */
+.page-editor-preview-body.preview-sidebar {
+  color: var(--color-sidebar-context);
+  background-color: var(--bgcolor-sidebar-context);
+}
+
+/*
+ * GROWI Grid Edit Modal
+ */
+.grw-grid-edit-preview {
+  .desktop-preview,
+  .tablet-preview,
+  .mobile-preview {
+    background: var(--bgcolor-global);
+  }
+  .grid-edit-border-for-each-cols {
+    border: 2px solid var(--bgcolor-global);
+  }
+}
+
+.grid-preview-col-0 {
+  background: var.$growi-blue;
+}
+
+.grid-preview-col-1 {
+  background: var(--info);
+}
+
+.grid-preview-col-2 {
+  background: var(--success);
+}
+
+.grid-preview-col-3 {
+  background: var.$growi-green;
+}
+
+/*
+ * GROWI comment form
+ */
+.page-comments-row {
+  background: var(--bgcolor-subnav);
+  .page-comment .page-comment-main,
+  .page-comment-form .comment-form-main {
+    background-color: var(--bgcolor-global);
+
+    .nav.nav-tabs {
+      > li > a.active {
+        background: transparent;
+        border-bottom: solid 1px hsl.darken(var(--bgcolor-global),4%);
+        border-bottom-color: hsl.darken(var(--bgcolor-global),4%);
+      }
+    }
+  }
+}
+
+/*
+ * GROWI search result
+ */
+.search-result-base {
+  .grw-search-page-nav {
+    background-color: var(--bgcolor-subnav);
+  }
+  .search-control {
+    background-color: var(--bgcolor-global);
+  }
+  .page-list {
+    .highlighted-keyword {
+      background: linear-gradient(transparent 60%, $bgcolor-keyword-highlighted 60%);
+    }
+  }
+}
+
+/*
+ * react bootstrap typeahead
+ */
+mark.rbt-highlight-text {
+  // Temporarily the highlight color is black
+  color: black;
+}
+
+/*
+ * GROWI page content footer
+ */
+.page-content-footer {
+  background-color: hsl.darken(var(--bgcolor-global),2%);
+  border-top-color: var(--border-color-theme);
+}
+
+/*
+ * GROWI admin page #layoutOptions #themeOptions
+ */
+.admin-page {
+  #layoutOptions {
+    .customize-layout-card {
+      &.border-active {
+        border-color: var(--color-theme-color-box);
+      }
+    }
+  }
+
+  #themeOptions {
+    .theme-option-container.active {
+      .theme-option-name {
+        color: var(--color-global);
+      }
+      a {
+        background-color: var(--color-theme-color-box);
+        border-color: var(--color-theme-color-box);
+      }
+    }
+  }
+}
+
+/*
+ * HackMd
+ */
+.bg-box {
+  background-color: var(--bgcolor-global);
+}
+
+/*
+  Slack Integration
+*/
+.selecting-bot-type {
+  .bot-type-disc {
+    width: 20px;
+  }
+}
+
+/*
+  In App Notification
+*/
+.grw-unopend-notification {
+  width: 7px;
+  height: 7px;
+  background-color: var(--primary);
+}
+
+/*
+Emoji picker modal
+*/
+.emoji-picker-modal {
+  background-color: transparent !important;
+}
+
+/*
+Expand / compress button bookmark list on users page
+*/
+.grw-user-page-list-m {
+  .grw-expand-compress-btn {
+    color: $body-color;
+    background-color: $body-bg;
+    &.active {
+      background-color: hsl.darken($body-bg, 12%),
+    }
+  }
+}
+
+/*
+ * Questionnaire modal
+ */
+.grw-questionnaire-btn-group {
+  .btn-outline-primary {
+    @include hsl-button.button-outline-variant(
+      #{hsl.lighten(var(--primary), 30%)} !important,
+      #{hsl.contrast(var(--primary))} !important,
+      var(--primary) !important,
+      #{hsl.lighten(var(--primary), 30%)} !important,
+    );
+    &:not(:disabled):not(.disabled):active,
+    &:not(:disabled):not(.disabled).active {
+      color: #{hsl.contrast(var(--primary))} !important;
+      background-color: var(--primary) !important;
+    }
+  }
+}
+
+/*
+ * revision-history-diff
+ */
+.revision-history-diff {
+  background-color: white;
+}

+ 4 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.1-RC.0",
+  "version": "7.0.2-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -113,7 +113,7 @@
     "escape-string-regexp": "^4.0.0",
     "eslint-plugin-regex": "^1.8.0",
     "expose-gc": "^1.0.0",
-    "express": "^4.16.1",
+    "express": "^4.19.2",
     "express-bunyan-logger": "^1.3.3",
     "express-mongo-sanitize": "^2.1.0",
     "express-session": "^1.16.1",
@@ -177,7 +177,7 @@
     "react-markdown": "^8.0.7",
     "react-multiline-clamp": "^2.0.0",
     "react-scroll": "^1.8.7",
-    "react-stickynode": "^4.1.0",
+    "react-stickynode": "^4.1.1",
     "react-syntax-highlighter": "^15.5.0",
     "react-toastify": "^9.1.3",
     "react-use-ripple": "^1.5.2",
@@ -234,6 +234,7 @@
     "@types/express": "^4.17.11",
     "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
+    "@types/react-stickynode": "^4.0.3",
     "@types/throttle-debounce": "^5.0.1",
     "@types/unzip-stream": "^0.3.4",
     "@types/url-join": "^4.0.2",

+ 4 - 2
apps/app/public/static/locales/en_US/commons.json

@@ -61,7 +61,8 @@
     "no_notification": "You don't have any notificatios.",
     "all": "All",
     "unopend": "Unread",
-    "mark_all_as_read": "Mark all as read"
+    "mark_all_as_read": "Mark all as read",
+    "only_unread": "Only unread"
   },
 
   "personal_dropdown": {
@@ -93,7 +94,8 @@
     "Page URL": "Page URL",
     "Permanent link": "Permanent link",
     "Page path and permanent link": "Page path and permanent link",
-    "Markdown link": "Markdown link"
+    "Markdown link": "Markdown link",
+    "Append params": "Append params"
   },
 
   "crop_image_modal": {

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

@@ -87,6 +87,7 @@
   "Go to this version": "View this version",
   "View diff": "View diff",
   "No diff": "No diff",
+  "Latest": "Latest",
   "User ID": "User ID",
   "User Information": "User information",
   "User Activation": "User Activation",
@@ -126,6 +127,7 @@
   "Only me": "Only me",
   "Only inside the group": "Only inside the group",
   "page_list": "Page List",
+  "comments": "Comments",
   "Reselect the group": "Reselect the group",
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
@@ -147,6 +149,7 @@
   "wide_view": "Wide View",
   "Recent Changes": "Recent Changes",
   "Page Tree": "Page Tree",
+  "Bookmarks": "Bookmarks",
   "In-App Notification": "Notifications",
   "original_path": "Original path",
   "new_path": "New path",
@@ -173,6 +176,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
+  "author_info": {
+    "created_at": "Created at",
+    "last_revision_posted_at": "Last revision posted at"
+  },
   "installer": {
     "tab": "Create account",
     "title": "Installer",
@@ -328,8 +335,15 @@
     "changes_not_saved": "Changes you made may not be saved. Are you sure you want to move?"
   },
   "page_comment": {
+    "comments": "Commments",
+    "comment": "Commment",
+    "preview": "Preview",
+    "write": "Write",
+    "add_a_comment": "Add a comment",
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
-    "no_user_found": "No user found"
+    "no_user_found": "No user found",
+    "reply": "Reply",
+    "delete_comment": "Delete comment?"
   },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",
@@ -529,7 +543,8 @@
   },
   "slack_notification": {
     "popover_title": "Slack Notification",
-    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list.",
+    "input_channels": "Input channels"
   },
   "search_result": {
     "title": "Search",
@@ -777,7 +792,7 @@
     "paths_recovered": "Paths recovered successfully",
     "path_recovery_failed": "Path recovery failed"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "Bookmarks",
     "recently_created": "Recently Created"
   },

+ 1135 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -0,0 +1,1135 @@
+{
+  "meta": {
+    "display_name": "Français"
+  },
+  "last_login": "Dernière connexion",
+  "wiki_management_homepage": "Gestion du wiki",
+  "public": "Public",
+  "anyone_with_the_link": "Tous les utilisateurs disposant du lien",
+  "specified_users": "Utilisateurs spécifiés",
+  "only_me": "Seulement moi",
+  "only_inside_the_group": "Utilisateurs du groupe",
+  "optional": "Optionnel",
+  "security_settings": {
+    "security_settings": "Paramètres de sécurité",
+    "scope_of_page_disclosure": "Confidentialité de la page",
+    "set_point": "Valeur",
+    "Guest Users Access": "Accès invité",
+    "always_hidden": "Toujours caché",
+    "always_displayed": "Toujours affiché",
+    "displayed_or_hidden": "Caché / Affiché",
+    "Fixed by env var": "Configuré par la variable d'environnement <code>{{key}}={{value}}</code>.",
+    "register_limitation": "Paramètres d'inscription",
+    "register_limitation_desc": "Restreindre l'inscription de nouveaux utilisateurs",
+    "The whitelist of registration permission E-mail address": "Autoriser l'inscription à ces adresses courriel",
+    "users_without_account": "Inaccessible aux utilisateurs sans compte",
+    "example": "Exemple",
+    "restrict_emails": "L'inscription par courriel peut-être restreinte par domaine (démarrant par @). ",
+    "for_example": " Par exemple, pour restreindre l'inscription aux utilisateurs dans le domaine growi.org, ajouter ",
+    "in_this_case": "; dans ce cas particulier, seul les utilisateurs du domaine growi.org peuvent s'inscrire.",
+    "insert_single": "Insérer une adresse courriel par ligne",
+    "page_list_and_search_results": "Liste et recherche de pages",
+    "page_listing_1": "Liste et recherche de pages<br>restreint à 'Seulement moi'",
+    "page_listing_1_desc": "Voir les pages restreintes à 'Seulement moi' lors de la recherche",
+    "page_listing_2": "Liste et recherche de pages<br>restreint au groupe utilisateur",
+    "page_listing_2_desc": "Voir les pages restreintes au groupe utilisateur lors de la recherche",
+    "page_access_rights": "Droits de lecture",
+    "page_delete_rights": "Droits de suppression",
+    "page_delete": "Suppression de page",
+    "page_delete_completely": "Suppression complète de page",
+    "other_options": "Paramètres supplémentaires",
+    "deletion_explanation": "Restreindre les utilisateurs pouvant supprimer une page.",
+    "complete_deletion_explanation": "Restreindre les utilisateurs pouvant supprimer complètement une page.",
+    "recursive_deletion_explain": "Restreindre les utilisateurs pouvant récursivement supprimer une page.",
+    "recursive_complete_deletion_explain": "Restreindre les utilisateurs pouvant récursivement supprimer complètement une page.",
+    "is_all_group_membership_required_for_page_complete_deletion": "L'utilisateur doit faire partie de tout les groupes ayant l'accès à la page",
+    "is_all_group_membership_required_for_page_complete_deletion_explanation": "Effectif lorsque les paramètres de page sont \"Seulement groupes spécifiés\".",
+    "inherit": "Hériter(Utilise le même paramètre que pour une page)",
+    "admin_only": "Administrateur seulement",
+    "admin_and_author": "Administrateur et auteur",
+    "anyone": "Tout le monde",
+    "user_homepage_deletion": {
+      "user_homepage_deletion": "Suppression de page d'accueil utilisateur",
+      "enable_user_homepage_deletion": "Activer la suppression de page d'accueil utilisateur",
+      "enable_force_delete_user_homepage_on_user_deletion": "Lorsqu'un utilisateur est supprimé, sa page d'accueil et ses sous-pages sont supprimées.",
+      "desc": "Les pages d'accueil utilisateurs pourront être supprimées."
+    },
+    "session": "Session",
+    "max_age": "Âge maximal (ms)",
+    "max_age_desc": "Spécifie (en milliseconde) l'âge maximal d'une session <br>Par défaut: 2592000000 (30 jours)",
+    "max_age_caution": "Un rédemarrage du serveur est nécessaire lorsque cette valeur est modifiée",
+    "forced_update_desc": "Ce paramètre à été modifié. Valeur précedente: ",
+    "page_delete_rights_caution": "Lorsque \"Supprimer / Supprimer récursivement\" est activé, le paramètre is \"Supprimer / Supprimer complètement\" est écrasé. <br> <br> Administrateur seulement > Administrateur et auteur > Tout le monde",
+    "Authentication mechanism settings": "Mécanisme d'authentification",
+    "setup_is_not_yet_complete": "Configuration incomplète",
+    "xss_prevent_setting": "Prévenir les attaques XSS(Cross Site Scripting)",
+    "xss_prevent_setting_link": "Paramètres Markdown",
+    "callback_URL": "URL de Callback",
+    "providerName": "Nom du fournisseur",
+    "issuerHost": "Hôte de l'émetteur",
+    "scope": "Permissions",
+    "desc_of_callback_URL": "Utilisé dans les paramètres du fournisseur {{AuthName}}",
+    "authorization_endpoint": "Adresse d'autorisation",
+    "token_endpoint": "Adresse de jeton",
+    "revocation_endpoint": "Adresse de révocation",
+    "introspection_endpoint": "Adresse d'introspection",
+    "userinfo_endpoint": "Adresse UserInfo",
+    "end_session_endpoint": "Adresse EndSession",
+    "registration_endpoint": "Adresse d'inscription",
+    "jwks_uri": "URL JSON Web Key Set",
+    "clientID": "ID du client",
+    "client_secret": "Secret du client",
+    "updated_general_security_setting": "Paramètres mis à jour",
+    "setup_not_completed_yet": "Configuration incomplète",
+    "guest_mode": {
+      "deny": "Refuser (Utilisateurs inscrits seulement)",
+      "readonly": "Autoriser (Lecture seule)"
+    },
+    "registration_mode": {
+      "open": "Ouvert (Tout le monde peut s'inscrire)",
+      "restricted": "Restreint (Requiert l'approbation d'administrateurs)",
+      "closed": "Fermé (Invitation seulement)"
+    },
+    "share_link_management": "Gestion des liens de partage",
+    "No_share_links":"Aucun liens de partage",
+    "share_link_notice":"Retirer les liens de partage",
+    "delete_all_share_links":"Supprimer tout les liens de partage",
+    "share_link_rights": "Permissions de liens de partage",
+    "enable_link_sharing": "Activer les liens de partage",
+    "all_share_links": "Liens de partage",
+    "configuration": " Configuration",
+    "Treat username matching as identical": "Lier les nouveaux comptes externes automatiquement lorsque <code>username</code> correspond",
+    "Treat username matching as identical_warn": "ATTENTION: Le système considère ne peut différencier un utilisateur lorsque <code>username</code> correspond.",
+    "Treat email matching as identical": "Lier les nouveaux comptes externes automatiquement lorsque <code>email</code> correspond",
+    "Treat email matching as identical_warn": "ATTENTION: Le système considère ne peut différencier un utilisateur lorsque <code>email</code> correspond.",
+    "Use env var if empty": "Utiliser la variable d'environnement <code>{{env}}</code> si vide",
+    "Use default if both are empty": "Si les deux sont vides, la valeur par défaut <code>{{target}}</code> est utilisée.",
+    "missing mandatory configs": "Les paramètres suivants obligatoires ne sont pas configurés.",
+    "Local": {
+      "name": "ID/Mot de passe",
+      "note for the only env option": "The LOCAL authentication is limited by the value of environment variable.<br>To change this setting, please change to false or delete the value of the environment variable <code>{{env}}</code> .",
+      "enable_local": "Activer ID/Mot de passe",
+      "password_reset_by_users": "Réinitialisation de mot passe",
+      "enable_password_reset_by_users": "Autoriser la réinitialisation de mot de passe",
+      "password_reset_desc": "Autorise l'utilisateur à réinitialiser son mot de passe",
+      "email_authentication": "Vérification d'adresse courriel",
+      "enable_email_authentication": "Activer la vérification d'adresse courriel",
+      "enable_email_authentication_desc": "Un courriel de vérification sera envoyé à l'inscription d'utilisateur."
+    },
+    "ldap": {
+      "enable_ldap": "Activer le LDAP",
+      "server_url_detail": "L'URL du répertoire LDAP  <code>ldap://host:port/DN</code> ou <code>ldaps://host:port/DN</code>.",
+      "bind_mode": "Mode d'authentification",
+      "bind_manager": "Manager",
+      "bind_user": "Utilisateur",
+      "bind_DN_manager_detail": "Le DN du compte s'authentifiant",
+      "bind_DN_user_detail1": "Le filtre pour s'authentifier avec le répertoire",
+      "bind_DN_user_detail2": "Utiliser <code>&#123;&#123;username&#125;&#125;</code> pour lier avec le nom d'utilisateur de la page de connexion.",
+      "bind_DN_password": "Lier le mot de passe DN",
+      "bind_DN_password_manager_detail": "Le mot de passe pour le compte DN",
+      "bind_DN_password_user_detail": "Le mot de passe utilisé sur la page de connexion sera lié",
+      "search_filter": "Filtre de recherche",
+      "search_filter_detail1": "Filtre de l'utilisateur authentifié",
+      "search_filter_detail2": "Utiliser <code>&#123;&#123;username&#125;&#125;</code> pour lier avec le nom d'utilisateur de la page de connexion.",
+      "search_filter_detail3": "Si vide, le filtre <code>(uid=&#123;&#123;username&#125;&#125;)</code> est utilisé.",
+      "search_filter_example1": "Faire correspondre avec 'uid' ou 'mail'",
+      "search_filter_example2": "Faire correspondre 'sAMAccountName' pour Active Directory",
+      "username_detail": "Spécifications des liaisons <code>username</code> lors de la création de nouveaux utilisateurs",
+      "name_detail": "Spécifications des liaisons pour le nom complet lors de la création de nouveaux utilisateurs",
+      "mail_detail": "Spécifications des liaisons pour l'adresse courriel lors de la création de nouveaux utilisateurs",
+      "group_search_base_DN": "Group Search Base DN",
+      "group_search_base_DN_detail": "Le DN de base pour rechercher les groupes. Si défini, <code>Filtre de recherche de groupe</code> doit être défini.",
+      "group_search_filter": "Filtre de recherche de groupe",
+      "group_search_filter_detail1": "Le filtre utilisé pour rechercher les groupes",
+      "group_search_filter_detail2": "La connexion par LDAP est acceptée seulement lorsque le filtre trouve un ou plusieurs groupes correspondants.",
+      "group_search_filter_detail3": "Utiliser <code>&#123;&#123;dn&#125;&#125;</code> pour le remplacer de l'objet utilisateur trouvé",
+      "group_search_filter_detail4": "<code>(&(cn=group1)(memberUid=&#123;&#123;dn&#125;&#125;))</code> hits the groups which has <code>cn=group1</code> and <code>memberUid</code> includes the user's <code>uid</code>(when <code>Group DN Property</code> is not changed from the default value.)",
+      "group_search_user_DN_property": "Propriété DN de l'utilisateur",
+      "group_search_user_DN_property_detail": "La propriété de l'objet utilisateur à utiliser pour l'interpolation <code>&#123;&#123;dn&#125;&#125;</code> de <code>Filtre de recherche de groupe</code>.",
+      "test_config": "Tester la configuration",
+      "updated_ldap": "Paramètres mis à jour"
+    },
+    "SAML": {
+      "name": "SAML",
+      "enable_saml": "Activer le SAML",
+      "id_detail": "Spécifications de l'attribut de nom pour identifier l'utilisateur dans le fournisseur d'identité SAML",
+      "username_detail": "Spécifications des liaisons <code>username</code> lors de la création de nouveaux utilisateurs",
+      "mapping_detail": "Spécifications des liaisons {{target}} lors de la création de nouveaux utilisateurs",
+      "cert_detail": "Certificat PEM encodé X.509 pour validation de la réponse du fournisseur d'identité",
+      "Use env var if empty": "Si vide, la valeur de la variable d'environnement <code>{{env}}</code> est utilisé.",
+      "note for the only env option": "Utilise uniquement les valeurs de variables d'environnement.",
+      "attr_based_login_control_detail": "Restreindre qui peut s'inscrire avec <code>&lt;saml: Attribute&gt;</code> inclut dans <code>&lt;saml: AttributeStatement&gt;</code> et <code>&lt;saml: AttributeValue&gt;</code>.",
+      "attr_based_login_control_rule_help": "<h5>Filtres valides:</h5><ul><li>Terms</li><li>Fields</li><li>AND/NOT/OR Operator</li><li>Grouping</li></ul><h5>Filtres invalides:</h5><ul><li>Wildcard, Fuzzy, Proximity, Range and Boosting</li><li>+/- Operator</li><li>Field Grouping</li></ul><h5>Escaping special characters</h5>Il est nécessaire d'échapper les caractères suivants:<br><code>+ - && || ! ( ) { } [ ] ^ &quot; &tilde; * ? : &#92;</code> et <code>/</code>",
+      "attr_based_login_control_rule_example1": "<h5>Exemple de conditions</h5>Si un filtre est <code>(Department: A || Department: B) && Position: Leader</code>, les utilisateurs ayant <code>Department: A</code> ou <code>Department: B</code> et <code>Position: Leader</code> <strong>peuvent</strong> se connecter.",
+      "attr_based_login_control_rule_example2": "<h5>Exemple d'échappement</h5>Si une URL est utilisé comme filtre, échapper les caractères suivants:<br><code>http&#92;:&#92;/&#92;/schemas.example.com&#92;/ws&#92;/2005&#92;/05&#92;/identity&#92;/claims&#92;/emailaddress: &quot;myname@example.com&quot;</code>",
+      "updated_saml": "Paramètres mis à jour"
+    },
+    "OAuth": {
+      "enable_oidc": "Activer le OIDC",
+      "register": "Register for %s",
+      "change_redirect_url": "Entrer <code>%s</code> <br>(où <code>%s</code> est le nom d'hôte) pour \"URL de redirection autorisés\".",
+      "Google": {
+        "enable_google": "Activer Google OAuth",
+        "name": "Google OAuth",
+        "register_1": "Accéder à {{link}}",
+        "register_2": "Créer un nouveau projet",
+        "register_3": "Créer un identifiant &rightarrow; OAuth client ID &rightarrow; Sélectionner \"Web application\"",
+        "register_4": "Configurer l'application OAuth avec l'un des URL de redirection autorisés avec <code>{{url}}</code>",
+        "register_5": "Copier l'ID client et Secret client ci-dessus",
+        "updated_google": "Paramètres mis à jour"
+      },
+      "Facebook": {
+        "name": "Facebook OAuth"
+      },
+      "GitHub": {
+        "enable_github": "Activer GitHub OAuth",
+        "name": "GitHub OAuth",
+        "register_1": "Accéder à {{link}}",
+        "register_2": "Configurer l'application OAuth avec l'un des URL de redirection autorisés avec <code>{{url}}</code>",
+        "register_3": "Copier l'ID client et Secret client ci-dessus",
+        "updated_github": "Paramètres mis à jour"
+      },
+      "OIDC": {
+        "name": "OpenID Connect",
+        "id_detail": "Specification of the name of attribute which can identify the user in OIDC claims",
+        "username_detail": "Spécifications des liaisons <code>username</code> lors de la création de nouveaux utilisateurs",
+        "name_detail": "Spécifications des liaisons <code>name</code> lors de la création de nouveaux utilisateurs",
+        "mapping_detail": "Spécifications des liaisons pour %s lors de la création de nouveaux utilisateurs",
+        "register_1": "Contacter votre administrateur OIDC",
+        "register_2": "Configurer l'application OAuth avec l'un des URL de redirection autorisés avec <code>%s</code>",
+        "register_3": "Copier l'ID client et Secret client ci-dessus",
+        "updated_oidc": "Paramètres mis à jour",
+        "Use discovered URL if empty": "Use discovered URL from \"Issuer Host\" if empty"
+      },
+      "how_to": {
+        "google": "Comment configurer Google OAuth?",
+        "github": "Comment configurer GitHub OAuth?",
+        "oidc": "Comment configurer OIDC?"
+      }
+    },
+    "form_item_name": {
+      "entryPoint": "Point d'entrée",
+      "issuer": "Émetteur",
+      "cert": "Certificat",
+      "attrMapId": "ID",
+      "attrMapUsername": "Nom d'utilisateur",
+      "attrMapMail": "Adresse courriel",
+      "attrMapFirstName": "Prénom",
+      "attrMapLastName": "Nom",
+      "ABLCRule": "Règle"
+    }
+  },
+  "notification_settings": {
+    "notification_settings": "Paramètres de notification",
+    "slack_incoming_configuration": "Configuration de webhook entrants Slack",
+    "prioritize_webhook": "Prioriser le webhook entrant plutôt que Slack",
+    "prioritize_webhook_desc": "Activer cette option utilisera les webhook entrants plutôt que Slack.",
+    "slack_app_configuration": "Configuration de l'application Slack",
+    "slack_app_configuration_desc": "Cette méthode n'est pas recommandée, car trop complexe.",
+    "use_instead":"Utiliser plutôt les webhook entrants Slack",
+    "how_to": {
+      "header": "Comment configurer un webhook entrant?",
+      "workspace": "(Dans le Workspace) Ajouter un webhook",
+      "workspace_desc1": "Se diriger vers la page de <a href='https://slack.com/services/new/incoming-webhook'>configuration de webhooks entrants</a>.",
+      "workspace_desc2": "Choisir un canal par défaut",
+      "workspace_desc3": "Ajouter.",
+      "at_growi": "(Page d'administration GROWI) Ajouter l'URL du webhook",
+      "at_growi_desc": "Ajouter &rdquo;Webhook URL&rdquo; et sauvegarder."
+    },
+    "user_trigger_notification_header": "Paramètres de notifications par défaut",
+    "pattern": "Schéma",
+    "channel": "Canal",
+    "pattern_desc": "Chemin du wiki. Un schéma avec <code>*</code> peut être utilisé.",
+    "channel_desc": "Nom du canal Slack. Omettre le <code>#</code>.",
+    "valid_page": "Activer/désactiver les notifications",
+    "link_notification_help": "<strong>Les pages accessibles uniquement par le lien 'Tout le monde avec lien'</strong> n'envoient pas toujours de notifications.",
+    "just_me_notification_help": "<strong>Les pages restreintes à 'Seulement moi'</strong> envoient des notifications lors de modifications.",
+    "group_notification_help": "<strong>Les pages restreintes à 'Groupe d'utilisateur'</strong> envoient des notifications lors de modifications.",
+    "notification_list": "Paramètres de notifications",
+    "add_notification": "Ajouter",
+    "trigger_path": "Chemin déclencheur",
+    "trigger_path_help": "(les expressions avec <code>*</code> sont acceptées)",
+    "trigger_events": "Déclencheur",
+    "notify_to": "Notifier à",
+    "back_to_list": "Retour à la liste",
+    "notification_detail": "Détails",
+    "event_pageCreate": "Lors de la \"création\"",
+    "event_pageEdit": "Lors de l'\"édition\"",
+    "event_pageDelete": "Lors de la \"suppression\"",
+    "event_pageMove": "Lors d'un \"déplacement\" (page renommée)",
+    "event_pageLike": "Lorsqu'une page est \"aimé\"",
+    "event_comment": "Lors d'un \"commentaire\"",
+    "email": {
+      "ifttt_link": "Créer un nouvel applet IFTTT lors de l'envoi de courriels"
+    },
+    "updated_slackApp": "Paramètres mis à jour",
+    "add_notification_pattern": "Ajouter un schéma de notification",
+    "delete_notification_pattern": "Supprimer un schéma de notification",
+    "delete_notification_pattern_desc1": "Chemin de suppression: {{path}}",
+    "delete_notification_pattern_desc2": "Sera supprimé définitivement",
+    "toggle_notification": "Paramètres mis à jour pour {{path}}",
+    "not_found_global_notification_triggerid": "ID global de notification introuvable"
+  },
+  "full_text_search_management": {
+    "full_text_search_management": "Configuration de la recherche",
+    "elasticsearch_management": "Configuration Elasticsearch",
+    "connection_status": "Statut",
+    "connection_status_label_unconfigured": "UNCONFIGURED",
+    "connection_status_label_connected": "CONNECTED",
+    "connection_status_label_disconnected": "DISCONNECTED",
+    "connection_status_label_erroroccured": "ERROR OCCURED ON SEARCH SERVICE",
+    "indices_status": "Statut des indices",
+    "indices_status_label_normalized": "NORMALIZED",
+    "indices_status_label_unnormalized": "REBUILDING ou BROKEN",
+    "indices_summary": "Résumé des indices",
+    "reconnect": "Reconnexion",
+    "reconnect_button": "Reconnecter",
+    "reconnect_description": "Faire un essai de connexion vers Elasticsearch.",
+    "normalize": "Normaliser",
+    "normalize_button": "Normaliser les indices",
+    "normalize_description": "Réparer les indices cassés.",
+    "rebuild": "Reconstruire",
+    "rebuild_button": "Reconstruire",
+    "rebuild_description_1": "Reconstruire l'index est les données de pages",
+    "rebuild_description_2": "Cela peut prendre un certain temps."
+  },
+  "mailer_setup_required":"<a href='/admin/app'>Configuration Email</a> sont requis pour envoyer.",
+  "admin_top": {
+    "management_wiki": "Configuration du wiki",
+    "system_information": "Information système",
+    "wiki_administrator": "Seuls les administrateurs peuvent accéder à cette page",
+    "assign_administrator": "Il est possible d'assigner l'accès administrateur en utilisant le bouton 'Ajouter accès administrateur'",
+    "package_name": "Nom du paquet",
+    "specified_version": "Version spécifiée",
+    "installed_version": "Version installée",
+    "list_of_env_vars":"Variables d'environnement",
+    "env_var_priority": "Les valeurs de la base de données sont priorisées.",
+    "about_security": "Voir les <a href='/admin/security'>paramètres de sécurité</a> pour les variables d'environnement de sécurité.",
+    "copy_prefilled_host_information": {
+      "default": "Copier les informations",
+      "done": "Copié dans le presse-papier!"
+    },
+    "bug_report": "Soumettre un rapport de bogue",
+    "submit_bug_report": "<a href='https://github.com/weseek/growi/issues/new?assignees=&labels=bug&template=bug-report.md&title=Bug%3A' target='_blank' rel='noreferrer'>soummettre ensuite sur GitHub.</a>"
+  },
+  "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_note": "Note: Les contraintes uniques seront écrasées.",
+    "upgrade_to_v5": "Convertir vers la V5",
+    "modal_migration_warning": "La conversion peut prendre un certain. Il est recommandé d'empêcher la création, modification et suppression de pages durant ce processus.",
+    "start_upgrading": "Convertir vers la V5",
+    "successfully_started": "Conversion réussie.",
+    "already_upgraded": "Conversion déjà effectuée.",
+    "header_upgrading_progress": "Progression de la conversion",
+    "migration_succeeded": "Conversion réussie! Le mode maintenance peut être désactivée et GROWI utilisé.",
+    "migration_failed": "Conversion échouée. Lire la documentation GROWI pour des informations supplémentaires."
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "Mode maintenance",
+    "under_maintenance_mode": "Mode maintenance activé",
+    "failed_to_start_maintenance_mode": "Échec de démarrage du mode maintenance.",
+    "failed_to_end_maintenance_mode": "Échec de fin du mode maintenance.",
+    "successfully_started_maintenance_mode": "Mode maintenance activé",
+    "successfully_ended_maintenance_mode": "Mode maintenance désactivé",
+    "warning_message_to_start": "Le mode maintenance restreint l'utilisation de GROWI. Seul la page d'administration sera accessible.",
+    "warning_message_to_end": "Vérifier que l'\"import de données\" ou la \"conversion vers la V5\" soit effectué. Sinon, laisser le mode maintenance activé.",
+    "supplymentary_message_to_start": "Seul l'API d'administration sera actif.",
+    "start_maintenance_mode": "Activer 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\"."
+  },
+  "app_setting": {
+    "site_name": "Nom du site",
+    "sitename_change": "Le nom du site utilisé dans l'en-tête et le titre HTML.",
+    "header_content": "Le contenu entré ici sera affiché dans l'en-tête, etc. ",
+    "site_url": {
+      "title": "Configuration de l'URL du site",
+      "desc": "Configuration de l'URL du site",
+      "warn": "Certaines fonctionnalitées peuvent ne pas fonctionner tant que l'URL du site n'est pas définie.",
+      "help": "URL complet du site démarrant par <code>http://</code> ou <code>https://</code>.",
+      "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> ."
+    },
+    "confidential_name": "Nom confidentiel",
+    "confidential_example": "ex): usage interne seulement",
+    "default_language": "Langue par défaut",
+    "default_mail_visibility": "Afficher l'adresse courriel pour les nouveaux utilisateurs",
+    "file_uploading": "Téléversement de fichiers",
+    "enable_files_except_image": "Autorise le téléversement de fichiers de n'importe quel type. Lorsque désactivé, seul les fichiers de type image sont autorisés.",
+    "attach_enable": "Autorise le téléversement de fichiers de n'importe quel type",
+    "update": "Sauvegarder",
+    "mail_settings": "Configuration e-mail",
+    "mailer_is_not_set_up": "Paramètres e-mail non configurés.",
+    "from_e-mail_address": "Adresse courriel <code>from</code>",
+    "transmission_method":"Méthode de transmission",
+    "smtp_label":"SMTP",
+    "ses_label":"SES(AWS)",
+    "send_test_email": "Envoi d'un courriel d'essai",
+    "success_to_send_test_email": "Courriel d'essai envoyé",
+    "smtp_settings": "Configuration SMTP",
+    "host": "Hôte",
+    "port": "Port",
+    "user": "Utilisateur",
+    "initialize_mail_settings": "réinitialiser les paramètres e-mail",
+    "initialize_mail_modal_header": "Réinitialiser les paramètres e-mail",
+    "confirm_to_initialize_mail_settings": "Les valeurs existantes seront écrasées. Réinitialiser les paramètres e-mail?",
+    "file_upload_settings":"Configuration du téléversement",
+    "file_upload_method":"Méthode de téléversement",
+    "file_delivery_method":"Méthode de récupération",
+    "file_delivery_method_redirect":"Rediriger",
+    "file_delivery_method_relay":"Relai interne du système",
+    "file_delivery_method_redirect_info":"Rediriger: Redirige vers une URL signé, performance excellente.",
+    "file_delivery_method_relay_info":"Relai interne du système: Le serveur GROWI sert les fichiers directement au client, sécurité complète.",
+    "fixed_by_env_var": "Défini par une variable d'environnement <code>FILE_UPLOAD={{fileUploadType}}</code>.",
+    "gcs_label": "GCP(GCS)",
+    "aws_label": "AWS(S3)",
+    "local_label": "Local",
+    "gridfs_label": "MongoDB(GridFS)",
+    "azure_label": "Azure(Blob)",
+    "azure_tenant_id": "ID tenant",
+    "azure_client_id": "ID client",
+    "azure_client_secret": "Secret client",
+    "azure_storage_account_name": "Nom du compte de stockage",
+    "azure_storage_container_name": "Nom du conteneur",
+    "azure_note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> .",
+    "file_upload": "Téléversement de fichiers",
+    "test_connection": "Essai de la connection e-mail",
+    "change_setting": "Si ce paramètre n'est pas complètement configuré, les fichiers existants seront inaccessibles.",
+    "region": "Région",
+    "bucket_name": "Nom du bucket",
+    "custom_endpoint": "URL personnalisée",
+    "custom_endpoint_change": "URL d'un service de stockage d'objet tel que MinIO qui a un API compatible S3. Par défaut, Amazon S3 est utilisé",
+    "s3_secret_access_key_input_description": "Valeur cachée",
+    "load_plugins": "Charger les plugins",
+    "enable": "Activer",
+    "disable": "Désactiver",
+    "use_env_var_if_empty": "Si la valeur dans la base de données est vide, la valeur de variable d'environnement <code>{{variable}}</code> est utilisé.",
+    "note_for_the_only_env_option": "Les paramètres sont définis par des variables d'environnement.<br>Pour modifier ce paramètre, supprimer la variable d'environnement <code>{{env}}</code> .",
+    "questionnaire_settings": "Données analytiques",
+    "questionnaire_settings_explanation": "Paramètres d'activation des données analytiques. L'utilisateur peut choisir ce paramètre individuellement dans \"Autres paramètres\".",
+    "about_data_sent": "À propos",
+    "docs_link": "https://docs.growi.org/en/admin-guide/management-cookbook/app-settings.html#questionnaire-settings",
+    "learn_more": "En savoir plus",
+    "other_info_will_be_sent": "En plus des données analytiques, des données diagnostiques pour améliorer GROWI sont envoyées. Les données personnelles ne sont pas incluses.",
+    "we_will_use_the_data_to_improve_growi": "Les données seront utilisées pour améliorer au mieux GROWI",
+    "anonymize_app_site_url": "Ne pas inclure l'URL du site",
+    "url_anonymization_explanation": "L'URL du site configurée ne sera pas inclue dans les données envoyées.",
+    "enable_questionnaire": "Activer les données analytiques"
+  },
+  "markdown_settings": {
+    "markdown_settings": "Configuration Markdown",
+    "lineBreak_header": "Configuration du saut de ligne",
+    "lineBreak_desc": "Configuration du saut de ligne.",
+    "lineBreak_options": {
+      "enable_lineBreak": "Activer le saut de ligne",
+      "enable_lineBreak_desc": "Convertir le saut de ligne<code>&lt;br&gt;</code>en HTML",
+      "enable_lineBreak_for_comment": "Activer le saut de ligne dans les commentaires",
+      "enable_lineBreak_for_comment_desc": "Convertir le saut de ligne dans les commentaires<code>&lt;br&gt;</code>en HTML"
+    },
+    "indent_header": "Configuration de l'indentation",
+    "indent_desc": "Configuration de l'indentation",
+    "indent_options": {
+      "indentSize": "Taille par défaut",
+      "indentSize_desc": "Taille par défaut de l'indentation dans l'éditeur Markdown",
+      "disallow_indent_change": "Empêcher le changement de taille d'indentation",
+      "disallow_indent_change_desc": "Forcer l'usage de la taille par défaut"
+    },
+    "xss_header": "Configuration prévention XSS",
+    "xss_desc": "Configuration de la prévention des attaques XSS (cross-site scripting).",
+    "xss_options": {
+      "enable_xss_prevention": "Activer prévention XSS",
+      "remove_all_tags": "Retirer tout les tags",
+      "remove_all_tags_desc": "Retire tout les tags HTML et CSS",
+      "recommended_setting": "Paramètres recommandés",
+      "custom_whitelist": "Liste autorisée",
+      "tag_names": "Nom de tags",
+      "tag_attributes": "Attributs de tags",
+      "import_recommended": "Importer les recommendations {{target}}"
+    }
+  },
+  "customize_settings": {
+    "customize_settings": "Personnalisation",
+    "default_sidebar_mode": {
+      "title": "Mode par défaut de la barre latérale",
+      "desc": "Le mode d'affichage par défaut de la barre latérale pour les utilisateurs.",
+      "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"
+    },
+    "layout": "Agencement",
+    "layout_options": {
+      "default": "Largeur par défaut",
+      "expanded": "100%"
+    },
+    "theme": "Thème",
+    "theme_desc": {
+      "light_and_dark": "Modes hybrides",
+      "unique": "Foncé ou clair"
+    },
+    "function": "Fonctionnalités",
+    "function_desc": "Des fonctionnalitées additionnelles peuvent être activées",
+    "function_options": {
+      "timeline": "Chronologie",
+      "timeline_desc1": "Afficher la chronologie des pages",
+      "timeline_desc2": "Peut affecter la performance si beaucoup de pages sont présentes",
+      "timeline_desc3": "Le chargement peut être améliorés en invalidant.",
+      "tab_switch": "Sauvegarder le changement d'onglets",
+      "tab_switch_desc1": "Sauvegarde l'état de navigation dans le navigateur de l'utilisateur.",
+      "tab_switch_desc2": "Lorsque désactivé, la navigation est forcé par l'interface.",
+      "attach_title_header": "Ajouter automatiquement une section h1",
+      "attach_title_header_desc": "Ajoute le chemin de la page en tant que h1 lors de création d'une page.",
+      "list_num_s": "Nombre de pages modales",
+      "list_num_desc_s": "Nombre de pages affichées sur les modales",
+      "list_num_m": "Nombre de pages articles",
+      "list_num_desc_m": "Nombre de pages affichées dans les 'favoris' ou les pages crées récemment.",
+      "list_num_l": "Nombre de pages recherche",
+      "list_num_desc_l": "Nombre de pages affichées lors de la recherche",
+      "list_num_xl": "Nombre de pages articles",
+      "list_num_desc_xl": "Nombre de pages affichées dans la 'corbeille' ou '404'.",
+      "stale_notification": "Afficher les anciennes notifications",
+      "stale_notification_desc": "Affiche les notifications sur les pages mises à jour il y a plus d'un an",
+      "show_all_reply_comments": "Afficher tout les commentaires",
+      "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
+      "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
+      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche."
+    },
+      "presentation": "Présentation",
+    "presentation_options": {
+      "enable_marp": "Activer Marp",
+      "enable_marp_desc": "Marp est utilisable dans la visualisation de présentation. Potentiellement vulnérable aux attaques XSS.",
+      "marp_official_site": "Site officiel Marp",
+      "marp_official_site_link": "https://marp.app",
+      "marp_in_growi" : "GROWI Docs - Créer des présentations avec Marp",
+      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
+    },
+    "custom_title": "Titre personnalisé",
+    "custom_title_detail": "Le tag <code>&lt;title&gt;</code> est personnalisable. Les variables suivantes seront automatiquement remplacées:",
+    "custom_title_detail_placeholder1": "<code>&#123;&#123;sitename&#125;&#125;</code> - Nom du wiki.",
+    "custom_title_detail_placeholder2": "<code>&#123;&#123;pagename&#125;&#125;</code> - Nom de la page actuelle.",
+    "custom_title_detail_placeholder3": "<code>&#123;&#123;pagepath&#125;&#125;</code> - Chemin de la page actuelle.",
+    "custom_noscript": "Noscript personnalisé",
+    "custom_noscript_detail": "Il est possible d'ajouter du code Noscript. Il sera inséré dans le tag <code>&lt;noscript&gt;</code>.",
+    "custom_css": "CSS personnalisé",
+    "write_css": "CSS personnalisé.",
+    "ctrl_space": "Ctrl+Space pour l'autocomplétion",
+    "custom_script": "Script personnalisé",
+    "custom_presentation": "Presentation personnalisé",
+    "write_java": "Code javascript qui sera appliqué au système entier.",
+    "reflect_change": "Un rechargement de la page est nécessaire pour afficher les changements.",
+    "custom_logo" : "Logo personnalisé",
+    "default_logo": "Logo par défaut",
+    "upload_logo": "Téléverser un logo",
+    "current_logo": "Logo actuel",
+    "upload_new_logo": "Téléverser un nouveau logo",
+    "delete_logo": "Supprimer le logo"
+  },
+  "importer_management": {
+    "import_data": "Importer des données",
+    "article": "Article",
+    "category": "Catégorie",
+    "tag": "Tag",
+    "page": "Page",
+    "page_path": "Chemin de page",
+    "beta_warning": "Cette fonctionnalité est en beta.",
+    "import_from": "Importer depuis {{from}}",
+    "import_growi_archive": "Importer une archive GROWI",
+    "error": {
+      "only_upsert_available": "Seul l'option 'Upsert' est disponible pour les collections de pages"
+    },
+    "growi_settings": {
+      "description_of_import_mode": {
+        "about": "Lors de l'import de données et d'un conflit avec les données actuelles, choisir l'une des trois options",
+        "insert": "Insert: Passe l'import",
+        "upsert": "Upsert: crit et met à jour le contenu existant",
+        "flash_and_insert": "Flash and Insert: Supprime les données existantes et effectue l'import"
+      },
+      "growi_archive_file": "Fichier d'archive GROWI",
+      "uploaded_data": "Données téléversées",
+      "extracted_file": "Fichier extrait",
+      "collection": "Collection",
+      "upload": "Téléverser",
+      "discard": "Jeter les données téléversées",
+      "errors": {
+        "different_versions": "La version de GROWI ne correspond pas aux données téléversées",
+        "at_least_one": "Séléctionner au moins une collection.",
+        "page_and_revision": "Les 'Pages' et 'Revisions' doivent être importés ensemble",
+        "depends": "'{{target}}' doit être sélectionné lorsque '{{condition}}' est sélectionné."
+      },
+      "configuration": {
+        "pages": {
+          "overwrite_author": {
+            "label": "Écrase l'auteur de la page avec l'utilisateur actuel",
+            "desc": "Il est recommandé de <span class=\"text-danger\">NE PAS</span> activer cette option lorsque les utilisateurs sont également importés."
+          },
+          "set_public_to_page": {
+            "label": "Rend les pages de '{{from}}' 'publiques'",
+            "desc": "Rend toutes les pages de <b>'{{from}}'</b> accessibles en lecture par <span class=\"text-danger\">TOUT les utilisateurs</span>."
+          },
+          "initialize_meta_datas": {
+            "label": "Réinitialiser les compteurs de page",
+            "desc": "Il est recommandé de <span class=\"text-danger\">NE PAS</span> activer cette option lorsque les utilisateurs sont également importés"
+          }
+        },
+        "revisions": {
+          "overwrite_author": {
+            "label": "Écrase l'auteur de la révision avec l'utilisateur actuel",
+            "desc": "Il est recommandé de <span class=\"text-danger\">NE PAS</span> activer cette option lorsque les utilisateurs sont également importés"
+          }
+        }
+      }
+    },
+    "esa_settings": {
+      "team_name": "Nom de l'équipe",
+      "access_token": "Jeton d'accès",
+      "test_connection": "Essai de la connection esa"
+    },
+    "qiita_settings": {
+      "team_name": "Nom de l'équipe",
+      "access_token": "Jeton d'accès",
+      "test_connection": "Essai de la connection qiita:team"
+    },
+    "import": "Importer",
+    "skip_username_and_email_when_overlapped": "Passe le nom et adresse courriel exactes dans le nouvel environnement",
+    "prepare_new_account_for_migration":"Préparer le compte pour la migration",
+    "archive_data_import_detail":"En savoir plus",
+    "admin_archive_data_import_guide_url":"https://docs.growi.org/en/admin-guide/management-cookbook/import.html",
+    "page_skip": "Les pages ayant le nom d'une page déjà existante ne seront pas importées.",
+    "Directory_hierarchy_tag": "Tag de hiérarchie"
+  },
+  "export_management": {
+    "export_archive_data": "Archive de données d'export",
+    "exporting_collection_list": "Export des collections",
+    "exported_data_list": "Données exportées",
+    "export_collections": "Exporter les collections",
+    "check_all": "Activer tout",
+    "uncheck_all": "Désactiver tout",
+    "desc_password_seed": "<p>NE PAS OUBLIER de mettre à jour <code>PASSWORD_SEED</code> dans le nouveau système GROWI, ou les utilisateurs ne pourront pas accéder au système. <br><strong>HINT:</strong><br>The current <code>PASSWORD_SEED</code> will be stored in <code>meta.json</code> in exported ZIP.</p>",
+    "create_new_archive_data": "Créer une nouvelle archive",
+    "export": "Exporter",
+    "cancel": "Annuler",
+    "file": "Fichier",
+    "growi_version": "Version de GROWI",
+    "collections": "Collections",
+    "exported_at": "Date d'export",
+    "export_menu": "Menu d'export",
+    "download": "Télécharger",
+    "delete": "Supprimer"
+  },
+  "external_notification": {
+    "external_notification": "Notifications externes",
+    "enabled": "Activé",
+    "disabled": "Désactivé",
+    "header_status": "Statut de l'intégration Slack",
+    "caution_enabled": "Les notification s'enverront seulement au workspace primaire Slack."
+  },
+  "slack_integration": {
+    "slack_integration": "Intégration Slack",
+    "selecting_bot_types": {
+      "slack_bot": "Slack bot",
+      "official_bot": "Bot officiel",
+      "custom_bot": "Bot personnalisé",
+      "without_proxy": "sans proxy",
+      "with_proxy": "avec proxy",
+      "recommended": "Recommendé",
+      "set_up": "Configurer",
+      "multiple_workspaces_integration": "Intégration avec plusieurs workspaces",
+      "security_control": "Vérification de sécurité",
+      "easy": "Facile",
+      "normal": "Normal",
+      "hard": "Difficile",
+      "possible": "Possible",
+      "impossible": "Impossible"
+    },
+    "bot_reset_successful": "Les paramètres de bot ont été réinitialisés.",
+    "adding_slack_ws_integration_settings_successful": "Les paramètres ont été ajoutés.",
+    "bot_all_reset_successful": "Tout les paramètres de bot ont été réinitialisés",
+    "copied_to_clipboard": "Copié dans le presse-papier",
+    "set_scope": "Configurer les jetons d'accès du bot depuis Slack",
+    "modal": {
+      "warning": "Attention",
+      "sure_change_bot_type": "Modifier le type de ce bot?",
+      "changes_will_be_deleted": "Les paramètres existants seront écrasés",
+      "cancel": "Annuler",
+      "change": "Modifier"
+    },
+    "toastr": {
+      "delete_slack_integration_procedure": "Paramètres de l'intégration Slack supprimés."
+    },
+    "use_env_var_if_empty": "Si la valeur dans la base de données est vide, la valeur de variable d'environnement <code>{{variable}}</code> est utilisé.",
+    "access_token_settings": {
+      "regenerate": "Réinitialiser"
+    },
+    "delete": "Supprimer",
+    "integration_procedure": "Procédure d'intégration",
+    "custom_bot_without_proxy_settings": "Bot Personnalisé sans proxy",
+    "integration_failed":"Échec de l'intégration",
+    "reset": "Réinitialiser",
+    "reset_all_settings": "Réinitialiser tout les paramètres",
+    "delete_slackbot_settings": "Supprimer les paramètres du bot Slack",
+    "slackbot_settings_notice": "La procédure d'intégration dans Slack sera supprimée. <br> Continuer?",
+    "all_settings_of_the_bot_will_be_reset": "Les paramètres seront réinitialisés.<br>Continuer?",
+    "accordion": {
+      "create_bot": "Créer bot",
+      "how_to_create_a_bot": "Comment créer un bot",
+      "how_to_install": "Comment installer",
+      "install_bot_to_slack": "Installer dans Slack",
+      "install_now": "Installer",
+      "generate_access_token": "Générer jeton d'accès",
+      "register_for_growi_official_bot_proxy_service": "Ajouter le bot proxy GROWI officiel",
+      "register_for_growi_custom_bot_proxy": "Ajout le bot GROWI officiel",
+      "enter_growi_register_on_slack": "Remplir <b>/growi register</b> dans Slack",
+      "paste_growi_url": "Remplir l'URL suivante dans <b>GROWI URL</b>.",
+      "enter_access_token_for_growi_and_proxy": "Remplir <b>Access Token Proxy to GROWI</b> et <b>Access Token GROWI to Proxy</b>",
+      "set_proxy_url_on_growi": "Configurer l'URL du proxy GROWI",
+      "copy_proxy_url": "Lorsque les étapes ci-dessus sont complétées, l'URL du proxy sera affiché dans le canal Slack sélectionné.",
+      "enter_proxy_url_and_update": "Entrer l'URL du proxy récupérée lors de l'étape précédente dans <b>Proxy URL</b>.",
+      "dont_need_update": "※Si la valeur est déjà remplie, il n'est pas nécessaire de la modifier.",
+      "select_install_your_app": "Sélectionner \"Installer votre application\".",
+      "go-to-manage-distribution": "Aller dans \"Manage Settings\" > \"Manage distribution\" dans la page de l'application Slack",
+      "activate-public-distribution": "Dans \"Share Your App with Other Workspaces\", vérifier que tous les items soit sélectionnés, puis cliquer \"Activate Public Distribution\"",
+      "click-add-to-slack-button": "Cliquer \"Add to Slack\".",
+      "select_install_to_workspace": "Sélectionner \"Install to Workspace\".",
+      "register_proxy_url": "Enregistrer l'URL du proxy avec GROWI",
+      "click_allow": "Sélectionner \"Allow\".",
+      "install_complete_if_checked": "Confirmer que \"Install your app\" est sélectionné.",
+      "invite_bot_to_channel": "Inviter le bot GROWI au canal en mentionnant @example.",
+      "register_secret_and_token": "Secret de signature et jeton d'accès du bot",
+      "manage_permission": "Gestion des permissions",
+      "growi_commands": "Commandes GROWI",
+      "multiple_growi_command": "Commandes utilisables avec plusieurs instances GROWI",
+      "single_growi_command": "Commandes utilisables avec une seule instance GROWI",
+      "allowed_channels_description": "Ajouter les canaux autorisés pour la commande \"{{keyName}}\". Séparer chaque élément avec \",\" . Les utilisateurs pourront utiliser la commande \"{{keyName}}\" depuis les canaux spécifiés.",
+      "unfurl_description": "Montrer le contenu de la page GROWI lorsque son lien est partagé sur Slack",
+      "unfurl_allowed_channels_description": "Ajouter les ID des canaux autorisés pour \"déployer\" . Séparer chaque élément avec \",\" . Le contenu d'une page publique GROWI sera visible lorsque son lien est partagé sur Slack",
+      "allow_all": "Tout autoriser",
+      "deny_all": "Tout refuser",
+      "allow_specified": "Autoriser sélectionnés",
+      "allow_all_long": "Tout autoriser (Depuis tout les canaux)",
+      "deny_all_long": "Tout refuser (Depuis tout les canaux)",
+      "allow_specified_long": "Autoriser sélectionnés (Depuis les canaux sélectionnés)",
+      "test_connection": "Tester la connexion",
+      "test_connection_by_pressing_button": "Cliquer sur le bouton pour tester la connexion",
+      "test_connection_only_public_channel":"Testez la connexion dans un canal publique.",
+      "error_check_logs_below": "Une erreur est survenue.",
+      "send_message_to_slack_work_space": "Envoyer un message vers l'espace de travail Slack.",
+      "add_slack_workspace": "Ajouter un espace de travail Slack"
+    },
+    "custom_bot_without_proxy_integration": "Bot personnalisé sans proxy",
+    "integration_sentence": {
+      "integration_is_not_complete": "Intégration échouée. <br>Procéder avec les instructions suivantes.",
+      "integration_successful": "Intégration réussie",
+      "integration_some_ws_is_not_complete": "Certains espaces de travail ne sont pas connectés"
+    },
+    "custom_bot_with_proxy_integration": "Bot personnalisé avec proxy",
+    "official_bot_integration": "Intégration officiel du bot",
+    "docs_url": {
+      "slack_integration": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/",
+      "official_bot": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#official-bot-%E3%80%90recommended%E3%80%91",
+      "custom_bot_without_proxy": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#custom-bot-without-proxy",
+      "custom_bot_with_proxy": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/#custom-bot-with-proxy",
+      "official_bot_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/official-bot-settings.html",
+      "custom_bot_without_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-without-proxy-settings.html",
+      "custom_bot_with_proxy_setting": "https://docs.growi.org/en/admin-guide/management-cookbook/slack-integration/custom-bot-with-proxy-settings.html"
+    }
+  },
+  "slack_integration_legacy": {
+    "slack_integration_legacy": "Ancienne intégration Slack",
+    "alert_disabled": "'Ancienne intégration Slack' est désactivée depuis que <a href='/admin/slack-integration'>les nouveaux paramètres</a> sont actifs",
+    "alert_deplicated": "'Ancienne intégration Slack' sera discontinué dans le futur. Utiliser plutôt <a href='/admin/slack-integration'>les nouveaux paramètres</a> "
+  },
+  "user_management": {
+    "user_management": "Configuration des utilisateurs",
+    "invite_users": "Créer un nouvel utilisateur temporaire",
+    "click_twice_same_checkbox": "Il est nécessaire de sélectionner une option.",
+    "status": "Statut",
+    "invite_modal": {
+      "emails": "Adresse Courriel (Supporte l'usage de plusieurs lignes)",
+      "description1":"Créer des utilisateurs temporaires avec une adresse courriel.",
+      "description2":"Un mot de passe temporaire sera généré..",
+      "invite_thru_email": "Envoyer courriel d'invitation",
+      "mail_setting_link":"<span className='material-symbols-outlined me-2'>settings</span><a href='/admin/app'>Paramètres courriel</a>",
+      "valid_email": "Adresse courriel valide requise",
+      "temporary_password": "Cette utilisateur a un mot de passe temporaire",
+      "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",
+      "send_temporary_password": "Si un courriel d'invitation n'est pas envoyé, copiez le mot de passe temporaire",
+      "send_email": "Le courriel d'invitation peut aussi être envoyé depuis la table utilisateur",
+      "existing_email": "Des comptes avec les adresses courriel suivantes sont déjà existants",
+      "issue": "Créer"
+    },
+    "user_table": {
+      "administrator": "Administrateur",
+      "read_only": "Lecture seule",
+      "edit_menu": "Modifier menu",
+      "reset_password": "Réinitialiser mot de passe",
+      "administrator_menu": "Menu administrateur",
+      "accept": "Accepter",
+      "deactivate_account": "Désactiver compte",
+      "your_own": "Vous ne pouvez pas désactiver votre propre compte",
+      "revoke_admin_access": "Révoquer permission d'administration",
+      "cannot_revoke": "Vous ne pouvez pas révoquer votre propre permission d'administration",
+      "grant_admin_access": "Ajouter permission administrateur",
+      "revoke_read_only_access": "Révoquer permission de lecture",
+      "grant_read_only_access": "Ajouter permission de lecture",
+      "send_invitation_email": "Envoyer courriel d'invitation",
+      "resend_invitation_email": "Renvoyer courriel d'invitation"
+    },
+    "reset_password": "Réinitialiser mot de passe",
+    "reset_password_modal": {
+      "password_never_seen": "Le mot de passe temporaire ne sera plus visible",
+      "password_reset_message": "Il est fortement recommandé de modifier le nouveau mot de passe immédiatement après la connexion.",
+      "send_new_password": "Envoyez le nouveau mot de passe à l'utilisateur.",
+      "target_user": "Utilisateur cible",
+      "new_password": "Nouveau mot de passe"
+    },
+    "external_account": "Configuration des comptes externes",
+    "external_accounts":"Comptes externes",
+    "create_external_account":"Créer compte externe",
+    "external_account_list": "Liste des comptes externes",
+    "external_account_none":"Pas de compte externe",
+    "invite": "Inviter",
+    "invited": "Utilisateur invité",
+    "back_to_user_management": "Gestion des utilisateurs",
+    "authentication_provider": "Fournisseur d'authentification",
+    "manage": "Gérer",
+    "password_setting": "Configuration mot de passe",
+    "password_setting_help": "Mot de passe configuré?",
+    "set": "Oui",
+    "unset": "Non",
+    "related_username": "Utilisateur ",
+    "cannot_invite_maximum_users": "La limite maximale d'utilisateurs invitables est atteinte.",
+    "current_users": "Utilisateurs:"
+  },
+  "user_group_management": {
+    "user_group_management": "Configuration des groupes",
+    "create_group": "Créer nouveau groupe",
+    "add_child_group": "Ajouter groupe enfant",
+    "remove_child_group": "Retirer",
+    "deny_create_group": "Les paramètres actuels ne permettent pas la création du groupe",
+    "group_name": "Nom du groupe",
+    "group_example": "e.g. : Group1",
+    "child_user_group": "Groupe utilisateur enfant",
+    "parent_group": "Groupe parent",
+    "select_parent_group": "Sélectionner groupe parent",
+    "release_parent_group": "Libérer groupe parent",
+    "add_modal": {
+      "description": "L'utilisateur sera ajouté au groupe parent.",
+      "add_user": "Ajouter utilisateur au groupe",
+      "search_option": "Rechercher paramètre",
+      "enable_option": "Activer {{option}}",
+      "forward_match": "Correspondance avant",
+      "partial_match": "Correspondance partielle",
+      "backward_match": "Correspondance inversée"
+    },
+    "group_list": "Liste des groupes",
+    "child_group_list": "Liste des groupes enfants",
+    "back_to_list": "Retour à la liste",
+    "basic_info": "Information de base",
+    "user_list": "Liste des utilisateurs",
+    "created_group": "Groupe crée",
+    "is_loading_data": "Chargement...",
+    "no_pages": "Le groupe n'a pas la permission de voir la page.",
+    "remove_from_group": "Retirer l'utilisateur",
+    "delete_modal": {
+      "header": "Supprimer groupe",
+      "desc": "Les groupes enfants seront également supprimés. Une fois supprimé, le groupe et ses pages correspondantes seront innaccessibles.",
+      "dropdown_desc": "Choisir une action pour les pages privées",
+      "select_group": "Sélectionner un groupe",
+      "no_groups": "Aucun groupe",
+      "publish_pages": "Publier",
+      "delete_pages": "Supprimer",
+      "transfer_pages": "Transférer vers le groupe"
+    },
+    "update_parent_confirm_modal": {
+      "header": "Le parent de ce groupe sera modifié",
+      "caution_change_parent": "Cette opération modifiera le parent du groupe \"{{groupName}}\".",
+      "danger_message": "Cette action affecte les permissions de visionnage associées au groupe.",
+      "force_update_parents_label": "Forcer l'ajout d'utilisateurs manquants",
+      "force_update_parents_description": "Ajoute les utilisateurs manquants au groupe ancêtre si ils existent lors d'un changement de groupe parent."
+    }
+  },
+  "audit_log_management": {
+    "audit_log": "Journal d'audit",
+    "audit_log_settings": "Configuration des journaux d'audit",
+    "user": "Utilisateur",
+    "username": "Nom d'utilisateur",
+    "date": "Date",
+    "action": "Action",
+    "ip": "Adresse IP",
+    "url": "URL",
+    "settings": "Paramètres",
+    "return": "Retour",
+    "clear": "Vider",
+    "activity_expiration_date": "Expiration des journaux d'audit",
+    "activity_expiration_date_explanation": "Les journaux d'audit sont supprimés après le nombre de seconde spécifiées",
+    "fixed_by_env_var": "Configuré par la variable d'environnement <code>{{key}}={{value}}</code>.",
+    "available_action_list": "Rechercher une action",
+    "available_action_list_explanation": "Liste des actions pouvant être recherchées/vues",
+    "action_list": "Liste d'actions",
+    "disable_mode_explanation": "Cette fonctionnalité est désactivée. Afin de l'activer, mettre à jour <code>AUDIT_LOG_ENABLED</code> pour true.",
+    "docs_url": {
+      "log_type": "https://docs.growi.org/en/admin-guide/admin-cookbook/audit-log-setup.html#log-types"
+    }
+  },
+  "g2g_data_transfer": {
+    "transfer_data_to_another_growi": "Transférer les données de ce GROWI vers un autre GROWI",
+    "advanced_options": "Paramètres avancés",
+    "start_transfer": "Démarrer transfert",
+    "paste_transfer_key": "Copier clé de transfert ici"
+  },
+  "plugins": {
+    "plugins": "Plugins",
+    "plugin_installer": "Configuration de plugins",
+    "form": {
+      "label_url": "URL du plugin",
+      "desc_url": "Les plugins sont installables par URL",
+      "label_branch": "Branche",
+      "desc_branch": "Spécification du nom de la branche. Par défaut: `main`"
+    },
+    "plugin_card": "Plugins",
+    "plugin_is_not_installed": "Aucun plugins installés",
+    "install": "Installer",
+    "confirm": "Supprimer le plugin?"
+  },
+  "cloud_setting_management": {
+    "to_cloud_settings": "Ouvrir paramètres GROWI.cloud"
+  },
+  "audit_log_action_category": {
+    "Page": "Page",
+    "Comment": "Commentaire",
+    "Tag": "Tag",
+    "Attachment": "Pièce jointe",
+    "ShareLink": "Lien de partage",
+    "Search": "Rechercher",
+    "User": "Utilisateur",
+    "Admin": "Administrateur"
+  },
+  "audit_log_action": {
+    "USER_REGISTRATION_SUCCESS": "Création d'utilisateur",
+    "USER_LOGIN_WITH_LOCAL": "Connexion avec ID/Mot de passe",
+    "USER_LOGIN_WITH_LDAP": "Connexion avec LDAP",
+    "USER_LOGIN_WITH_GOOGLE": "Connexion avec Google",
+    "USER_LOGIN_WITH_GITHUB": "Connexion avec GitHub",
+    "USER_LOGIN_WITH_OIDC": "Connexion avec OIDC",
+    "USER_LOGIN_WITH_SAML": "Connexion avec SAML",
+    "USER_LOGIN_FAILURE": "Échec de connexion",
+    "USER_LOGOUT": "Déconnexion",
+    "USER_FOGOT_PASSWORD": "Demander une réinitialisation de mot de passe",
+    "USER_RESET_PASSWORD": "Réinitialiser mon mot de passe",
+    "USER_PERSONAL_SETTINGS_UPDATE": "Modifier mes paramètres",
+    "USER_IMAGE_TYPE_UPDATE": "User image type update",
+    "USER_LDAP_ACCOUNT_ASSOCIATE": "Associer compte LDAP",
+    "USER_LDAP_ACCOUNT_DISCONNECT": "Dissocier compte LDAP",
+    "USER_PASSWORD_UPDATE": "Modifier mot de passe",
+    "USER_API_TOKEN_UPDATE": "Modifier jeton API",
+    "USER_EDITOR_SETTINGS_UPDATE": "Modifier paramètre éditeur",
+    "USER_IN_APP_NOTIFICATION_SETTINGS_UPDATE": "Paramètres de notifications",
+    "USER_REGISTRATION_APPROVAL_REQUEST": "Demande d'inscription pour ID/Mot de passe",
+    "PAGE_VIEW": "Vue page",
+    "PAGE_USER_HOME_VIEW": "Page d'accueil utilisateur",
+    "PAGE_FORBIDDEN": "Page inaccessible",
+    "PAGE_NOT_FOUND": "Page introuvable",
+    "PAGE_NOT_CREATABLE": "Page non ajoutable",
+    "PAGE_LIKE": "Aimer",
+    "PAGE_UNLIKE": "Retirer réaction",
+    "PAGE_BOOKMARK": "Favori",
+    "PAGE_UNBOOKMARK": "Retirer des favoris",
+    "PAGE_CREATE": "Créer page",
+    "PAGE_UPDATE": "Modifier page",
+    "PAGE_RENAME": "Renommer page",
+    "PAGE_DUPLICATE": "Dupliquer page",
+    "PAGE_DELETE": "Supprimer page",
+    "PAGE_DELETE_COMPLETELY": "Supprimer définitivement page",
+    "PAGE_REVERT": "Historique page",
+    "PAGE_EMPTY_TRASH": "Vider la corbeille",
+    "PAGE_RECURSIVELY_RENAME": "Renommage récursif de pages",
+    "PAGE_RECURSIVELY_DELETE": "Suppression récursive de pages",
+    "PAGE_RECURSIVELY_DELETE_COMPLETELY": "Suppression récursive complète de pages",
+    "PAGE_RECURSIVELY_REVERT": "Historique récursif de page",
+    "PAGE_SUBSCRIBE": "S'abonner",
+    "PAGE_UNSUBSCRIBE": "Se désabonner",
+    "PAGE_EXPORT": "Exporter",
+    "TAG_UPDATE": "Modifier les tags",
+    "IN_APP_NOTIFICATION_ALL_STATUSES_OPEN": "Marquer toutes les notifications comme lues",
+    "COMMENT_CREATE": "Ajouter un commentaire",
+    "COMMENT_UPDATE": "Modifier un commentaire",
+    "COMMENT_REMOVE": "Supprimer un commentaire",
+    "SHARE_LINK_CREATE": "Ajouter un lien de partage",
+    "SHARE_LINK_DELETE": "Supprimer un lien de partage",
+    "SHARE_LINK_DELETE_BY_PAGE": "Supprimer les liens de partage de la page",
+    "SHARE_LINK_ALL_DELETE": "Supprimer tout les liens de partage",
+    "SHARE_LINK_PAGE_VIEW": "Liens de partage",
+    "SHARE_LINK_EXPIRED_PAGE_VIEW": "Liens de partage expirés",
+    "SHARE_LINK_NOT_FOUND": "Liens de partage introuvables",
+    "ATTACHMENT_ADD": "Ajouter une pièce jointe",
+    "ATTACHMENT_REMOVE": "Supprimer une pièce jointe",
+    "ATTACHMENT_DOWNLOAD": "Télécharger une pièce jointe",
+    "SEARCH_PAGE": "Rechercher une page",
+    "SEARCH_PAGE_VIEW": "Résultats de recherche",
+    "ADMIN_APP_SETTING_UPDATE": "Modifier les paramètres d'application",
+    "ADMIN_SITE_URL_UPDATE": "Modifier les paramètres d'URL",
+    "ADMIN_MAIL_SMTP_UPDATE": "Modifier les paramètres d'e-mail",
+    "ADMIN_MAIL_SES_UPDATE": "Modifier les paramètres d'e-mail (SES)",
+    "ADMIN_MAIL_TEST_SUBMIT" : "Envoyer courriel de test",
+    "ADMIN_FILE_UPLOAD_CONFIG_UPDATE": "Modifier paramètres de téléversemetnt de fichiers",
+    "ADMIN_PLUGIN_UPDATE": "Mettre à jour les paramètres de plugins",
+    "ADMIN_MAINTENANCEMODE_ENABLED": "Activer mode maintenance",
+    "ADMIN_MAINTENANCEMODE_DISABLED": "Désactiver mode maintenance",
+    "ADMIN_SECURITY_SETTINGS_UPDATE": "Mettre à jour les paramètres de sécurité",
+    "ADMIN_PERMIT_SHARE_LINK": "Activer liens de partage",
+    "ADMIN_REJECT_SHARE_LINK": "Désactiver liens de partage",
+    "ADMIN_AUTH_ID_PASS_ENABLED": "Activer authentification ID/Password",
+    "ADMIN_AUTH_ID_PASS_DISABLED": "Désactiver authentification ID/Password",
+    "ADMIN_AUTH_ID_PASS_UPDATE": "Mettre à jour les paramètres d'authentification ID/Password",
+    "ADMIN_AUTH_LDAP_ENABLED": "Activer LDAP auth",
+    "ADMIN_AUTH_LDAP_DISABLED": "Désactiver LDAP auth",
+    "ADMIN_AUTH_LDAP_UPDATE": "Mettre à jour les paramètres d'authentification LDAP",
+    "ADMIN_AUTH_SAML_ENABLED": "Activer SAML auth",
+    "ADMIN_AUTH_SAML_DISABLED": "Désactiver SAML auth",
+    "ADMIN_AUTH_SAML_UPDATE": "Mettre à jour les paramètres d'authentification SAML",
+    "ADMIN_AUTH_OIDC_ENABLED": "Activer OIDC auth",
+    "ADMIN_AUTH_OIDC_DISABLED": "Désactiver OIDC auth",
+    "ADMIN_AUTH_OIDC_UPDATE": "Mettre à jour les paramètres d'authentification OIDC settings",
+    "ADMIN_AUTH_GOOGLE_ENABLED": "Activer Google auth",
+    "ADMIN_AUTH_GOOGLE_DISABLED": "Désactiver Google auth",
+    "ADMIN_AUTH_GOOGLE_UPDATE": "Mettre à jour les paramètres d'authentification Google",
+    "ADMIN_AUTH_GITHUB_ENABLED": "Activer GitHub auth",
+    "ADMIN_AUTH_GITHUB_DISABLED": "Désactiver GitHub auth",
+    "ADMIN_AUTH_GITHUB_UPDATE": "Mettre à jour les paramètres d'authentification GitHub",
+    "ADMIN_MARKDOWN_LINE_BREAK_UPDATE": "Mettre à jour les paramètres de casse de liens",
+    "ADMIN_MARKDOWN_INDENT_UPDATE": "Mettre à jour les paramètres d'indentation",
+    "ADMIN_MARKDOWN_PRESENTATION_UPDATE": "Mettre à jour les paramètres de présentation",
+    "ADMIN_MARKDOWN_XSS_UPDATE": "Mettre à jour les paramètres XSS",
+    "ADMIN_LAYOUT_UPDATE": "Mettre à jour les paramètres d'affichage",
+    "ADMIN_THEME_UPDATE": "Mettre à jour les paramètres de thème",
+    "ADMIN_SIDEBAR_UPDATE": "Mettre à jour le mode de barre de navigation",
+    "ADMIN_FUNCTION_UPDATE": "Mettre à jour les Function",
+    "ADMIN_CODE_HIGHLIGHT_UPDATE": "Mettre à jour les paramètres de surlignage de code",
+    "ADMIN_CUSTOM_TITLE_UPDATE": "Mettre à jour les paramètres de titres personnalisés",
+    "ADMIN_CUSTOM_NOSCRIPT_UPDATE": "Mettre à jour les paramètres noScript",
+    "ADMIN_CUSTOM_CSS_UPDATE": "Mettre à jour les paramètres CSS",
+    "ADMIN_CUSTOM_SCRIPT_UPDATE": "Mettre à jour les paramètres de script personnalisé",
+    "ADMIN_ARCHIVE_DATA_UPLOAD": "Téléverser les données d'archive",
+    "ADMIN_GROWI_DATA_IMPORTED": "Importer les données d'archive",
+    "ADMIN_UPLOADED_GROWI_DATA_DISCARDED": "Supprimer les données d'archive",
+    "ADMIN_ESA_DATA_IMPORTED": "Importer depuis esa.io",
+    "ADMIN_ESA_DATA_UPDATED": "Mettre à jour les paramètres d'import esa.io",
+    "ADMIN_CONNECTION_TEST_OF_ESA_DATA": "Tester la connexion esa",
+    "ADMIN_QIITA_DATA_IMPORTED": "Importer depuis Qiita:Team",
+    "ADMIN_QIITA_DATA_UPDATED": "Mettre à jour les paramètres d'import Qiita:Team",
+    "ADMIN_CONNECTION_TEST_OF_QIITA_DATA": "Tester la connexion Qiita:Team",
+    "ADMIN_ARCHIVE_DATA_CREATE": "Créer données d'archive",
+    "ADMIN_ARCHIVE_DATA_DOWNLOAD": "Télécharger les données d'archive",
+    "ADMIN_ARCHIVE_DATA_DELETE": "Supprimer les données d'archive",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_ADD": "Ajouter paramètres de déclencheur de notifications",
+    "ADMIN_USER_NOTIFICATION_SETTINGS_DELETE": "Supprimer les paramètres de déclencheur de notifications",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ADD": "Ajouter paramètres de notifications globales",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_UPDATE": "Mettre à jour les paramètres de notifications globales",
+    "ADMIN_NOTIFICATION_GRANT_SETTINGS_UPDATE": "Mettre à jour les permissions de notifications globales",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_ENABLED": "Activer les notifications globales",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DISABLED": "Désactiver les notifications globales",
+    "ADMIN_GLOBAL_NOTIFICATION_SETTINGS_DELETE": "Supprimer les paramètres de notifications globales",
+    "ADMIN_SLACK_WORKSPACE_CREATE": "Ajouter espace de travail Slack",
+    "ADMIN_SLACK_WORKSPACE_DELETE": "Supprimer espace de travail Slack",
+    "ADMIN_SLACK_BOT_TYPE_UPDATE": "Changer le type de bot Slack",
+    "ADMIN_SLACK_BOT_TYPE_DELETE": "Supprimer le type de bot Slack",
+    "ADMIN_SLACK_ACCESS_TOKEN_REGENERATE": "Regénérer jeton d'accès Slack",
+    "ADMIN_SLACK_MAKE_APP_PRIMARY": "Rendre le bot primaire",
+    "ADMIN_SLACK_PERMISSION_UPDATE": "Mettre à jour les permissions du bot Slack",
+    "ADMIN_SLACK_PROXY_URI_UPDATE": "Mettre à jour l'URL du proxy pour le bot personnalisé",
+    "ADMIN_SLACK_RELATION_TEST": "Essai de connexion au bot Slack",
+    "ADMIN_SLACK_WITHOUT_PROXY_SETTINGS_UPDATE": "Mettre à jour les paramètres du bot Slack",
+    "ADMIN_SLACK_WITHOUT_PROXY_PERMISSION_UPDATE": "Mettre à jour les permissions du bot Slack",
+    "ADMIN_SLACK_WITHOUT_PROXY_TEST": "Essai de connexion au bot Slack sans proxy",
+    "ADMIN_SLACK_CONFIGURATION_SETTING_UPDATE": "Modifier la configuration des webhooks entrants Slack",
+    "ADMIN_USERS_INVITE": "Invitation utilisateur",
+    "ADMIN_USERS_PASSWORD_RESET": "Réinitialiser le mot de passe",
+    "ADMIN_USERS_ACTIVATE": "Activer",
+    "ADMIN_USERS_DEACTIVATE": "Désactiver",
+    "ADMIN_USERS_GRANT_ADMIN": "Ajouter droit d'administration",
+    "ADMIN_USERS_REVOKE_ADMIN": "Révoquer droit d'administration",
+    "ADMIN_USERS_GRANT_READ_ONLY": "Ajouter droit de lecture",
+    "ADMIN_USERS_REVOKE_READ_ONLY": "Révoquer droit de lecture",
+    "ADMIN_USERS_SEND_INVITATION_EMAIL": "Envoyer le courriel d'invitation",
+    "ADMIN_USERS_REMOVE": "Supprimer l'utilisateur",
+    "ADMIN_USER_GROUP_CREATE": "Créer un groupe",
+    "ADMIN_USER_GROUP_UPDATE": "Mettre à jour le group",
+    "ADMIN_USER_GROUP_DELETE": "Supprimer le groupe",
+    "ADMIN_USER_GROUP_ADD_USER": "Ajouter l'utilisateur au groupe",
+    "ADMIN_SEARCH_CONNECTION": "Essai de reconnexion Elasticsearch",
+    "ADMIN_SEARCH_INDICES_NORMALIZE": "Nomarliser l'index Elasticsearch",
+    "ADMIN_SEARCH_INDICES_REBUILD": "Reconstruire l'index Elasticsearch"
+  },
+  "g2g": {
+    "transfer_success": "Transfert de GROWI vers GROWI complété!",
+    "error_generate_growi_archive": "Échec de la création du fichier d'archive GROWI",
+    "error_send_growi_archive": "Échec de l'envoi du fichier d'archive GROWI vers l'autre GROWI"
+  },
+  "external_user_group": {
+    "management": "Gestion des groupes externes",
+    "execute_sync": "Synchroniser",
+    "sync": "Synchroniser",
+    "invalid_sync_settings": "Paramètres invalides",
+    "update_sync_settings_failed": "Échec de la mise à jour des paramètres",
+    "description_form_detail": "Please note that edited value will be overwritten on next sync if description mapper is set in sync settings",
+    "only_description_edit_allowed": "Seule la description peut être modifiée pour les groupes externes",
+    "sync_being_executed": "Une synchronisation est actuellement en cours. Une synchronisation pourra être effectuée lorsque celle-ci sera terminée.",
+    "sync_succeeded": "Synchronisation réussie",
+    "sync_failed": "Synchronisation échouée",
+    "provider": "Fournisseur",
+    "confirmation_before_sync": "Confirmation préalable",
+    "execution_time_warning": "Le processus de synchronisation prend un certain temps, dépendemment de la quantité d'utilisateurs et de groupes.",
+    "parallel_sync_forbidden": "Il n'est pas possible de lancer plusieurs synchronisation en même temps.",
+    "ldap": {
+      "group_sync_settings": "Configuration synchronisation groupes LDAP",
+      "group_search_base_DN": "DN",
+      "group_search_base_dn_detail": "Le DN pour rechercher les groupes. La valeur écrite dans les paramètres de sécurité est utilisée par défaut.",
+      "membership_attribute": "Attribut d'appartenance",
+      "membership_attribute_detail": "Attribut de l'objet groupe qui indique l'appartenance de l'utilisateur.",
+      "membership_attribute_type": "Type d'attribut",
+      "membership_attribute_type_detail": "Le type d'attribut d'appartenance, soit DN ou UID.",
+      "child_group_attribute": "Attribut du groupe enfant",
+      "child_group_attribute_detail": "Attribut de l'objet groupe qui indique les informations du groupe enfant, soit son DN.",
+      "preserve_deleted_ldap_groups": "Préserver les groupes LDAP supprimés",
+      "name_mapper_detail": "Attribut relié au nom de groupe",
+      "updated_group_sync_settings": "Mettre à jour les paramètres de synchronisation de groupes LDAP",
+      "password": "Mot de passe",
+      "password_detail": "Le mot de passe de connexion est nécessaire, car le type de liaison est User Bind",
+      "auth_not_set": "L'authentification LDAP doit être activé avant le processus de synchronisation"
+    },
+    "keycloak": {
+      "group_sync_settings": "Configuration synchronisation groupes Keycloak",
+      "host": "Hôte",
+      "host_detail": "URL hôte Keycloak",
+      "group_realm": "Realm du groupe",
+      "group_realm_detail": "Realm contenant les groupes pour la synchronisation",
+      "group_sync_client_realm": "Realm du client utilisé pour interroger l'API Admin",
+      "group_sync_client_realm_detail": "Realm du client utilisé pour authentifier l'interrogation de l'API admin Keycloak",
+      "group_sync_client_id": "ID du client",
+      "group_sync_client_id_detail": "Identifiant du client utilisé pour authentifier l'interrogation de l'API admin Keycloak",
+      "group_sync_client_secret": "Secret du client",
+      "group_sync_client_secret_detail": "Secret du client utilisé pour authentifier l'interrogation de l'API admin Keycloak",
+      "updated_group_sync_settings": "Mettre à jour les paramètres de synchronisation de groupes Keycloak",
+      "preserve_deleted_keycloak_groups": "Préserver les groupes Keycloak supprimés",
+      "auth_not_set": "Activer OIDC or SAML host that includes 'Host' and 'Group Realm' of group sync settings"
+    },
+    "auto_generate_user_on_sync": "Générer l'utilisateur lors de la synchronisation",
+    "description_mapper_detail": "Attribut relié à la description du groupe. La description est modifiable après la synchronisation. L'attribut de liaison écrasera automatiquement la valeur modifié lors d'une synchronisation."
+  },
+  "toaster": {
+    "grant_user_admin": "{{username}}: droits d'administration ajoutés",
+    "revoke_user_admin": "{{username}}: droits d'administration retirés",
+    "grant_user_read_only": "{{username}}: droits de lecture ajoutés",
+    "revoke_user_read_only": "{{username}}: droits de lecture retirés",
+    "activate_user_success": "{{username}} activé",
+    "deactivate_user_success": "{{username}} désactivé",
+    "remove_user_success": "{{username}} supprimé",
+    "remove_external_user_success": "{{accountId}} supprimé",
+    "switch_disable_link_sharing_success": "Paramètres de lien de partage mis à jour",
+    "install_plugin_success": "{{pluginName}} installé",
+    "activate_plugin_success": "{{pluginName}} activé",
+    "deactivate_plugin_success": "{{pluginName}} désactivé",
+    "remove_plugin_success": "{{pluginName}} désinstallé"
+  },
+  "forbidden_page": {
+    "do_not_have_admin_permission": "Seul les administrateurs peuvent accéder à cette page."
+  }
+}

+ 161 - 0
apps/app/public/static/locales/fr_FR/commons.json

@@ -0,0 +1,161 @@
+{
+  "Show": "Afficher",
+  "Hide": "Cacher",
+  "Add": "Ajouter",
+  "Insert": "Insérer",
+  "Reset": "Réinitialiser",
+  "Sign out": "Se déconnecter",
+  "New": "Nouveau",
+  "Delete": "Supprimer",
+
+  "meta": {
+    "display_name": "Français"
+  },
+  "toaster": {
+    "add_succeeded": "Succès de l'ajout de {{target}} ",
+    "create_failed": "Échec de création de {{target}}",
+    "create_succeeded": "Succès de la création de {{target}}",
+    "delete_succeeded": "{{target}} supprimé",
+    "remove_share_link": "{{count}} liens de partage supprimés",
+    "remove_share_link_success": "Lien de partage {{shareLinkId}} supprimé",
+    "update_failed": "Échec de mise à jour de {{target}}",
+    "update_successed": "Succès de mise à jour de {{target}}"
+  },
+  "alert": {
+    "siteUrl_is_not_set": "'URL du site' n'est pas renseigné. Remplir depuis {{link}}",
+    "please_enable_mailer": "La configuration SMTP est requise.",
+    "password_reset_please_enable_mailer": "La configuration SMTP est requise.",
+    "email_is_already_in_use": "La configuration SMTP est déjà faite."
+  },
+  "headers": {
+    "app_settings": "Paramètres de l'application"
+  },
+
+  "header_search_box": {
+    "label": {
+      "All pages": "Toutes les pages",
+      "This tree": "Cette arbre"
+    },
+    "item_label": {
+      "All pages": "Toutes les pages",
+      "This tree": "Enfants de cette arbre"
+    }
+  },
+
+  "search_method_menu_item": {
+    "search_in_all": "Rechercher dans tout",
+    "only_children_of_this_tree": "Enfants de cet arbre",
+    "exact_mutch": "Correspondance exacte"
+  },
+
+  "share_links": {
+    "Share Link": "Liens de partage",
+    "Page Path": "Chemin de la page",
+    "expire": "Expiration",
+    "description": "Description"
+  },
+
+  "in_app_notification": {
+    "notification_list": "Notifications d'application",
+    "see_all": "Voir tout",
+    "no_notification": "Vous n'avez pas de notifications.",
+    "all": "Toutes",
+    "unopend": "Non-lues",
+    "mark_all_as_read": "Tout marquer comme lu"
+  },
+
+  "personal_dropdown": {
+    "home": "Accueil",
+    "settings": "Paramètres",
+    "color_mode": "Couleur",
+    "sidebar_mode": "Navigation latérale",
+    "sidebar_mode_editor": "Navigation latérale dans l'éditeur",
+    "use_os_settings": "Utiliser les paramètres système",
+    "feedback": "Avis"
+  },
+
+
+  "create_page_dropdown": {
+    "new_page": "Créer nouvelle page",
+    "todays": {
+      "desc": "Créer le mémo du jour",
+      "memo": "mémo"
+    },
+    "template": {
+      "desc": "Créer/modifier page modèle",
+      "children": "Modèle page enfant",
+      "descendants": "Modèle pour descendants"
+    }
+  },
+
+  "copy_to_clipboard": {
+    "Copy to clipboard": "Copier dans le presse-papier",
+    "Page path": "Chemin de la page",
+    "Page URL": "URL de la page",
+    "Permanent link": "Lien permanent",
+    "Page path and permanent link": "Chemin de la page et lien permanent",
+    "Markdown link": "Lien Markdown",
+    "Append params": "Affixer les paramètres"
+  },
+
+  "crop_image_modal": {
+    "image_crop": "Recadrage d'image",
+    "crop": "Recadrer",
+    "save": "Sauvegarder",
+    "cancel": "Annuler"
+  },
+
+  "handsontable_modal": {
+    "title": "Modifier table",
+    "data_import": "Import de données",
+    "save": "Sauvegarder",
+    "cancel": "Annuler",
+    "done": "Terminer",
+    "data_import_form": {
+      "select_data_format": "Sélectionner format de données",
+      "import_data": "Importer données",
+      "paste_table_data": "Coller les données de la table",
+      "parse_error": "Erreur d'analyse",
+      "cancel": "Annuler",
+      "import": "Importer"
+    }
+  },
+
+  "questionnaire_modal": {
+    "required": "Requis",
+    "submit": "Soumettre",
+    "close": "Fermer",
+    "title": "Sondages aléatoires GROWI pour données anonymisées.",
+    "more_satisfied_services": "Nous espérons satisfaire au mieux les utilisateurs de GROWI",
+    "strive_to_improve_services": "et utilisons les retours d'utilisateurs afin d'améliorer l'expérience d'usage GROWI",
+    "length_of_experience": {
+      "more_than_two_years": "Plus de 2 ans",
+      "one_to_two_years": "Plus d'un an, mais moins de 2 ans",
+      "six_months_to_one_year": "Plus de 6 mois, mais moins d'un an",
+      "three_months_to_six_months": "Plus de 3 mois, mais moins de 6 mois",
+      "one_month_to_three_months": "Plus d'un moins, mais moins de 3 mois",
+      "less_than_one_month": "Moins d'un mois"
+    },
+    "satisfaction_with_growi": "Satisfaction avec GROWI",
+    "history_of_growi_usage": "Historique d'usage de GROWI",
+    "occupation": "Occupation",
+    "position": "Position",
+    "comment_on_growi": "Commentaires sur GROWI",
+    "successfully_submitted": "Questionnaire soumis.",
+    "thanks_for_answering": "Merci pour votre avis."
+  },
+
+  "not_found_page": {
+    "page_not_exist": "Cette page est introuvable."
+  },
+
+  "g2g_data_transfer": {
+    "tab": "Transfert de données",
+    "data_transfer": "Transfert de données",
+    "transfer_data_to_this_growi": "Transférer les données d'un autre GROWI vers ce GROWI",
+    "publish_transfer_key": "Publier la clé de transfert",
+    "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
+    "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
+    "transfer_to_growi_cloud": "Si vous souhaitez transférer depuis GROWI.cloud, cliquez ici."
+  }
+}

+ 867 - 0
apps/app/public/static/locales/fr_FR/translation.json

@@ -0,0 +1,867 @@
+{
+  "meta": {
+    "display_name": "Français"
+  },
+  "Help": "Aide",
+  "view": "Voir",
+  "Edit": "Modifier",
+  "Delete": "Supprimer",
+  "delete_all": "Tout supprimer",
+  "Duplicate": "Dupliquer",
+  "PathRecovery": "Récupération de chemin",
+  "Copy": "Copier",
+  "preview": "Prévisualiser",
+  "desktop": "Ordinateur",
+  "phone": "Téléphone",
+  "tablet": "Tablette",
+  "Click to copy": "Cliquer pour copier",
+  "Rename": "Renommer",
+  "Move/Rename": "Déplacer/renommer",
+  "Redirected": "Redirigé",
+  "Unlinked": "Délié",
+  "unlink_redirection": "Délier la redirection",
+  "Done": "Terminer",
+  "Cancel": "Annuler",
+  "Create": "Créer",
+  "Description": "Description",
+  "Admin": "Administration",
+  "administrator": "Administrateur",
+  "Tag": "Étiquette",
+  "Tags": "Étiquettes",
+  "Close": "Fermer",
+  "Shortcuts": "Raccourcis",
+  "CustomSidebar": "Navigation latérale",
+  "eg": "e.g.",
+  "add": "Ajouter",
+  "Undo": "Annuler",
+  "Article": "Article",
+  "Page Path": "Chemin de page",
+  "Category": "Catégorie",
+  "User": "Utilisateur",
+  "account_id": "Identifiant de compte",
+  "Update": "Mettre à jour",
+  "Update Page": "Mettre à jour la page",
+  "Error": "Erreur",
+  "Warning": "Avertissement",
+  "Sign in": "Se connecter",
+  "Sign in with External auth": "Se connecter avec {{signin}}",
+  "Sign up is here": "Inscription",
+  "Sign in is here": "Connexion",
+  "Sign up": "S'inscrire",
+  "or": "ou",
+  "Sign up with Google Account": "S'inscrire avec Google",
+  "Sign in with Google Account": "Se connecter avec Google",
+  "Sign up with this Google Account": "S'inscrire avec ce compte Google",
+  "Select": "Sélectionner",
+  "Required": "Requis",
+  "Example": "Exemple",
+  "Taro Yamada": "John Doe",
+  "List View": "Liste",
+  "Timeline View": "Chronologie",
+  "History": "Historique",
+  "attachment_data": "Pièces jointes",
+  "No_attachments_yet": "Aucunes pièces jointes.",
+  "Presentation Mode": "Mode présentation",
+  "Not available for guest": "Indisponible pour les invités",
+  "Not available in this version": "Indisponible dans cette version",
+  "No users have liked this yet": "Aucun utilisateur n'a aimé cette page",
+  "No users have liked this yet.": "Aucun utilisateur n'a aimé cette page.",
+  "No users have bookmarked yet": "Aucun utilisateur n'a mis en favoris cette page",
+  "Create Archive Page": "Créer page d'archive",
+  "Create Sidebar Page": "Créer page <strong>/Sidebar</strong>",
+  "File type": "Type de fichier",
+  "Target page": "Page cible",
+  "Include Attachment File": "Inclure le fichier de pièces jointes",
+  "Include Comment": "Inclure les commentaires",
+  "Include Subordinated Page": "Inclure les pages subordonnées",
+  "Include Subordinated Target Page": "inclure {{target}}",
+  "All Subordinated Page": "Toutes les pages subordonnées",
+  "Specify Hierarchy": "Spécifier hiérarchie",
+  "Submitted the request to create the archive": "Création d'archive en cours",
+  "username": "Nom d'utilisateur",
+  "Created": "Crée le",
+  "Last updated": "Modifié le",
+  "Share": "Partager",
+  "Markdown Link": "Lien Markdown",
+  "Create/Edit Template": "Créer/Modifier page modèle",
+  "Go to this version": "Voir cette version",
+  "View diff": "Voir le diff",
+  "No diff": "Aucune différences",
+  "Latest": "Dernière version",
+  "User ID": "Identifiant utilisateur",
+  "User Information": "Informations utilisateur",
+  "User Activation": "Activation utilisateur",
+  "Basic Info": "Informations de base",
+  "Name": "Nom",
+  "Email": "Adresse courriel",
+  "Language": "Langue",
+  "English": "Anglais",
+  "Japanese": "Japonais",
+  "Set Profile Image": "Sélectionner image de profil",
+  "Upload Image": "Téléverser image",
+  "Current Image": "Image actuelle",
+  "Delete Image": "Supprimer image",
+  "Delete this image?": "Supprimer cette image?",
+  "Updated": "Modifié",
+  "Upload new image": "Téléverser nouvelle image",
+  "Connected": "Connecté",
+  "Loading": "Chargement...",
+  "Disclose E-mail": "Afficher adresse courriel",
+  "page exists": "cette page est déjà existante",
+  "Error occurred": "Une erreur est survenue",
+  "Input page name": "Nom de la page",
+  "Input page name (optional)": "Nom de la page (optionnel)",
+  "Input parent page path": "Chemin de la page parent",
+  "New Page": "Nouvelle page",
+  "Create under": "Créer la page sous:",
+  "V5 Page Migration": "Convertir vers la V5",
+  "GROWI.5.0_new_schema": "Nouveau schéma GROWI.5.0",
+  "See_more_detail_on_new_schema": "Plus de détails sur <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html#about-the-new-v5-compatible-format' target='_blank'>{{title}}</a> <i class='icon-share-alt'></i> ",
+  "external_account_management": "Gestion des comptes externes",
+  "UserGroup": "Groupe utilisateur",
+  "Basic Settings": "Paramètres de base",
+  "The contents entered here will be shown in the header etc": "Le contenu entré ici sera visible dans l'en-tête",
+  "Public": "Public",
+  "Anyone with the link": "Tous les utilisateurs disposant du lien",
+  "Specified users only": "Utilisateurs spécifiés",
+  "Only me": "Seulement moi",
+  "Only inside the group": "Utilisateurs du groupe",
+  "page_list": "Liste de pages",
+  "comments": "Commentaires",
+  "Reselect the group": "Resélectionner ce groupe",
+  "Shareable link": "Lien partageable",
+  "The whitelist of registration permission E-mail address": "Les adresses courriel permises lors de l'inscription",
+  "Add tags for this page": "Ajouter des étiquettes",
+  "tag_list": "Étiquettes",
+  "popular_tags": "Étiquettes populaires",
+  "Check All tags": "voir toutes les étiquettes",
+  "You have no tag, You can set tags on pages": "Vous n'avez aucunes étiquettes, vous pouvez assigner des étiquettes aux pages",
+  "Show latest": "Voir le plus récent",
+  "Load latest": "Charger le plus récent",
+  "edited this page": "à modifié cette page.",
+  "List Drafts": "Brouillons",
+  "Deleted Pages": "Pages supprimées",
+  "Questionnaire": "Questionnaire",
+  "Disassociate": "Dissocier",
+  "No bookmarks yet": "Aucuns favoris",
+  "add_bookmark": "Ajouter aux favoris",
+  "remove_bookmark": "Retirer des favoris",
+  "wide_view": "Vue élargie",
+  "Recent Changes": "Modifications récentes",
+  "Page Tree": "Arbre",
+  "Bookmarks": "Favoris",
+  "In-App Notification": "Notifications",
+  "original_path": "Chemin originel",
+  "new_path": "Nouveau chemin",
+  "duplicated_path": "Chemin dupliqué",
+  "Link sharing is disabled": "Le partage est désactivé",
+  "successfully_saved_the_page": "Page sauvegardée",
+  "you_can_not_create_page_with_this_name": "Vous ne pouvez pas créer cette page",
+  "not_allowed_to_see_this_page": "Vous ne pouvez pas voir cette page",
+  "Confirm": "Confirmer",
+  "Successfully requested": "Demande envoyée.",
+  "form_validation": {
+    "error_message": "Des champs sont invalides",
+    "required": "%s est requis",
+    "invalid_syntax": "La syntaxe de %s est invalide.",
+    "title_required": "Titre requis.",
+    "field_required": "{{target}} est requis"
+  },
+  "page_name": "Nom de la page",
+  "folder_name": "Nom du dossier",
+  "field": "champ",
+  "not_creatable_page": {
+    "message": "Vous ne pouvez pas créer cette page dans ce chemin."
+  },
+  "custom_navigation": {
+    "no_pages_under_this_page": "Il n'y a aucune page sous celle-ci."
+  },
+  "author_info": {
+    "created_at": "Crée le",
+    "last_revision_posted_at": "Dernière révision le"
+  },
+  "installer": {
+    "tab": "Créer compte",
+    "title": "Configuration",
+    "setup": "Configuration initiale",
+    "create_initial_account": "Créer un compte",
+    "initial_account_will_be_administrator_automatically": "Ce compte sera administrateur par défaut.",
+    "unavaliable_user_id": "Cet 'Identifiant utilisateur' est indisponible.",
+    "failed_to_install": "Échec de l'installation. Réessayer.",
+    "failed_to_login_after_install": "Connexion échouée. Redirection vers le formulaire de connexion..."
+  },
+  "breaking_changes": {
+    "v346_using_basic_auth": "Le protocole Basic Authentication <strong>ne sera plus disponible</strong>. Retirer les paramètres de %s"
+  },
+  "page_register": {
+    "send_email": "Envoyer courriel",
+    "notice": {
+      "restricted": "Approbation requise.",
+      "restricted_defail": "Lorsque un administrateur aura approuvé votre accès, vous pourrez vous connecter."
+    },
+    "form_help": {
+      "email": "Votre adresse courriel doit faire partie de la liste autorisée.",
+      "password": "Votre mot de passe doit posséder un minimun de {{target}} caractères.",
+      "user_id": "L'URL des pages crées contient votre identifiant utilisateur. L'identifiant utilisateur peut contenir des lettres, chiffres et certains symboles."
+    }
+  },
+  "page_me": {
+    "form_help": {
+      "profile_image1": "Configuration du téléversement des images incomplète.",
+      "profile_image2": "Configurer AWS ou activer le stockage local."
+    }
+  },
+  "page_me_apitoken": {
+    "api_token": "Jeton API",
+    "notice": {
+      "apitoken_issued": "Aucun jeton d'API existant.",
+      "update_token1": "Un nouveau jeton peut être généré.",
+      "update_token2": "Modifiez le jeton aux endroits où celui-ci est utilisé."
+    },
+    "form_help": {}
+  },
+  "Password": "Mot de passe",
+  "Password Settings": "Paramètres de mot passe",
+  "personal_settings": {
+    "disassociate_external_account": "Dissocier compte externe",
+    "disassociate_external_account_desc": "Dissocier le compte externe <strong>{{providerType}}</strong> <strong>{{accountId}}</strong>?",
+    "set_new_password": "Modifier mot de passe",
+    "update_password": "Modifier mot de passe",
+    "current_password": "Mot de passe actuel",
+    "new_password": "Nouveau mot de passe",
+    "new_password_confirm": "Saisir de nouveau",
+    "password_is_not_set": "Mot de passe non saisi"
+  },
+  "share_links": {
+    "Shere this page link to public": "Partager cette page publiquement",
+    "share_link_list": "Liens de partage",
+    "share_link_management": "Gestion des liens de partage",
+    "delete_all_share_links": "Supprimer tout les liens de partage",
+    "expire": "Expiration",
+    "Days": "Jour",
+    "Custom": "Autre",
+    "description": "Description",
+    "enter_desc": "Entrer description",
+    "Unlimited": "illimité",
+    "Issue": "Partager",
+    "share_settings": "Paramètres de partage",
+    "Invalid_Number_of_Date": "Valeurs invalides",
+    "link_sharing_is_disabled": "Le partage est désactivé"
+  },
+  "API Settings": "Configuration API",
+  "Other Settings": "Autres paramètres",
+  "API Token Settings": "Paramètres de jetons",
+  "Current API Token": "Jeton d'API actuel",
+  "Update API Token": "Modifier jeton",
+  "in_app_notification_settings": {
+    "in_app_notification_settings": "Paramètres de notifications",
+    "subscribe_settings": "Paramètres d'abonnement automatique aux notifications de pages",
+    "default_subscribe_rules": {
+      "page_create": "S'abonner à la page lors de sa création."
+    }
+  },
+  "ui_settings": {
+    "ui_settings": "Paramètres UI",
+    "side_bar_mode": {
+      "settings": "Paramètres navigation latérale",
+      "side_bar_mode_setting": "Activer la navigation latérale",
+      "description": "Activer pour toujours afficher la barre de navigation latérale lorsque l'écran est large. Si la largeur d'écran est faible, le cas inverse est applicable."
+    }
+  },
+  "color_mode_settings": {
+    "light": "Clair",
+    "dark": "Sombre",
+    "system": "Système",
+    "settings": "Paramètres de thème",
+    "description": "Affichage en mode clair, sombre ou selon les paramètres système.<br>Seuls les thèmes supportés seront modifiés."
+  },
+  "editor_settings": {
+    "editor_settings": "Paramètres de l'éditeur"
+  },
+  "search_help": {
+    "title": "Aide",
+    "and": {
+      "syntax help": "séparer avec un espace",
+      "desc": "Pages incluant {{word1}}, {{word2}} dans le titre ou le corps"
+    },
+    "exclude": {
+      "desc": "Pages excluant {{word}} dans le titre ou le corps"
+    },
+    "phrase": {
+      "syntax help": "entourer de guillemets",
+      "desc": "Pages incluant \"{{phrase}}\""
+    },
+    "prefix": {
+      "desc": "Pages dont le titre débute par {{path}}"
+    },
+    "exclude_prefix": {
+      "desc": "Pages dont le titre ne débute pas par {{path}}"
+    },
+    "tag": {
+      "desc": "Pages ayant l'étiquette {{tag}}"
+    },
+    "exclude_tag": {
+      "desc": "Pages n'ayant pas l'étiquette {{tag}}"
+    }
+  },
+  "search": {
+    "search page bodies": "Faire la touche [Enter] pour de la recherche textuelle"
+  },
+  "page_page": {
+    "notice": {
+      "version": "Version historique de la page.",
+      "redirected": "Redirection depuis",
+      "redirected_period": ".",
+      "unlinked": "Les pages de redirection à cette page ont été supprimées.",
+      "restricted": "L'accès à cette page est restreint",
+      "stale": "Plus de {{count}} an est passé depuis la dernière mise à jour.",
+      "stale_plural": "Plus de {{count}} années sont passées depuis la dernière mise à jour.",
+      "expiration": "Ce lien expirera <strong>{{expiredAt}}</strong>.",
+      "no_deadline": "Cette page n'a pas de date d'expiration"
+    }
+  },
+  "page_edit": {
+    "input_channels": "Canal Slack...",
+    "theme": "Thème",
+    "keymap": "Touches",
+    "indent": "Indentation",
+    "editor_config": "Configuration de l'éditeur",
+    "Show active line": "Montrer la ligne active",
+    "auto_format_table": "Formattage les tables",
+    "overwrite_scopes": "{{operation}} et écraser les scopes des pages enfants",
+    "notice": {
+      "conflict": "Sauvegarde impossible, la page est en cours de modification par un autre utilisateur. Recharger la page."
+    },
+    "changes_not_saved": "Les modifications n'ont pas été sauvegardées. Fermer?"
+  },
+  "page_comment": {
+    "comments": "Commentaires",
+    "comment": "Commmenter",
+    "preview": "Prévisualiser",
+    "write": "Écrire",
+    "add_a_comment": "Ajouter un commentaire",
+    "display_the_page_when_posting_this_comment": "Afficher la page en postant le commentaire",
+    "no_user_found": "Aucun utilisateur trouvé",
+    "reply": "Répondre",
+    "delete_comment": "Supprimer?"
+  },
+  "page_api_error": {
+    "notfound_or_forbidden": "Page originale introuvable ou accès restreint.",
+    "already_exists": "Une page avec ce chemin existe déjà.",
+    "outdated": "Page obsolète.",
+    "user_not_admin": "Seul un administrateur peut supprimer la page",
+    "single_deletion_empty_pages": "Une page vide ne peut pas être supprimée",
+    "complete_deletion_not_allowed_for_user": "Vous n'êtes pas autorisé à supprimer cette page"
+  },
+  "page_history": {
+    "revision_list": "Historique des modifications",
+    "revision": "version",
+    "comparing_source": "Source",
+    "comparing_target": "Destination",
+    "comparing_revisions": "Comparer les modifications",
+    "compare_latest": "Comparer avec la version la plus récente",
+    "compare_previous": "Compare avec une version précédente"
+  },
+  "modal_rename": {
+    "label": {
+      "Move/Rename page": "Déplacer/renommer page",
+      "New page name": "Nom de la page",
+      "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
+      "Failed to get exist path": "échec de la récupération du chemin",
+      "Current page name": "Nom de la page courante",
+      "Rename this page only": "Renommer cette page",
+      "Force rename all child pages": "Forcer le renommage des pages",
+      "Other options": "Autres options",
+      "Do not update metadata": "Ne pas modifier les métadonnées",
+      "Redirect": "Rediriger"
+    },
+    "help": {
+      "redirect": "Rediriger vers la nouvelle page",
+      "metadata": "Conserve les métadonnées d'édition de la page",
+      "recursive": "Déplacer/renommer les pages enfants récursivement"
+    }
+  },
+  "Put Back": "Annuler",
+  "Delete Completely": "Supprimer définitivement",
+  "page_has_been_reverted": "{{path}} déplacement annulé",
+  "modal_delete": {
+    "delete_page": "Supprimer la page",
+    "deleting_page": "Suppression de la page",
+    "delete_recursively": "Supprimer les pages enfants.",
+    "delete_completely": "Supprimer définitivement",
+    "delete_completely_restriction": "Vous n'êtes pas autorisé à supprimer définitivement les pages",
+    "recursively": "Supprime toutes les pages sous ce chemin.",
+    "completely": "Supprime définitivement la page."
+  },
+  "deleted_page": "Déplacée dans la corbeille",
+  "deleted_pages": "{{path}} supprimées",
+  "deleted_pages_completely": "{{path}} supprimées définitivement",
+  "renamed_pages": "{{path}} renommée",
+  "empty_trash": "Corbeille vidée",
+  "modal_empty": {
+    "empty_the_trash": "Vider la corbeille",
+    "empty_the_trash_button": "Vider",
+    "not_deletable_notice": "Certaines pages ne peuvent pas être supprimées",
+    "notice": "Les pages supprimées définitivement ne sont pas récupérables."
+  },
+  "modal_duplicate": {
+    "label": {
+      "Duplicate page": "Dupliquer",
+      "New page name": "Nom de la page",
+      "Failed to get subordinated pages": "échec de récupération des pages subordinnées",
+      "Current page name": "Nom de la page courante",
+      "Recursively": "Récursivement",
+      "Duplicate without exist path": "Dupliquer sans le chemin existant",
+      "Same page already exists": "Une page avec ce chemin existe déjà",
+      "Only duplicate user related pages": "Seul les pages dupliquées auquelles vous avez accès"
+    },
+    "help": {
+      "recursive": "Dupliquer les pages enfants récursivement",
+      "only_inherit_user_related_groups": "Si la page est configuré en \"Seulement dans le groupe\", les groupes auxquels vous n'appartenez pas perdront l'accès aux pages dupliquées"
+    }
+  },
+  "duplicated_pages": "{{fromPath}} dupliquée",
+  "modal_putback": {
+    "label": {
+      "Put Back Page": "Annuler déplacement",
+      "recursively": "Annuler récursivement"
+    },
+    "help": {
+      "recursively": "Annuler récursivement pour les pages enfants"
+    }
+  },
+  "modal_shortcuts": {
+    "global": {
+      "title": "Raccourcis clavier",
+      "Open/Close shortcut help": "Ouvrir/fermer<br> l'aide aux raccourcis",
+      "Edit Page": "Modifier page",
+      "Create Page": "Créer page",
+      "Search": "Rechercher",
+      "Show Contributors": "Voir contributeurs",
+      "MirrorMode": "Mode mirroir",
+      "Konami Code": "Code Konami",
+      "konami_code_url": "https://en.wikipedia.org/wiki/Konami_Code"
+    },
+    "editor": {
+      "title": "Raccourcis d'édition",
+      "Indent": "Indentation",
+      "Outdent": "Retrait",
+      "Save Page": "Sauvegarder la page",
+      "Delete Line": "Supprimer la ligne"
+    },
+    "commentform": {
+      "title": "Raccourcis de commentaires",
+      "Post": "Poster"
+    }
+  },
+  "modal_resolve_conflict": {
+    "file_conflicting_with_newer_remote": "Ce fichier est en conflit avec une autre version",
+    "resolve_conflict_message": "Sélectionner le corps de la page",
+    "resolve_conflict": "Résoudre le conflit",
+    "resolve_and_save": "Résoudre et sauvegarder",
+    "select_revision": "Sélectionner {{revision}}",
+    "requested_revision": "moi",
+    "origin_revision": "origine",
+    "latest_revision": "les autres",
+    "selected_editable_revision": "Corps de page sélectionné (Modifiable)"
+  },
+  "link_edit": {
+    "edit_link": "Modifier lien",
+    "set_link_and_label": "Ajouter lien et étiquette",
+    "link": "Lien",
+    "placeholder_of_link_input": "Chemin de la page ou URL",
+    "label": "Étiquette",
+    "path_format": "Format",
+    "use_relative_path": "Chemin relatif",
+    "use_permanent_link": "Lien permanent",
+    "notation": "Notation",
+    "markdown": "Markdown",
+    "GROWI_original": "GROWI original",
+    "pukiwiki": "Pukiwiki",
+    "preview": "Prévisualiser",
+    "page_not_found_in_preview": "\"{{path}}\" n'est pas une page GROWI."
+  },
+  "toaster": {
+    "file_upload_failed": "Échec du téléversement.",
+    "initialize_successed": "Initialisation de {{target}} réussie",
+    "remove_share_link_success": "Suppression de {{shareLinkId}} réussie",
+    "issue_share_link": "Lien de partage ajouté",
+    "remove_share_link": "{{count}} liens de partage supprimés",
+    "switch_disable_link_sharing_success": "Paramètres des liens de partage modifiés",
+    "failed_to_reset_password": "Échec de la réinitialisation du mot de passe",
+    "save_succeeded": "Sauvegarde réussie"
+  },
+  "template": {
+    "modal_label": {
+      "Select template": "Sélectionner modèle",
+      "Create/Edit Template Page": "Créer/modifier page modèle",
+      "Create template under": "Créer une page modèle enfant"
+    },
+    "option_label": {
+      "create/edit": "Créer/modifier page modèle",
+      "select": "Sélectionner type de page modèle"
+    },
+    "children": {
+      "label": "Modèle pour page enfant",
+      "desc": "Applicable aux pages de même niveau que la page modèle"
+    },
+    "decendants": {
+      "label": "Modèle pour descendants",
+      "desc": "Applicable aux page descendantes"
+    }
+  },
+  "sandbox": {
+    "header": "En-tête",
+    "header_x": "En-tête {{index}}",
+    "block": "Paragraphe",
+    "block_detail": "fait un paragraphe",
+    "empty_line": "Ligne vide",
+    "line_break": "Saut de ligne",
+    "line_break_detail": "(2 espaces) fait un saut de ligne",
+    "typography": "Typographie",
+    "italics": "Italique",
+    "bold": "Gras",
+    "italic_bold": "Gras italique",
+    "strikethrough": "Barré",
+    "link": "Lien",
+    "code_highlight": "Surlignage de code",
+    "list": "Liste",
+    "unordered_list_x": "Liste non ordonnée {{index}}",
+    "ordered_list_x": "Liste ordonnée {{index}}",
+    "task": "Tâche",
+    "task_checked": "Coché",
+    "task_unchecked": "Décoché",
+    "quote": "Citation",
+    "quote1": "Il est possible d'écrire ",
+    "quote2": "des citations sur plusieurs lignes",
+    "quote_nested": "Citation imbriquée",
+    "table": "Table",
+    "image": "Image",
+    "alt_text": "Texte alternatif",
+    "insert_image": "insère une image",
+    "open_sandbox": "Ouvrir le bac à sable"
+  },
+  "slack_notification": {
+    "popover_title": "Notifications Slack",
+    "popover_desc": "Entrer le nom du canal. Plusieurs canaux peuvent être notifiés en entrant leur noms séparés d'une virgule",
+    "input_channels": "Input channels"
+  },
+  "search_result": {
+    "title": "Recherche",
+    "result_meta": "Résultats de recherche pour:",
+    "deletion_mode_btn_lavel": "Sélectionner et supprimer la page",
+    "cancel": "Annuler",
+    "delete": "Supprimer",
+    "check_all": "Tout cocher",
+    "deletion_modal_header": "Supprimer page",
+    "delete_completely": "Supprimer définitivement",
+    "include_certain_path": "Inclure le chemin {{pathToInclude}} ",
+    "delete_all_selected_page": "Tout supprimer",
+    "currently_not_implemented": "Non implémenté",
+    "search_again": "Rechercher",
+    "number_of_list_to_display": "Afficher",
+    "page_number_unit": "pages",
+    "hit_number_unit": "trouvé",
+    "sort_axis": {
+      "relationScore": "Trier par pertinence",
+      "createdAt": "Date de création",
+      "updatedAt": "Dernière modification"
+    }
+  },
+  "private_legacy_pages": {
+    "title": "Anciennes pages privées",
+    "bulk_operation": "Opération de masse",
+    "convert_all_selected_pages": "Convertir au nouveau format V5",
+    "input_path_to_convert": "Entrer un chemin pour convertir les pages",
+    "alert_title": "Des pages au format V4 existent",
+    "alert_desc1": "Sélectionner les pages à convertir vers le format V5 avec le bouton \"Opération de masse\".",
+    "nopages_title": "GROWI V5 est maintenant utilisable!",
+    "nopages_desc1": "Toutes les pages ont été converties au format V5.",
+    "detail_info": "Pour plus de détails, voir <a href='https://docs.growi.org/en/admin-guide/upgrading/50x.html' target='_blank' class='alert-link'>Convertir vers GROWI v5.0.x <span className='growi-custom-icons'>external_link</span></a>.",
+    "modal": {
+      "title": "Convertir au format V5",
+      "converting_pages": "Conversion des pages",
+      "convert_recursively_label": "Convertir les pages enfants récursivement",
+      "convert_recursively_desc": "Convertir les pages sous ce chemin récursivement.",
+      "button_label": "Convertir"
+    },
+    "toaster": {
+      "page_migration_succeeded": "Conversion des pages sélectionnées au format V5 réussie.",
+      "page_migration_failed_with_paths": "Conversion de {{paths}} au format V5 échouée.",
+      "page_migration_failed": "Échec de la conversion au format V5."
+    },
+    "by_path_modal": {
+      "title": "Convertir au format V5",
+      "alert": "Cette opération est irréversible et il est possible que certaines pages non visible soit traitées.",
+      "checkbox_label": "Compris",
+      "description": "Entrer un chemin, toutes les pages sous ce chemin seront converties au format V5.",
+      "button_label": "Convertir",
+      "success": "Demande de conversion envoyée.",
+      "error": "Échec de la demande de conversion.",
+      "error_grant_invalid": "Les permissions de la page sont invalides.",
+      "error_page_not_found": "Page introuvable.",
+      "error_duplicate_pages_found": "Plusieurs pages au même chemin ont été trouvées, renommez ou supprimez puis réessayez."
+    }
+  },
+  "login": {
+    "title": "Connexion",
+    "sign_in_error": "Erreur de connexion",
+    "registration_successful": "Inscription réussie. Demander l'approbation d'un administrateur.",
+    "Setup": "Configuration",
+    "enabled_ldap_has_configuration_problem": "LDAP actif, vérifier la configuration.",
+    "set_env_var_for_logs": "(Remplir les variables d'environnement <code>DEBUG=crowi:service:PassportService</code> pour obtenir les journaux d'erreur)"
+  },
+  "invited": {
+    "title": "Invité",
+    "discription_heading": "Créer un compte",
+    "discription": "Créer un compte avec votre adresse courriel invitée"
+  },
+  "export_bulk": {
+    "failed_to_export": "Échec de l'export",
+    "failed_to_count_pages": "Échec du compte des pages",
+    "export_page_markdown": "Exporter la page en Markdown",
+    "export_page_pdf": "Exporter la page en PDF"
+  },
+  "message": {
+    "successfully_connected": "Connecté!",
+    "fail_to_save_access_token": "Échec de la sauvegarde de access_token.",
+    "fail_to_fetch_access_token": "Échec de la récupération de access_token.",
+    "successfully_disconnected": "Déconnecté!",
+    "strategy_has_not_been_set_up": "{{strategy}} n'est pas configuré",
+    "ldap_user_not_valid": "Utilisateur LDAP invalide",
+    "external_account_not_exist": "Compte externe introuvable",
+    "maximum_number_of_users": "Le nombre maximum d'utilisateurs est atteint.",
+    "sign_in_failure": "Échec de la connexion.",
+    "aws_sttings_required": "La configuration AWS est requise pour utiliser cette fonctionnalité.",
+    "application_already_installed": "Application déja installée.",
+    "email_address_could_not_be_used": "Cette adresse courriel n'est pas autorisée",
+    "user_id_is_not_available": "Cet identifiant utilisateur est indisponible.",
+    "username_should_not_be_null": "Le nom d'utilisateur est requis",
+    "email_address_is_already_registered": "Cette adresse courriel est indisponible.",
+    "can_not_register_maximum_number_of_users": "Le nombre maximum d'utilisateurs est atteint.",
+    "email_settings_is_not_setup": "La configuration d'envoi de courriels est incomplète.",
+    "email_authentication_is_not_enabled": "L'authentification par adresse courriel est désactivée.",
+    "failed_to_register": "Échec de l'inscription.",
+    "successfully_created": "Utilisateur {{username}} crée.",
+    "can_not_activate_maximum_number_of_users": "Ne peut activer au dessus du nombre maximal d'utilisateur.",
+    "failed_to_activate": "Échec de l'activation.",
+    "unable_to_use_this_user": "Impossible d'utiliser cet utilisateur",
+    "complete_to_install1": "Complétez pour installer GROWI! Connectez vous en tant qu'administrateur",
+    "complete_to_install2": "Complétez pour installer GROWI! Vérifier les paramètres de la page.",
+    "failed_to_create_admin_user": "Échec de création de l'administrateur. {{errMessage}}",
+    "successfully_send_email_auth": "Un courriel a été envoyé {{email}}. Ouvrez l'URL dans ce courriel et complétez l'inscription.",
+    "incorrect_token_or_expired_url": "Le jeton est invalide ou l'URL est expirée.",
+    "user_already_logged_in": "Vous ne pouvez pas créer de nouveau compte en étant connecté.",
+    "registration_closed": "Création de nouveau compte non-autorisée.",
+    "Username has invalid characters": "Le nom d'utilisateur contient des caractères invalides.",
+    "Username field is required": "Identifiant utilisateur requis.",
+    "Name field is required": "Nom requis.",
+    "Email format is invalid": "Format d'adresse courriel invalide.",
+    "Email field is required": "Adresse courriel requise.",
+    "Password has invalid character": "Le mot de passe contient des caractères invalides",
+    "Password minimum character should be more than 8 characters": "Le mot de passe doit contenir plus de 8 caractères.",
+    "Password field is required": "Mot de passe requis.",
+    "Username or E-mail has invalid characters": "Le nom d'utilisateur ou l'adresse courriel contient des caractères invalides",
+    "Password minimum character should be more than 6 characters": "Le mot de passe doit contenir au moins 6 caractères.",
+    "user_not_found": "Utilisateur introuvable.",
+    "provider_duplicated_username_exception": "<p><strong><span class='material-symbols-outlined me-1'>cancel</span>DuplicatedUsernameException</strong></p><p class='mb-0'> L'authentification est réussie pour {{ failedProviderForDuplicatedUsernameException }} , mais la création d'un utilisateur a échouée. Voir <a href='https://github.com/weseek/growi/issues/193'>#193</a>.</p>"
+  },
+  "grid_edit": {
+    "create_bootstrap_4_grid": "Créer grille Bootstrap 4",
+    "grid_settings": "Paramètres de la grille",
+    "grid_pattern": "Motif de grille",
+    "division": "Divisions",
+    "smart_no": "Mobile / Pas de point d'arrêt",
+    "break_point": "Point d'arrêt par taille d'écran"
+  },
+  "validation": {
+    "aws_region": "Entrer la région AWS ex):us-east-1",
+    "aws_custom_endpoint": "Pour l'option personnalisée, spécifier le protocole http(s)://.",
+    "failed_to_send_a_test_email": "Échec de l'envoi du courriel d'essai, vérifier la configuration."
+  },
+  "forgot_password": {
+    "forgot_password": "Mot de passe oublié?",
+    "send": "Envoyer",
+    "return_to_login": "Retour vers la connexion",
+    "reset_password": "Réinitialiser mot de passe",
+    "sign_in_instead": "Se connecter",
+    "password_reset_request_desc": "Le mot de passe est réinitialisable ici.",
+    "password_reset_excecution_desc": "Entrer un nouveau mot de passe",
+    "new_password": "Nouveau mot de passe",
+    "confirm_new_password": "Confirmer le nouveau mot de passe",
+    "email_is_required": "Adresse courriel requise",
+    "success_to_send_email": "Courriel envoyé",
+    "feature_is_unavailable": "Fonctionnalité indisponible.",
+    "incorrect_token_or_expired_url": "Le jeton est invalide ou l'URL est expirée. Effectuer une nouvelle réinitialisation via le lien ci-dessous.",
+    "password_and_confirm_password_does_not_match": "Le mot de passe ne correspond pas",
+    "please_enable_mailer_alert": "La réinitialisation de mot de passe est désactivée, car la configuration d'envois de courriels est incomplète."
+  },
+  "emoji": {
+    "title": "Choisir un émoji",
+    "search": "Rechercher",
+    "clear": "Vider",
+    "notfound": "Aucun émoji trouvé",
+    "skintext": "Choisir le teint par défaut",
+    "categories": {
+      "search": "Résultats de recherche",
+      "recent": "Récents",
+      "smileys": "Émotions",
+      "people": "Individus & corps",
+      "nature": "Animaux & nature",
+      "foods": "Nourriture & boisson",
+      "activity": "Activités",
+      "places": "Voyage",
+      "objects": "Objets",
+      "symbols": "Symboles",
+      "flags": "Drapeaux",
+      "custom": "Personnalisé"
+    },
+    "categorieslabel": "Catégories d'émojis",
+    "skintones": {
+      "1": "Teint par défaut",
+      "2": "Teint clair",
+      "3": "Teint moyen-clair",
+      "4": "Teint moyen",
+      "5": "Teint moyen-foncé",
+      "6": "Teint foncé"
+    }
+  },
+  "maintenance_mode": {
+    "maintenance_mode": "Mode maintenance",
+    "growi_is_under_maintenance": "GROWI est actuellement en maintenance.",
+    "admin_page": "Administration",
+    "login": "Connexion",
+    "logout": "Déconnexion"
+  },
+  "pagetree": {
+    "cannot_rename_a_title_that_contains_slash": "Renommage impossible lorsque le titre contient '/'",
+    "you_cannot_move_this_page_now": "Déplacement de la page impossible",
+    "something_went_wrong_with_moving_page": "Échec de déplacement de la page"
+  },
+  "duplicated_page_alert": {
+    "same_page_name_exists": "Une page avec ce nom 「{{pageName}}」 existe déjà",
+    "same_page_name_exists_at_path": "Une page avec ce nom {{pageName}} existe déjà {{path}} ",
+    "select_page_to_see": "Sélectionner une page"
+  },
+  "user_group": {
+    "select_group": "Sélectionner groupe",
+    "belonging_to_no_group": "Appartenance au groupe introuvable.",
+    "manage_user_groups": "Gestion des groupes utilisateurs"
+  },
+  "fix_page_grant": {
+    "modal": {
+      "no_grant_available": "La liste de permissions est introuvable. Modifier les permissions de la page parent et réessayer.",
+      "need_to_fix_grant": "Les permissions associées a la page doivent être modifiées. <br> Sélectionner parmi les options ci-dessous.",
+      "grant_label": {
+        "public": "Public",
+        "isForbidden": "Non autorisé",
+        "currentPageGrantLabel": "Autorisations de la page: ",
+        "parentPageGrantLabel": "Autorité de la page parent: ",
+        "docLink": "Pour plus d'informations sur la modifications de permissions, se référer <a href='https://docs.growi.org/en/guide/features/authority.html#permissions-for-subordinate-pages'>こちらのリンク</a>"
+      },
+      "radio_btn": {
+        "restrected": "Avec le lien",
+        "only_me": "seulement moi",
+        "grant_group": "Seulement les groupes spécifiés"
+      },
+      "select_group_default_text": "Sélectionner groupe",
+      "alert_message_select_group": "Aucun groupe sélectionné",
+      "btn_label": "Conversion",
+      "title": "Modifier autorité"
+    },
+    "alert": {
+      "description": "Les permissions de cette page doivent être modifiées.",
+      "btn_label": "Révision"
+    }
+  },
+  "tooltip": {
+    "like": "Like!",
+    "cancel_like": "Annuler",
+    "bookmark": "Favori",
+    "cancel_bookmark": "Annuler favori",
+    "receive_notifications": "Recevoir les notifications",
+    "stop_notification": "Stopper les notifications",
+    "footprints": "Visiteurs",
+    "operation": {
+      "attention": {
+        "rename": "Échec du renommage du chemin des pages descendantes, ouvrir le menu du lecteur 3-points et sélectionner 'Récupération du chemin'"
+      }
+    }
+  },
+  "page_operation": {
+    "paths_recovered": "Chemin récupéré",
+    "path_recovery_failed": "Échec de la récupération du chemin"
+  },
+  "user_home_page": {
+    "bookmarks": "Favoris",
+    "recently_created": "Crée récemment"
+  },
+  "bookmark_folder": {
+    "bookmark_folder": "dossier de favoris",
+    "bookmark": "favoris",
+    "delete_modal": {
+      "modal_header_label": "Supprimer dossier de favoris",
+      "modal_body_description": "Supprimer le dossier de favoris et son contenu",
+      "modal_body_alert": "Le contenu d'un dossier supprimé n'est pas récupérable",
+      "modal_footer_button": "Supprimer dossier"
+    },
+    "input_placeholder": "Nom du dossier",
+    "new_folder": "Nouveau dossier",
+    "delete": "Supprimer dossier",
+    "drop_item_here": "Glisser-déposer ici",
+    "cancel_bookmark": "Retirer le favori",
+    "move_to_root": "Déplacer à la racine",
+    "root": "racine (par défaut)"
+  },
+  "v5_page_migration": {
+    "page_tree_not_avaliable": "Cette fonctionnalité n'est pas encore disponible.",
+    "go_to_settings": "Activer cette fonctionnalité dans les paramètres"
+  },
+  "questionnaire": {
+    "give_us_feedback": "Faites-nous part de votre avis",
+    "thank_you_for_answering": "Merci pour votre réponse",
+    "additional_feedback": "Envoyez-nous votre avis depuis le menu déroulant sur le menu utilisateur.",
+    "dont_show_again": "Ne plus afficher",
+    "deny": "Ne pas répondre",
+    "agree": "En accord",
+    "disagree": "En désaccord",
+    "answer": "Répondre",
+    "no_answer": "Aucune réponse",
+    "settings": "Configuration du questionnaire",
+    "failed_to_send": "Échec de l'envoi du questionnaire",
+    "denied": "Le questionnaire ne sera plus montré",
+    "personal_settings_explanation": "Les questionnaires de satisfaction seront actifs.",
+    "enable_questionnaire": "Activer questionnaire",
+    "disabled_by_admin": "Questionnaire désactivé par l'administrateur"
+  },
+  "tag_edit_modal": {
+    "edit_tags": "Modifier étiquettes",
+    "done": "Terminer",
+    "tags_input": {
+      "tag_name": "nom de l'étiquette"
+    }
+  },
+  "delete_attachment_modal": {
+    "confirm_delete_attachment": "Supprimer pièce jointe?"
+  },
+  "rich_attachment": {
+    "attachment_not_be_found": "Pièce jointe introuvable"
+  },
+  "page_select_modal": {
+    "select_page_location": "Sélectionner emplacement de la page"
+  },
+  "wip_page": {
+    "save_as_wip": "Sauvegarder comme brouillon",
+    "success_save_as_wip": "Sauvegardée en tant que brouillon",
+    "fail_save_as_wip": "Échec de la sauvegarde du brouillon",
+    "alert": "Page en cours d'écriture",
+    "publish_page": "Publier page",
+    "success_publish_page": "Page publiée",
+    "fail_publish_page": "Échec de publication de la page"
+  },
+  "sidebar_header": {
+    "show_wip_page": "Voir brouillon",
+    "size_s": "Taille: P",
+    "size_l": "Taille: G"
+  }
+}

+ 4 - 2
apps/app/public/static/locales/ja_JP/commons.json

@@ -63,7 +63,8 @@
     "no_notification": "通知はありません",
     "all": "全て",
     "unopend": "未読",
-    "mark_all_as_read": "全て既読にする"
+    "mark_all_as_read": "全て既読にする",
+    "only_unread": "未読のみ"
   },
 
   "personal_dropdown": {
@@ -95,7 +96,8 @@
     "Page URL": "ページURL",
     "Permanent link": "パーマリンク",
     "Page path and permanent link": "ページ名とパーマリンク",
-    "Markdown link": "マークダウン形式のリンク"
+    "Markdown link": "マークダウン形式のリンク",
+    "Append params": "パラメータの追加"
   },
 
   "crop_image_modal": {

+ 19 - 4
apps/app/public/static/locales/ja_JP/translation.json

@@ -57,7 +57,7 @@
   "Timeline View": "タイムライン",
   "History": "更新履歴",
   "attachment_data": "添付データ",
-  "No_attachments_yet": "No attachments yet.",
+  "No_attachments_yet": "添付データはありません",
   "Presentation Mode": "プレゼンテーション",
   "Not available for guest": "ゲストユーザーは利用できません",
   "Not available in this version": "このバージョンでは利用できません",
@@ -83,6 +83,7 @@
   "Go to this version": "このバージョンを見る",
   "View diff": "差分を表示",
   "No diff": "差分なし",
+  "Latest": "最新",
   "User ID": "ユーザーID",
   "User Settings": "ユーザー設定",
   "User Information": "ユーザー情報",
@@ -125,6 +126,7 @@
   "Only me": "自分のみ",
   "Only inside the group": "特定グループのみ",
   "page_list": "ページリスト",
+  "comments": "コメント",
   "Reselect the group": "グループの再選択",
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
@@ -148,6 +150,7 @@
   "wide_view": "ワイドビュー",
   "Recent Changes": "最新の変更",
   "Page Tree": "ページツリー",
+  "Bookmarks": "ブックマーク",
   "In-App Notification": "通知",
   "original_path": "元のパス",
   "new_path": "新しいパス",
@@ -174,6 +177,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "このページの配下にはページが存在しません。"
   },
+  "author_info": {
+    "created_at": "作成日",
+    "last_revision_posted_at": "最終更新日"
+  },
   "installer": {
     "tab": "アカウント作成",
     "title": "インストーラー",
@@ -361,8 +368,15 @@
     "changes_not_saved": "変更が保存されていない可能性があります。本当に移動しますか?"
   },
   "page_comment": {
+    "comments": "コメント",
+    "comment": "コメント",
+    "preview": "プレビュー",
+    "write": "入力",
+    "add_a_comment": "コメントを追加",
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",
-    "no_user_found": "ユーザー名が見つかりません"
+    "no_user_found": "ユーザー名が見つかりません",
+    "reply": "返信",
+    "delete_comment": "コメントを削除しますか?"
   },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",
@@ -562,7 +576,8 @@
   },
   "slack_notification": {
     "popover_title": "Slack 通知",
-    "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。"
+    "popover_desc": "チャンネル名を入れてください。カンマ区切りのリストを入力することで複数のチャンネルに通知することができます。",
+    "input_channels": "チャンネル名"
   },
   "search_result": {
     "title": "検索",
@@ -810,7 +825,7 @@
     "paths_recovered": "パスを修復しました",
     "path_recovery_failed": "パスを修復できませんでした"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "ブックマーク",
     "recently_created": "最近作成したページ"
   },

+ 4 - 2
apps/app/public/static/locales/zh_CN/commons.json

@@ -64,7 +64,8 @@
     "no_notification": "您没有任何通知",
     "all": "全部",
     "unopend": "未读",
-    "mark_all_as_read" : "标记为已读"
+    "mark_all_as_read" : "标记为已读",
+    "only_unread": "Only unread"
   },
 
   "personal_dropdown": {
@@ -96,7 +97,8 @@
 		"Page URL": "页面Url",
 		"Parmanent link": "参数化链接",
 		"Page path and parmanent link": "页面路径及参数化链接",
-		"Markdown link": "Markdown链接"
+		"Markdown link": "Markdown链接",
+    "Append params": "Append params"
 	},
 
   "crop_image_modal": {

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

@@ -85,6 +85,7 @@
   "Go to this version": "查看此版本",
   "View diff": "查看差异",
   "No diff": "无差异",
+  "Latest": "最新",
   "User ID": "用户ID",
   "Home": "首页",
   "My Drafts": "My Drafts",
@@ -133,6 +134,7 @@
   "Only me": "只有我",
   "Only inside the group": "仅组内",
   "page_list": "Page List",
+  "comments": "Comments",
   "Reselect the group": "重新选择组",
   "Shareable link": "可分享链接",
   "The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
@@ -153,6 +155,7 @@
   "wide_view": "视野开阔",
   "Recent Changes": "最新修改",
   "Page Tree": "页面树",
+  "Bookmarks": "书签",
   "In-App Notification": "通知",
   "original_path": "Original path",
   "new_path": "New path",
@@ -180,6 +183,10 @@
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
   },
+  "author_info": {
+    "created_at": "Created at",
+    "last_revision_posted_at": "Last revision posted at"
+  },
   "installer": {
     "tab": "创建账户",
     "title": "安装",
@@ -318,8 +325,15 @@
     "changes_not_saved": "您所做的更改可能不会保存。你真的想继续前进吗?"
   },
   "page_comment": {
+    "comments": "评论",
+    "comment": "评论",
+    "preview": "预览",
+    "write": "输入",
+    "add_a_comment": "Add a comment",
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
-    "no_user_found": "未找到用户名"
+    "no_user_found": "未找到用户名",
+    "reply": "Reply",
+    "delete_comment": "Delete comment?"
   },
   "page_api_error": {
     "notfound_or_forbidden": "未找到或禁止原始页。",
@@ -516,7 +530,8 @@
   },
   "slack_notification": {
     "popover_title": "Slack Notification",
-    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list."
+    "popover_desc": "Input channel name. You can notify multiple channels by entering a comma-separated list.",
+    "input_channels": "Input channels"
   },
   "share_links": {
     "Shere this page link to public": "Shere this page link to public",
@@ -780,7 +795,7 @@
     "paths_recovered": "成功恢复了页面路径",
     "path_recovery_failed": "路径恢复失败"
   },
-  "footer": {
+  "user_home_page": {
     "bookmarks": "书签",
     "recently_created": "最近创建页面"
   },

+ 14 - 0
apps/app/resource/locales/fr_FR/admin/userInvitation.ejs

@@ -0,0 +1,14 @@
+Bonjour, <%- email %>
+
+Vous avez été invité au Wiki, vous pouvez vous connectez avec les identifiants suivants:
+
+Adresse courriel: <%- email %>
+Mot de passe: <%- password %>
+(Ce mot de passe est généré automatiquement. Une modification du mot de passe est requise lors de la connexion initiale)
+
+Nous vous attendons avec impatience!
+<%- url %>
+
+--
+<%- appTitle %>
+<%- url %>

+ 11 - 0
apps/app/resource/locales/fr_FR/admin/userResetPassword.ejs

@@ -0,0 +1,11 @@
+Bonjour, <%- email %>
+
+Votre mot de passe a été réinitialisé par l'administrateur, vous pouvez vous connectez avec les identifiants suivants:
+
+Adresse courriel: <%- email %>
+Nouveau mot de passe: <%- password %>
+(Ce mot de passe est généré automatiquement. Une modification du mot de passe est requise lors de la connexion initiale)
+
+--
+<%- appTitle %>
+<%- url %>

+ 20 - 0
apps/app/resource/locales/fr_FR/admin/userWaitingActivation.ejs

@@ -0,0 +1,20 @@
+Bonjour, <%- adminUser.name %>
+
+Un nouvel utilisateur s'est inscrit à <%- appTitle %>.
+
+
+====
+Informations sur l'utilisateur:
+
+Nom: <%- createdUser.name %>
+Nom d'utilisateur: <%- createdUser.username %>
+Adresse courriel: <%- createdUser.email %>
+====
+
+Pour effectuer une action, voir:
+<%- url %>/admin/users
+
+
+--
+<%- appTitle %>
+<%- url %>

+ 9 - 0
apps/app/resource/locales/fr_FR/notifications/comment.ejs

@@ -0,0 +1,9 @@
+<%- username %> à commenté sur <%- path %>.
+
+----------------------
+
+<%- comment %>
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 13 - 0
apps/app/resource/locales/fr_FR/notifications/notActiveUser.ejs

@@ -0,0 +1,13 @@
+Réinitialisation du mot de passe
+
+Bonjour, <%- email %>
+
+Une demande de réinitialisation de mot de passe a été demandée depuis <%- appTitle %>.
+Cette adresse courriel n'est pas enregistré. Réessayez avec une adresse courriel différente.
+
+Si vous n'avez pas demandé de réinitialisation de mot de passe, ignorez ce courriel.
+
+-------------------------------------------------------------------------
+
+GROWI: <%- appTitle %>
+URL: <%- url %>

+ 5 - 0
apps/app/resource/locales/fr_FR/notifications/pageCreate.ejs

@@ -0,0 +1,5 @@
+<%- username %> a crée une page <%- path %>.
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 5 - 0
apps/app/resource/locales/fr_FR/notifications/pageDelete.ejs

@@ -0,0 +1,5 @@
+<%- username %> à supprimé la page <%- path %>.
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 5 - 0
apps/app/resource/locales/fr_FR/notifications/pageEdit.ejs

@@ -0,0 +1,5 @@
+<%- username %> a modifié la page <%- path %>.
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 5 - 0
apps/app/resource/locales/fr_FR/notifications/pageLike.ejs

@@ -0,0 +1,5 @@
+<%- username %> a aimé la page <%- path %>.
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 5 - 0
apps/app/resource/locales/fr_FR/notifications/pageMove.ejs

@@ -0,0 +1,5 @@
+<%- username %> a renommé la page <%- oldPath %> en <%- newPath %>.
+
+----------------------
+
+GROWI: <%- appTitle %>

+ 12 - 0
apps/app/resource/locales/fr_FR/notifications/passwordReset.ejs

@@ -0,0 +1,12 @@
+Réinitialisation du mot de passe
+
+Bonjour, <%- email %>
+
+Une demande de réinitialisation de mot de passe a été demandée pour votre compte GROWI (<%- appTitle %>).
+Pour réinitialiser votre mot de passe, cliquer sur le lien ci-dessous.
+
+<%- url %>
+
+Ce lien a une durée de vie de 10 minutes, expirant le <%- expiredAt %>.
+
+Si vous n'avez pas demandé de réinitialisation de mot de passe, ignorez ce courriel.

+ 8 - 0
apps/app/resource/locales/fr_FR/notifications/passwordResetSuccessful.ejs

@@ -0,0 +1,8 @@
+Réinitialisation du mot de passe réussie
+
+Bonjour, <%- email %>
+
+Votre mot de passe a été réinitialisé.
+Connectez-vous avec votre nouveau mot de passe
+
+Merci,

+ 12 - 0
apps/app/resource/locales/fr_FR/notifications/userActivation.ejs

@@ -0,0 +1,12 @@
+Confirmation de compte
+
+Bonjour, <%- email %>
+
+Un compte a été crée pour GROWI (<%- appTitle %>).
+Pour activer votre compte, cliquez sur le lien ci-dessous.
+
+<%- url %>
+
+Le lien a une durée de vie de 1 heure, expirant le <%- expiredAt %>.
+
+Si vous n'avez pas crée ce compte, vous pouvez ignorer ce courriel.

+ 169 - 0
apps/app/resource/locales/fr_FR/sandbox-bootstrap5.md

@@ -0,0 +1,169 @@
+# 1. Badges
+
+<span class="badge text-bg-primary">primary</span>  
+
+<span class="badge text-bg-secondary">secondary</span>  
+
+<span class="badge text-bg-success">success</span>  
+
+<span class="badge text-bg-danger">danger</span>  
+
+<span class="badge text-bg-warning">warning</span>  
+
+<span class="badge text-bg-info">info</span>  
+
+<span class="badge text-bg-light">light</span>  
+
+<span class="badge text-bg-dark">dark</span>  
+
+
+# 2. Alerts
+
+<div class="alert alert-primary" role="alert">
+  This is a primary alert.
+</div>
+
+<div class="alert alert-secondary" role="alert">
+  This is a secondary alert.
+</div>
+
+<div class="alert alert-success" role="alert">
+  This is a success alert.
+</div>
+
+<div class="alert alert-danger" role="alert">
+  This is a danger alert.
+</div>
+
+<div class="alert alert-warning" role="alert">
+  This is a warning alert.
+</div>
+
+<div class="alert alert-info" role="alert">
+  This is a info alert.
+</div>
+
+<div class="alert alert-light" role="alert">
+  This is a light alert.
+</div>
+
+<div class="alert alert-dark" role="alert">
+  This is a dark alert.
+</div>
+
+
+# 3. Cards
+
+<div class="card text-bg-primary mb-3" style="max-width: 50rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Primary card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-secondary mb-3" style="max-width: 45rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Secondary card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-success mb-3" style="max-width: 40rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Success card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-danger mb-3" style="max-width: 35rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Danger card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-warning mb-3" style="max-width: 30rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Warning card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-info mb-3" style="max-width: 25rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Info card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-light mb-3" style="max-width: 20rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Light card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+<div class="card text-bg-dark mb-3" style="max-width: 15rem;">
+  <div class="card-header">Header</div>
+  <div class="card-body">
+    <h5 class="card-title">Dark card title</h5>
+    <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
+  </div>
+</div>
+
+
+# 4. Colors
+## Contextual colors
+<p class="text-primary">Look, I'm in a well!</p>
+<p class="text-warning">Look, I'm in a well!</p>
+<p class="text-danger">Look, I'm in a well!</p>
+
+## Contextual backgrounds
+<p class="text-danger bg-primary">Look, I'm in a well!</p>
+<p class="text-primary bg-warning">Look, I'm in a well!</p>
+<p class="text-warning bg-danger">Look, I'm in a well!</p>
+
+
+# 5. Collapse
+## Displaying content
+<a class="btn btn-primary text-white" data-bs-toggle="collapse" href="#collapse-1">
+  Show content
+</a>
+
+<div class="collapse" id="collapse-1">
+  <div class="card card-body">
+
+- Content you want to display
+  - Content you want to display
+      
+  </div>
+</div>
+
+## Hiding content
+<a class="btn btn-secondary text-white" data-bs-toggle="collapse" href="#collapse-2">
+  Hide content
+</a>
+
+<div class="collapse show" id="collapse-2">
+  <div class="card card-body">
+
+- Content you want to hide
+  - Content you want to hide
+
+  </div>
+</div>
+
+
+# Official docs
+- [Click here for Badges details](https://getbootstrap.jp/docs/5.3/components/badge/)
+- [Click here for Alerts details](https://getbootstrap.jp/docs/5.3/components/alerts/)
+- [Click here for Cards details](https://getbootstrap.jp/docs/5.3/components/card/)
+- [Click here for Colors details](https://getbootstrap.jp/docs/5.3/utilities/colors/)
+- [Click here for Collapse details](https://getbootstrap.jp/docs/5.3/components/collapse/)

Разница между файлами не показана из-за своего большого размера
+ 7 - 0
apps/app/resource/locales/fr_FR/sandbox-diagrams.md


+ 71 - 0
apps/app/resource/locales/fr_FR/sandbox-math.md

@@ -0,0 +1,71 @@
+# :pencil: Math
+
+See [KaTeX](https://katex.org/).
+
+## Inline Formula
+
+When $a \ne 0$, there are two solutions to $ax^2 + bx + c = 0$ and they are
+  $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$
+
+## The Lorenz Equations
+
+$$
+\begin{align}
+\dot{x} & = \sigma(y-x) \\
+\dot{y} & = \rho x - y - xz \\
+\dot{z} & = -\beta z + xy
+\end{align}
+$$
+
+
+## The Cauchy-Schwarz Inequality
+
+$$
+\left( \sum_{k=1}^n a_k b_k \right)^{\!\!2} \leq
+ \left( \sum_{k=1}^n a_k^2 \right) \left( \sum_{k=1}^n b_k^2 \right)
+$$
+
+## A Cross Product Formula
+
+$$
+\mathbf{V}_1 \times \mathbf{V}_2 =
+ \begin{vmatrix}
+  \mathbf{i} & \mathbf{j} & \mathbf{k} \\
+  \frac{\partial X}{\partial u} & \frac{\partial Y}{\partial u} & 0 \\
+  \frac{\partial X}{\partial v} & \frac{\partial Y}{\partial v} & 0 \\
+ \end{vmatrix}
+$$
+
+
+## The probability of getting $\left(k\right)$ heads when flipping $\left(n\right)$ coins is:
+
+$$
+P(E) = {n \choose k} p^k (1-p)^{ n-k}
+$$
+
+## An Identity of Ramanujan
+
+$$
+\frac{1}{(\sqrt{\phi \sqrt{5}}-\phi) e^{\frac25 \pi}} =
+     1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}}
+      {1+\frac{e^{-8\pi}} {1+\ldots} } } }
+$$
+
+## A Rogers-Ramanujan Identity
+
+$$
+1 +  \frac{q^2}{(1-q)}+\frac{q^6}{(1-q)(1-q^2)}+\cdots =
+    \prod_{j=0}^{\infty}\frac{1}{(1-q^{5j+2})(1-q^{5j+3})},
+     \quad\quad \text{for $|q|<1$}.
+$$
+
+## Maxwell's Equations
+
+$$
+\begin{align}
+  \nabla \times \vec{\mathbf{B}} -\, \frac1c\, \frac{\partial\vec{\mathbf{E}}}{\partial t} & = \frac{4\pi}{c}\vec{\mathbf{j}} \\
+  \nabla \cdot \vec{\mathbf{E}} & = 4 \pi \rho \\
+  \nabla \times \vec{\mathbf{E}}\, +\, \frac1c\, \frac{\partial\vec{\mathbf{B}}}{\partial t} & = \vec{\mathbf{0}} \\
+  \nabla \cdot \vec{\mathbf{B}} & = 0
+\end{align}
+$$

+ 158 - 0
apps/app/resource/locales/fr_FR/sandbox.md

@@ -0,0 +1,158 @@
+# What is Sandbox?
+- In this page, you will find tips that help you to master GROWI 
+- Feel free to enrich the content of your pages with the references under this hierarchy
+
+
+# :closed_book:Headings & Paragraphs
+- By inserting headings and paragraphs, you can make the text on the page easier to read
+
+## Headers
+- Add `#` before the heading text to create a heading 
+    - Depending on the number of `#`, the typeface size of headings would be different shown in the View screen 
+    - Check the View screen on the right side to understand the effect of headings
+- The number of `#` will decide the hierarchy level and help you to organize the contents
+
+```
+# First-level heading
+## Second-level heading
+### Third-level heading
+#### Forth-level heading
+##### Fifth-level heading
+###### Sixth-level heading
+```
+
+## Break
+- Insert two half-width spaces at the end of the sentence you want to break
+    - You can also change this in the Setting to break the line without half-width spaces
+        - Change the line break setting in the `Markdown Settings` sector of the admin page
+
+#### Without line break
+Paragraph 1
+Paragraph 2
+
+#### With line break
+Paragraph 1  
+Paragraph 2
+
+## Block
+- Paragraphs can be created by inserting a blank table in the text
+- Passage can be broken into sentences and make them easier to read
+
+#### Without paragraph
+Paragraph 1  
+Paragraph 2
+
+#### With paragraph
+Paragraph 1  
+
+Paragraph 2
+
+
+# :green_book: Styling Text
+- Various styles can be applied to enrich the textual expression of a sentence
+    - These styles also can be easily applied by selecting the toolbar icon at the bottom of the Edit screen
+
+## Italic
+- Enclose the text with an asterisk `*` or an underscore `_`.
+
+#### Examples
+- This sentence indicates emphasis with *Italic*
+- This sentence indicates emphasis with _Italic_ 
+
+## Bold
+- Enclose the text with two asterisks `*` or two underscores `_`
+
+#### Example
+- This sentence indicates emphasis with **Bold** 
+- This sentence indicates emphasis with __Bold__
+
+## Italic & Bold
+- Enclose the text with three asterisks `*` or three underscores `_`
+
+#### Example
+- This sentence indicates emphasis with ***Italic & Bold***
+- This sentence indicates emphasis witH ___Italic & Bold___
+
+
+# :orange_book: Insert Lists
+## Bulleted List
+- Insert a bulleted list by starting a line with a hyphen `-`, a plus `+`, or an asterisk `*`
+
+#### Example
+- This sentence is present in the bulleted list
+    - This sentence is present in the bulleted list
+        - This sentence is present in the bulleted list
+        - This sentence is present in the bulleted list
+- This sentence is present in the bulleted list
+    - This sentence is present in the bulleted list
+
+## Numbered List
+- `Number.` at the beginning of a line to insert a numbered list
+- Numbered list and bulleted list can also be combined for use
+
+#### Example
+1. This sentence is present in the numbered list
+    1. This sentence is present in the numbered list
+    1. This sentence is present in the numbered list
+    1. This sentence is present in the numbered list
+        - This sentence is present in the bulleted list 
+1. This sentence is present in the bulleted list
+    - This sentence is present in the bulleted list
+
+## Task List
+- Insert an unchecked checkbox list by writing `[] `
+    - Check the checkbox by writing `[x]`
+
+#### Example
+- [ ] Task 1
+    - [x] Task 1-1
+    - [ ] Task 1-2
+- [x] Task 2
+
+
+# :blue_book: Others
+## Blockquotes
+- Use quoted expressions by putting `>` at the beginning of the paragraph
+    - Multiple quotations can be expressed by using a sequence of `>` characters
+- Lists and other elements can be used together within the blockquotes
+
+#### Example
+> - Quotation
+> - Quotation
+>> Multiple quotations need to insert more `>`
+
+## Code
+- It is possible to express the code by adding it in three `` ` ``
+
+#### Example
+```
+Add codes here  
+Line breaks and paragraphs can be reflected in the code
+
+- List also can be used in code
+    - List also can be used in code
+```
+
+## Inline Code
+- Enclose words in `` ` `` to make inline code
+
+#### Example
+Here is the `inline code` 
+
+## Horizontal lines
+- Insert the horizontal line with three or more consecutive asterisks `*` or underscores `_`
+
+#### Example
+Below is a horizontal line
+***
+
+Below is a horizontal line
+___
+
+
+# :ledger: More Applications
+- [Bootstrap5](/Sandbox/Bootstrap5)
+
+- [Diagrams](/Sandbox/Diagrams)
+
+- [Math](/Sandbox/Math)

+ 48 - 0
apps/app/resource/locales/fr_FR/welcome.md

@@ -0,0 +1,48 @@
+# :tada: Bienvenue dans GROWI
+
+GROWI est un outil de documentation interne et une base de connaissances pour les entreprises et individus.  
+Avec GROWI, les utilisateurs peuvent facilement partager et modifier de l'information.
+
+<div class="alert alert-primary" role="alert">
+※Sentez-vous libre de modifier cette page en tant que page d'accueil de la documentation.
+</div>
+
+# :beginner: Que peut-on faire avec GROWI?
+## 1. Gestion des connaissances: Créer des pages pour stocker l'information
+- Comment créer et modifier des pages?
+    - Vous pouvez créer une nouvelle page en cliquant sur l'"icone de crayon" dans le coin supérieur gauche
+    - Vous pouvez modifier une page en cliquant sur le bouton "Edit" dans le coin supérieur droit
+- Comment gérer les pages?
+    - GROWI organise les pages en une structure **hiérarchique**
+        - Exemple: ` /page A/page B/page C ` 
+    - Des étiquettes peuvent également être ajoutées aux pages
+
+## 2. Récupération de l'information: Rechercher l'information de diverses manières
+- Recherche de mots-clés
+- Rechercer en utilisant les barres latérales
+    - Recherche par arbre
+    - Recherche par date de modification
+    - Recherche par étiquette, et plus...
+
+## 3. Partage de l'information: Facile à l'interne et l'externe
+- Vous pouvez envoyer l'URL and le lien permanent d'une page aux autres utilisateurs
+    - Les groupes d'utilisateurs permettent de gérer les permissions sur le contenu
+- GROWI permet le partage aux individus ne possédant pas de compte
+    - Les liens de partage permettent de rendre des pages publiques!
+
+#### :bulb: Voir [Sandbox](/Sandbox) pour en apprendre plus sur la modification des pages!
+
+
+# :wrench: Pour les administrateurs
+
+### :arrow_right: Comment utiliser GROWI avec plusieurs membres?
+- :heavy_check_mark: Inviter les membres!
+    - [Ajouter ou inviter de nouveaux utilisateurs dans GROWI](https://docs.growi.org/en/admin-guide/management-cookbook/user-management.html#temporary-issuance-of-a-new-user)
+
+### :arrow_right: Insatisfait avec l'interface de GROWI?
+- :heavy_check_mark: Modifions l'apparence de l'interface GROWI!
+    - [Appliquer un thème](/admin/customize)
+
+### :arrow_right: La configuration est incomplète?
+- :heavy_check_mark: Remplissons les paramètres de sécurité!
+    - [Configurer les paramètres de sécurité](/admin/security)

+ 7 - 0
apps/app/src/client/services/create-page/create-page.ts

@@ -0,0 +1,7 @@
+import { apiv3Post } from '~/client/util/apiv3-client';
+import type { IApiv3PageCreateParams, IApiv3PageCreateResponse } from '~/interfaces/apiv3';
+
+export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
+  const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
+  return res.data;
+};

+ 1 - 0
apps/app/src/client/services/create-page/index.ts

@@ -1,2 +1,3 @@
+export * from './create-page';
 export * from './use-create-page-and-transit';
 export * from './use-create-template-page';

+ 3 - 1
apps/app/src/client/services/create-page/use-create-page-and-transit.tsx

@@ -2,12 +2,14 @@ import { useCallback, useState } from 'react';
 
 import { useRouter } from 'next/router';
 
-import { createPage, exist } from '~/client/services/page-operation';
+import { exist } from '~/client/services/page-operation';
 import type { IApiv3PageCreateParams } from '~/interfaces/apiv3';
 import { useCurrentPagePath } from '~/stores/page';
 import { EditorMode, useEditorMode } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
+import { createPage } from './create-page';
+
 const logger = loggerFactory('growi:Navbar:GrowiContextualSubNavigation');
 
 /**

+ 10 - 10
apps/app/src/client/services/page-operation.ts

@@ -4,11 +4,11 @@ import type { IPageHasId } from '@growi/core';
 import { SubscriptionStatusType } from '@growi/core';
 import urljoin from 'url-join';
 
-import type {
-  IApiv3PageCreateParams, IApiv3PageCreateResponse, IApiv3PageUpdateParams, IApiv3PageUpdateResponse,
-} from '~/interfaces/apiv3';
 import { useEditingMarkdown, usePageTagsForEditors } from '~/stores/editor';
-import { useCurrentPageId, useSWRMUTxCurrentPage, useSWRxTagsInfo } from '~/stores/page';
+import {
+  useCurrentPageId, useSWRMUTxCurrentPage, useSWRxApplicableGrant, useSWRxTagsInfo,
+  useSWRxCurrentGrantData,
+} from '~/stores/page';
 import { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
 import loggerFactory from '~/utils/logger';
 
@@ -91,11 +91,6 @@ export const resumeRenameOperation = async(pageId: string): Promise<void> => {
   await apiv3Post('/pages/resume-rename', { pageId });
 };
 
-export const createPage = async(params: IApiv3PageCreateParams): Promise<IApiv3PageCreateResponse> => {
-  const res = await apiv3Post<IApiv3PageCreateResponse>('/page', params);
-  return res.data;
-};
-
 export type UpdateStateAfterSaveOption = {
   supressEditingMarkdownMutation: boolean,
 }
@@ -107,6 +102,8 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
   const { mutate: mutateTagsInfo } = useSWRxTagsInfo(pageId);
   const { sync: syncTagsInfoForEditor } = usePageTagsForEditors(pageId);
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { mutate: mutateCurrentGrantData } = useSWRxCurrentGrantData(pageId);
+  const { mutate: mutateApplicableGrant } = useSWRxApplicableGrant(pageId);
 
   // update swr 'currentPageId', 'currentPage', remote states
   return useCallback(async() => {
@@ -129,6 +126,9 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
       mutateEditingMarkdown(updatedPage.revision.body);
     }
 
+    mutateCurrentGrantData();
+    mutateApplicableGrant();
+
     const remoterevisionData = {
       remoteRevisionId: updatedPage.revision._id,
       remoteRevisionBody: updatedPage.revision.body,
@@ -139,7 +139,7 @@ export const useUpdateStateAfterSave = (pageId: string|undefined|null, opts?: Up
     setRemoteLatestPageData(remoterevisionData);
   },
   // eslint-disable-next-line max-len
-  [pageId, mutateTagsInfo, syncTagsInfoForEditor, mutateCurrentPageId, mutateCurrentPage, opts?.supressEditingMarkdownMutation, setRemoteLatestPageData, mutateEditingMarkdown]);
+  [pageId, mutateTagsInfo, syncTagsInfoForEditor, mutateCurrentPageId, mutateCurrentPage, opts?.supressEditingMarkdownMutation, mutateCurrentGrantData, mutateApplicableGrant, setRemoteLatestPageData, mutateEditingMarkdown]);
 };
 
 export const unlink = async(path: string): Promise<void> => {

+ 1 - 0
apps/app/src/client/services/upload-attachments/index.ts

@@ -0,0 +1 @@
+export * from './upload-attachments';

+ 39 - 0
apps/app/src/client/services/upload-attachments/upload-attachments.ts

@@ -0,0 +1,39 @@
+import type { IAttachment } from '@growi/core';
+
+import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
+import type { IApiv3GetAttachmentLimitParams, IApiv3GetAttachmentLimitResponse, IApiv3PostAttachmentResponse } from '~/interfaces/apiv3/attachment';
+import loggerFactory from '~/utils/logger';
+
+
+const logger = loggerFactory('growi:client:services:upload-attachment');
+
+
+type UploadOpts = {
+  onUploaded?: (attachment: IAttachment) => void,
+  onError?: (error: Error, file: File) => void,
+}
+
+export const uploadAttachments = async(pageId: string, files: File[], opts?: UploadOpts): Promise<void> => {
+  files.forEach(async(file) => {
+    try {
+      const params: IApiv3GetAttachmentLimitParams = { fileSize: file.size };
+      const { data: resLimit } = await apiv3Get<IApiv3GetAttachmentLimitResponse>('/attachment/limit', params);
+
+      if (!resLimit.isUploadable) {
+        throw new Error(resLimit.errorMessage);
+      }
+
+      const formData = new FormData();
+      formData.append('file', file);
+      formData.append('page_id', pageId);
+
+      const { data: resAdd } = await apiv3PostForm<IApiv3PostAttachmentResponse>('/attachment', formData);
+
+      opts?.onUploaded?.(resAdd.attachment);
+    }
+    catch (e) {
+      logger.error('failed to upload', e);
+      opts?.onError?.(e, file);
+    }
+  });
+};

+ 1 - 1
apps/app/src/client/util/apiv3-client.ts

@@ -1,5 +1,5 @@
 // eslint-disable-next-line no-restricted-imports
-import { AxiosResponse } from 'axios';
+import type { AxiosResponse } from 'axios';
 import urljoin from 'url-join';
 
 // eslint-disable-next-line no-restricted-imports

+ 8 - 12
apps/app/src/client/util/bookmark-utils.ts

@@ -1,18 +1,12 @@
 import type { IRevision, Ref } from '@growi/core';
 
-import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import type { BookmarkFolderItems, BookmarkedPage } from '~/interfaces/bookmark-info';
 
 import { apiv3Delete, apiv3Post, apiv3Put } from './apiv3-client';
 
-// Check if bookmark folder item has children
-export const hasChildren = (item: BookmarkFolderItems | BookmarkFolderItems[]): boolean => {
-  if (item === null) {
-    return false;
-  }
-  if (Array.isArray(item)) {
-    return item.length > 0;
-  }
-  return item.children && item.children.length > 0;
+// Check if bookmark folder item has childFolder or bookmarks
+export const hasChildren = ({ childFolder, bookmarks }: { childFolder?: BookmarkFolderItems[], bookmarks?: BookmarkedPage[] }): boolean => {
+  return !!((childFolder && childFolder.length > 0) || (bookmarks && bookmarks.length > 0));
 };
 
 // Add new folder helper
@@ -41,8 +35,10 @@ export const toggleBookmark = async(pageId: string, status: boolean): Promise<vo
 };
 
 // Update Bookmark folder
-export const updateBookmarkFolder = async(bookmarkFolderId: string, name: string, parent: string | null, children: BookmarkFolderItems[]): Promise<void> => {
+export const updateBookmarkFolder = async(
+    bookmarkFolderId: string, name: string, parent: string | null, childFolder: BookmarkFolderItems[],
+): Promise<void> => {
   await apiv3Put('/bookmark-folder', {
-    bookmarkFolderId, name, parent, children,
+    bookmarkFolderId, name, parent, childFolder,
   });
 };

+ 2 - 0
apps/app/src/client/util/locale-utils.ts

@@ -8,12 +8,14 @@ import * as nextI18NextConfig from '^/config/next-i18next.config';
 const DIAGRAMS_NET_LANG_MAP = {
   ja_JP: 'ja',
   zh_CN: 'zh',
+  fr_FR: 'fr',
 };
 
 const ACCEPT_LANG_MAP = {
   en: Lang.en_US,
   ja: Lang.ja_JP,
   zh: Lang.zh_CN,
+  fr: Lang.fr_FR,
 };
 
 export const getDiagramsNetLangCode = (lang) => {

+ 2 - 1
apps/app/src/client/util/toastr.ts

@@ -1,4 +1,5 @@
-import { toast, ToastContent, ToastOptions } from 'react-toastify';
+import type { ToastContent, ToastOptions } from 'react-toastify';
+import { toast } from 'react-toastify';
 
 import { toArrayIfNot } from '~/utils/array-utils';
 

+ 4 - 2
apps/app/src/components/AuthorInfo/AuthorInfo.tsx

@@ -4,6 +4,7 @@ import type { IUser } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
+import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
 
@@ -18,6 +19,7 @@ export type AuthorInfoProps = {
 }
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
+  const { t } = useTranslation();
   const {
     date, user, mode = 'create', locate = 'subnav',
   } = props;
@@ -31,8 +33,8 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     ? 'Created by'
     : 'Updated by';
   const infoLabelForFooter = mode === 'create'
-    ? 'Created at'
-    : 'Last revision posted at';
+    ? t('author_info.created_at')
+    : t('author_info.last_revision_posted_at');
   const userLabel = user != null
     ? (
       <Link href={pagePathUtils.userHomepagePath(user)} prefetch={false}>

+ 31 - 29
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -1,6 +1,5 @@
-import {
-  FC, useCallback, useState,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback, useState } from 'react';
 
 import type { IPageToDeleteWithMeta } from '@growi/core';
 import { DropdownToggle } from 'reactstrap';
@@ -10,10 +9,9 @@ import {
 } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
 import { FolderIcon } from '~/components/Icons/FolderIcon';
-import {
-  BookmarkFolderItems, DragItemDataType, DragItemType, DRAG_ITEM_TYPE,
-} from '~/interfaces/bookmark-info';
-import { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
+import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
+import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
+import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
 import { useBookmarkFolderDeleteModal } from '~/stores/modal';
 
 import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
@@ -42,7 +40,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   } = props;
 
   const {
-    name, _id: folderId, children, parent, bookmarks,
+    name, _id: folderId, childFolder, parent, bookmarks,
   } = bookmarkFolder;
 
   const [targetFolder, setTargetFolder] = useState<string | null>(folderId);
@@ -52,7 +50,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
 
-  const childrenExists = hasChildren(children);
+  const childrenExists = hasChildren({ childFolder, bookmarks });
 
   const paddingLeft = BASE_FOLDER_PADDING * level;
 
@@ -65,14 +63,14 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const onPressEnterHandlerForRename = useCallback(async(folderName: string) => {
     try {
       // TODO: do not use any type
-      await updateBookmarkFolder(folderId, folderName, parent as any, children);
+      await updateBookmarkFolder(folderId, folderName, parent as any, childFolder);
       bookmarkFolderTreeMutation();
       setIsRenameAction(false);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, children, folderId, parent]);
+  }, [bookmarkFolderTreeMutation, childFolder, folderId, parent]);
 
   // Create new folder / subfolder handler
   const onPressEnterHandlerForCreate = useCallback(async(folderName: string) => {
@@ -99,7 +97,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     if (dragItemType === DRAG_ITEM_TYPE.FOLDER) {
       try {
         if (item.bookmarkFolder != null) {
-          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.children);
+          await updateBookmarkFolder(item.bookmarkFolder._id, item.bookmarkFolder.name, bookmarkFolder._id, item.bookmarkFolder.childFolder);
           bookmarkFolderTreeMutation();
         }
       }
@@ -129,7 +127,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
       // Maximum folder hierarchy of 2 levels
       // If the drop source folder has child folders, the drop source folder cannot be moved because the drop source folder hierarchy is already 2.
       // If the destination folder has a parent, the source folder cannot be moved because the destination folder hierarchy is already 2.
-      if (item.bookmarkFolder.children.length !== 0 || bookmarkFolder.parent != null) {
+      if (item.bookmarkFolder.childFolder.length !== 0 || bookmarkFolder.parent != null) {
         return false;
       }
 
@@ -142,9 +140,15 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
     return true;
   };
 
+  const triangleBtnClassName = (isOpen: boolean, childrenExists: boolean): string => {
+    if (!childrenExists) {
+      return 'grw-foldertree-triangle-btn btn px-0 opacity-25';
+    }
+    return `grw-foldertree-triangle-btn btn px-0 ${isOpen ? 'grw-foldertree-open' : ''}`;
+  };
 
   const renderChildFolder = () => {
-    return isOpen && children?.map((childFolder) => {
+    return isOpen && childFolder?.map((childFolder) => {
       return (
         <div key={childFolder._id} className="grw-foldertree-item-children">
           <BookmarkFolderItem
@@ -201,13 +205,13 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
 
   const onClickMoveToRootHandlerForBookmarkFolderItemControl = useCallback(async() => {
     try {
-      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.children);
+      await updateBookmarkFolder(bookmarkFolder._id, bookmarkFolder.name, null, bookmarkFolder.childFolder);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder._id, bookmarkFolder.children, bookmarkFolder.name, bookmarkFolderTreeMutation]);
+  }, [bookmarkFolder._id, bookmarkFolder.childFolder, bookmarkFolder.name, bookmarkFolderTreeMutation]);
 
   return (
     <div id={`grw-bookmark-folder-item-${folderId}`} className="grw-foldertree-item-container">
@@ -221,22 +225,20 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
         isDropable={isDropable}
       >
         <li
-          className="list-group-item list-group-item-action border-0 py-0 pe-3 d-flex align-items-center"
+          className="list-group-item list-group-item-action border-0 py-2 d-flex align-items-center rounded"
           onClick={loadChildFolder}
           style={{ paddingLeft }}
         >
           <div className="grw-triangle-container d-flex justify-content-center">
-            {childrenExists && (
-              <button
-                type="button"
-                className={`grw-foldertree-triangle-btn btn ${isOpen ? 'grw-foldertree-open' : ''}`}
-                onClick={loadChildFolder}
-              >
-                <div className="d-flex justify-content-center">
-                  <span className="material-symbols-outlined">arrow_right</span>
-                </div>
-              </button>
-            )}
+            <button
+              type="button"
+              className={triangleBtnClassName(isOpen, childrenExists)}
+              onClick={loadChildFolder}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-symbols-outlined">arrow_right</span>
+              </div>
+            </button>
           </div>
           <div>
             <FolderIcon isOpen={isOpen} />
@@ -249,7 +251,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             />
           ) : (
             <>
-              <div className="grw-foldertree-title-anchor ps-2">
+              <div className="grw-foldertree-title-anchor ps-1">
                 <p className="text-truncate m-auto ">{name}</p>
               </div>
             </>

+ 1 - 1
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -156,7 +156,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
                     isSelected={selectedItem === folder._id}
                   />
                 </div>
-                {folder.children?.map(child => (
+                {folder.childFolder?.map(child => (
                   <div key={child._id}>
                     <div
                       className="dropdown-item grw-bookmark-folder-menu-item grw-bookmark-folder-menu-item-folder-second list-group-item list-group-item-action"

+ 4 - 4
apps/app/src/components/Bookmarks/BookmarkFolderTree.module.scss

@@ -33,6 +33,9 @@ $grw-bookmark-item-padding-left: 35px;
       .grw-visible-on-hover {
         display: block;
       }
+      .page-list-meta {
+        display: none;
+      }
     }
 
     .grw-foldertree-triangle-btn {
@@ -54,10 +57,7 @@ $grw-bookmark-item-padding-left: 35px;
 
   .grw-foldertree-item-container {
     .grw-triangle-container {
-      // TODO: ignore width frickering
-      // https://redmine.weseek.co.jp/issues/130828
-      // min-width: 35px;
-      height: 40px;
+      height:30px;
     }
 
     .grw-bookmark-item-list{

+ 2 - 2
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { toastSuccess } from '~/client/util/toastr';
-import { OnDeletedFunction } from '~/interfaces/ui';
+import type { OnDeletedFunction } from '~/interfaces/ui';
 import {
   useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
 } from '~/stores/bookmark';
@@ -103,7 +103,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
 
   return (
     <div className={`grw-folder-tree-container ${styles['grw-folder-tree-container']}`}>
-      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group px-2 py-2`}>
+      <ul className={`grw-foldertree ${styles['grw-foldertree']} list-group py-2`}>
         {bookmarkFolders?.map((bookmarkFolder) => {
           return (
             <BookmarkFolderItem

+ 3 - 3
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -39,7 +39,7 @@ type Props = {
 
 export const BookmarkItem = (props: Props): JSX.Element => {
   const BASE_FOLDER_PADDING = 15;
-  const BASE_BOOKMARK_PADDING = 20;
+  const BASE_BOOKMARK_PADDING = 16;
 
   const { t } = useTranslation();
   const router = useRouter();
@@ -56,7 +56,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level + 1));
+  const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
   const dragItem: Partial<DragItemDataType> = {
     ...bookmarkedPage, parentFolder,
   };
@@ -148,7 +148,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       useDragMode={isOperable}
     >
       <li
-        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 me-auto d-flex align-items-center"
+        className="grw-bookmark-item-list list-group-item list-group-item-action border-0 py-0 pe-1 me-auto d-flex align-items-center rounded"
         key={bookmarkedPage._id}
         id={bookmarkItemId}
         style={{ paddingLeft }}

+ 8 - 9
apps/app/src/components/Comments.tsx

@@ -2,23 +2,20 @@ import React, { useEffect, useMemo, useRef } from 'react';
 
 import type { IRevisionHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
+import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 
-import { type PageCommentProps } from '~/components/PageComment';
 import { useSWRxPageComment } from '~/stores/comment';
 import { useIsTrashPage, useSWRMUTxPageInfo } from '~/stores/page';
 
 import { useCurrentUser } from '../stores/context';
 
-import type { CommentEditorProps } from './PageComment/CommentEditor';
-
-
 const { isTopPage } = pagePathUtils;
 
 
-const PageComment = dynamic<PageCommentProps>(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
-const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+const PageComment = dynamic(() => import('~/components/PageComment').then(mod => mod.PageComment), { ssr: false });
+const CommentEditorPre = dynamic(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditorPre), { ssr: false });
 
 export type CommentsProps = {
   pageId: string,
@@ -33,6 +30,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
     pageId, pagePath, revision, onLoaded,
   } = props;
 
+  const { t } = useTranslation('');
+
   const { mutate } = useSWRxPageComment(pageId);
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
   const { data: isDeleted } = useIsTrashPage();
@@ -69,7 +68,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
   };
 
   return (
-    <div className="page-comments-row mt-5 py-4 border-top border-3 d-edit-none d-print-none">
+    <div className="page-comments-row mt-5 py-4 border-top d-edit-none d-print-none">
+      <h4 className="mb-3">{t('page_comment.comments')}</h4>
       <div id="page-comments-list" className="page-comments-list" ref={pageCommentParentRef}>
         <PageComment
           pageId={pageId}
@@ -81,9 +81,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
       </div>
       {!isDeleted && (
         <div id="page-comment-write">
-          <CommentEditor
+          <CommentEditorPre
             pageId={pageId}
-            isForNewComment
             onCommentButtonClicked={onCommentButtonClickHandler}
             revisionId={revision._id}
           />

+ 1 - 1
apps/app/src/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -134,7 +134,7 @@ export const CopyDropdown = (props) => {
                   checked={isParamsAppended}
                   onChange={toggleAppendParams}
                 />
-                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>Append params</label>
+                <label className="form-label form-check-label small" htmlFor={customSwitchForParamsId}>{ t('copy_to_clipboard.Append params') }</label>
               </div>
             ) }
           </div>

+ 1 - 9
apps/app/src/components/Common/PagePathNav/PagePathNav.module.scss

@@ -14,21 +14,13 @@
     z-index: bs.$zindex-sticky;
   }
 
+  // TODO:Responsive font size
   // set smaller font-size when sticky
   .sticky-inner-wrapper.active {
     h1 {
       font-size: 1.75rem !important;
     }
   }
-  // avoid sticky-top nav to turnate page path
-  .is-collapse-with-top {
-    @include bs.media-breakpoint-down(md) {
-      max-width: calc(100% - 350px);
-    }
-    @include bs.media-breakpoint-up(md) {
-      max-width: calc(100% - 500px);
-    }
-  }
 }
 
 .grw-page-path-nav :global {

+ 62 - 15
apps/app/src/components/Common/PagePathNav/PagePathNav.tsx

@@ -1,5 +1,8 @@
-import type { FC } from 'react';
-import React from 'react';
+import React, {
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -7,6 +10,9 @@ import dynamic from 'next/dynamic';
 import Sticky from 'react-stickynode';
 
 import { useIsNotFound } from '~/stores/page';
+import {
+  usePageControlsX, useCurrentProductNavWidth, useSidebarMode,
+} from '~/stores/ui';
 
 import LinkedPagePath from '../../../models/linked-page-path';
 import { PagePathHierarchicalLink } from '../PagePathHierarchicalLink';
@@ -25,6 +31,7 @@ type Props = {
   isCollapseParents?: boolean,
   formerLinkClassName?: string,
   latterLinkClassName?: string,
+  maxWidth?: number,
 }
 
 const CopyDropdown = dynamic(() => import('../CopyDropdown').then(mod => mod.CopyDropdown), { ssr: false });
@@ -33,10 +40,10 @@ const Separator = ({ className }: {className?: string}): JSX.Element => {
   return <span className={`separator ${className ?? ''} ${styles['grw-mx-02em']}`}>/</span>;
 };
 
-export const PagePathNav: FC<Props> = (props: Props) => {
+export const PagePathNav = (props: Props): JSX.Element => {
   const {
     pageId, pagePath, isWipPage, isSingleLineMode, isCollapseParents,
-    formerLinkClassName, latterLinkClassName,
+    formerLinkClassName, latterLinkClassName, maxWidth,
   } = props;
   const dPagePath = new DevidedPagePath(pagePath, false, true);
 
@@ -82,7 +89,7 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   const copyDropdownId = `copydropdown-${pageId}`;
 
   return (
-    <div>
+    <div style={{ maxWidth }}>
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
       <div className="d-flex align-items-center">
         <h1 className={`m-0 ${latterLinkClassName}`}>
@@ -103,25 +110,65 @@ export const PagePathNav: FC<Props> = (props: Props) => {
   );
 };
 
+PagePathNav.displayName = 'PagePathNav';
+
 
 type PagePathNavStickyProps = Omit<Props, 'isCollapseParents'>;
 
 export const PagePathNavSticky = (props: PagePathNavStickyProps): JSX.Element => {
+
+  const { data: pageControlsX } = usePageControlsX();
+  const { data: sidebarWidth } = useCurrentProductNavWidth();
+  const { data: sidebarMode } = useSidebarMode();
+  const pagePathNavRef = useRef<HTMLDivElement>(null);
+
+  const [navMaxWidth, setNavMaxWidth] = useState<number | undefined>();
+
+  useEffect(() => {
+    if (pageControlsX == null || pagePathNavRef.current == null || sidebarWidth == null) {
+      return;
+    }
+    setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+  }, [pageControlsX, pagePathNavRef, sidebarWidth]);
+
+  useEffect(() => {
+    // wait for the end of the animation of the opening and closing of the sidebar
+    const timeout = setTimeout(() => {
+      if (pageControlsX == null || pagePathNavRef.current == null || sidebarMode == null) {
+        return;
+      }
+      setNavMaxWidth(pageControlsX - pagePathNavRef.current.getBoundingClientRect().x - 10);
+    }, 200);
+    return () => {
+      clearTimeout(timeout);
+    };
+  }, [pageControlsX, pagePathNavRef, sidebarMode]);
+
   return (
     // Controlling pointer-events
     //  1. disable pointer-events with 'pe-none'
-    <Sticky className={`${styles['grw-page-path-nav-sticky']} mb-4`} innerClass="mt-1 pe-none" innerActiveClass="active">
-      {({ status }: { status: boolean }) => {
-        const isCollapseParents = status === Sticky.STATUS_FIXED;
-        return (
+    <div ref={pagePathNavRef}>
+      <Sticky className={`${styles['grw-page-path-nav-sticky']} mb-4`} innerClass="mt-1 pe-none" innerActiveClass="active">
+        {({ status }) => {
+          const isCollapseParents = status === Sticky.STATUS_FIXED;
+          return (
           // Controlling pointer-events
           //  2. enable pointer-events with 'pe-auto' only against the children
           //      which width is minimized by 'd-inline-block'
-          <div className={`d-inline-block pe-auto ${isCollapseParents ? 'is-collapse-with-top' : ''}`}>
-            <PagePathNav {...props} isCollapseParents={isCollapseParents} latterLinkClassName={isCollapseParents ? 'fs-3  text-truncate' : 'fs-2'} />
-          </div>
-        );
-      }}
-    </Sticky>
+          //
+            <div className="d-inline-block pe-auto">
+              <PagePathNav
+                {...props}
+                isCollapseParents={isCollapseParents}
+                latterLinkClassName={isCollapseParents ? 'fs-3  text-truncate' : 'fs-2'}
+                maxWidth={isCollapseParents ? navMaxWidth : undefined}
+              />
+            </div>
+          );
+        }}
+      </Sticky>
+    </div>
   );
 };
+
+PagePathNavSticky.displayName = 'PagePathNavSticky';

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

@@ -13,7 +13,7 @@ const BookMarkLinkButton = React.memo(() => {
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
         <span className="material-symbols-outlined p-0">bookmark</span>
-        <span>{t('footer.bookmarks')}</span>
+        <span>{t('user_home_page.bookmarks')}</span>
       </button>
     </ScrollLink>
   );
@@ -30,7 +30,7 @@ const RecentlyCreatedLinkButton = React.memo(() => {
         className="btn btn-sm btn-outline-neutral-secondary rounded-pill d-flex align-items-center w-100"
       >
         <span className="growi-custom-icons mx-1">recently_created</span>
-        <span>{t('footer.recently_created')}</span>
+        <span>{t('user_home_page.recently_created')}</span>
       </button>
     </ScrollLink>
   );

+ 3 - 1
apps/app/src/components/FontFamily/use-growi-custom-icons.tsx

@@ -1,9 +1,11 @@
 import localFont from 'next/font/local';
 
-import { DefineStyle } from './types';
+import type { DefineStyle } from './types';
 
 const growiCustomIconFont = localFont({
   src: '../../../../../packages/custom-icons/dist/growi-custom-icons.woff2',
+  adjustFontFallback: false,
+  display: 'block',
 });
 
 export const useGrowiCustomIcon: DefineStyle = () => (

+ 1 - 1
apps/app/src/components/FontFamily/use-lato.tsx

@@ -1,6 +1,6 @@
 import { Lato } from 'next/font/google';
 
-import { DefineStyle } from './types';
+import type { DefineStyle } from './types';
 
 const lato = Lato({
   weight: ['400', '700'],

+ 2 - 1
apps/app/src/components/FontFamily/use-material-symbols-outlined.tsx

@@ -1,10 +1,11 @@
 import localFont from 'next/font/local';
 
-import { DefineStyle } from './types';
+import type { DefineStyle } from './types';
 
 const materialSymbolsOutlined = localFont({
   src: '../../../resource/fonts/MaterialSymbolsOutlined-opsz,wght,FILL@20..48,300,0..1.woff2',
   adjustFontFallback: false,
+  display: 'block',
 });
 
 export const useMaterialSymbolsOutlined: DefineStyle = () => (

+ 1 - 1
apps/app/src/components/FontFamily/use-source-han-code-jp.tsx

@@ -1,6 +1,6 @@
 import localFont from 'next/font/local';
 
-import { DefineStyle } from './types';
+import type { DefineStyle } from './types';
 
 const sourceHanCodeJPSubsetMain = localFont({
   src: '../../../resource/fonts/SourceHanCodeJP-Regular-subset-main.woff2',

+ 2 - 2
apps/app/src/components/Icons/FolderIcon.tsx

@@ -9,10 +9,10 @@ export const FolderIcon = (props: Props): JSX.Element => {
   return (
     <>
       {!isOpen ? (
-        <span className="material-symbols-outlined">folder_open</span>
+        <span className="material-symbols-outlined">folder</span>
 
       ) : (
-        <span className="material-symbols-outlined">folder</span>
+        <span className="material-symbols-outlined">folder_open</span>
       )
       }
     </>

+ 14 - 0
apps/app/src/components/Layout/RawLayout.module.scss

@@ -0,0 +1,14 @@
+.grw-toast-container {
+  --toastify-color-info: var(--bs-info);
+  --toastify-color-success: var(--bs-success);
+  --toastify-color-warning: var(--bs-warning);
+  --toastify-color-error: var(--bs-danger);
+  --toastify-icon-color-info: var(--toastify-color-success);
+  --toastify-icon-color-success: var(--toastify-color-success);
+  --toastify-icon-color-warning: var(--toastify-color-warning);
+  --toastify-icon-color-error: var(--toastify-color-error);
+  --toastify-color-progress-info: var(--toastify-color-success);
+  --toastify-color-progress-success: var(--toastify-color-success);
+  --toastify-color-progress-warning: var(--toastify-color-warning);
+  --toastify-color-progress-error: var(--toastify-color-error);
+}

+ 8 - 3
apps/app/src/components/Layout/RawLayout.tsx

@@ -1,6 +1,7 @@
-import React, { ReactNode, useState } from 'react';
+import type { ReactNode } from 'react';
+import React, { useState } from 'react';
 
-import { ColorScheme } from '@growi/core';
+import type { ColorScheme } from '@growi/core';
 import Head from 'next/head';
 import { ToastContainer } from 'react-toastify';
 import { useIsomorphicLayoutEffect } from 'usehooks-ts';
@@ -9,6 +10,10 @@ import { useNextThemes, NextThemesProvider } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
 
+import styles from './RawLayout.module.scss';
+
+const toastContainerClass = styles['grw-toast-container'] ?? '';
+
 const logger = loggerFactory('growi:cli:RawLayout');
 
 
@@ -41,7 +46,7 @@ export const RawLayout = ({ children, className }: Props): JSX.Element => {
       <NextThemesProvider>
         <div className={classNames.join(' ')}>
           {children}
-          <ToastContainer theme={colorScheme} />
+          <ToastContainer className={toastContainerClass} theme={colorScheme} />
         </div>
       </NextThemesProvider>
     </>

+ 8 - 6
apps/app/src/components/LoginForm/LoginForm.tsx

@@ -272,15 +272,12 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
 
     return (
       <>
-        <div className="text-center text-line d-flex align-items-center mb-3">
-          <p className="text-white mb-0">{t('or')}</p>
-        </div>
         <div className="mt-2">
           { enabledExternalAuthType.map(authType => <ExternalAuthButton authType={authType} />) }
         </div>
       </>
     );
-  }, [props, t]);
+  }, [props]);
 
   const resetRegisterErrors = useCallback(() => {
     if (registerErrors.length === 0) return;
@@ -514,10 +511,15 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
     <div className={moduleClass}>
       <div className="nologin-dialog mx-auto rounded-4 rounded-top-0" id="nologin-dialog" data-testid="login-form">
         <div className="row mx-0">
-          <div className="col-12 px-md-4">
+          <div className="col-12 px-md-4 pb-5">
             <ReactCardFlip isFlipped={isRegistering} flipDirection="horizontal" cardZIndex="3">
               <div className="front">
                 {isLocalOrLdapStrategiesEnabled && renderLocalOrLdapLoginForm()}
+                {isLocalOrLdapStrategiesEnabled && isSomeExternalAuthEnabled && (
+                  <div className="text-center text-line d-flex align-items-center mb-3">
+                    <p className="text-white mb-0">{t('or')}</p>
+                  </div>
+                )}
                 {isSomeExternalAuthEnabled && renderExternalAuthLoginForm()}
                 {isLocalOrLdapStrategiesEnabled && isPasswordResetEnabled && (
                   <div className="mt-4">
@@ -533,7 +535,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
                 )}
                 {/* Sign up link */}
                 {isRegistrationEnabled && (
-                  <div className="mt-2 mb-5">
+                  <div className="mt-2">
                     <a
                       href="#register"
                       className="btn btn-sm btn-secondary btn-function col-10 col-sm-9 mx-auto py-1 d-flex"

+ 9 - 0
apps/app/src/components/Navbar/GrowiContextualSubNavigation.module.scss

@@ -1,4 +1,13 @@
 @use '~/styles/mixins';
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+.grw-contextual-sub-navigation {
+  @include bs.media-breakpoint-down(md) {
+    // set min-height to keep the height
+    //  even if the item is empty on the share link page
+    min-height: 46px;
+  }
+}
 
 @include mixins.editing() {
   .grw-contextual-sub-navigation {

+ 68 - 46
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -1,4 +1,5 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';
+
 
 import { isPopulated } from '@growi/core';
 import type {
@@ -10,6 +11,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
+import Sticky from 'react-stickynode';
 import { DropdownItem } from 'reactstrap';
 
 import { useShouldExpandContent } from '~/client/services/layout';
@@ -30,6 +32,7 @@ import { mutatePageTree } from '~/stores/page-listing';
 import {
   useEditorMode, useIsAbleToShowPageManagement,
   useIsAbleToChangeEditorMode,
+  useIsDeviceLargerThanMd,
 } from '~/stores/ui';
 
 import { CreateTemplateModal } from '../CreateTemplateModal';
@@ -194,12 +197,16 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const { data: isAbleToShowPageManagement } = useIsAbleToShowPageManagement();
   const { data: isAbleToChangeEditorMode } = useIsAbleToChangeEditorMode();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
   const { open: openDuplicateModal } = usePageDuplicateModal();
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId);
 
+  const [isStickyActive, setStickyActive] = useState(false);
+
+
   const path = currentPage?.path ?? currentPathname;
   // const grant = currentPage?.grant ?? grantData?.grant;
   // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
@@ -288,53 +295,68 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
     );
   }, [isLinkSharingDisabled, isReadOnlyUser, pageId, revisionId]);
 
+  // hide sub controls when sticky on mobile device
+  const hideSubControls = useMemo(() => {
+    return !isDeviceLargerThanMd && isStickyActive;
+  }, [isDeviceLargerThanMd, isStickyActive]);
+
   return (
     <>
-      <GroundGlassBar
-        className={`${styles['grw-contextual-sub-navigation']}
-          d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
-        `}
-        data-testid="grw-contextual-sub-nav"
-        id="grw-contextual-sub-nav"
-      >
-        {pageId != null && (
-          <PageControls
-            pageId={pageId}
-            revisionId={revisionId}
-            shareLinkId={shareLinkId}
-            path={path ?? currentPathname} // If the page is empty, "path" is undefined
-            expandContentWidth={shouldExpandContent}
-            disableSeenUserInfoPopover={isSharedUser}
-            showPageControlDropdown={isAbleToShowPageManagement}
-            additionalMenuItemRenderer={additionalMenuItemsRenderer}
-            onClickDuplicateMenuItem={duplicateItemClickedHandler}
-            onClickRenameMenuItem={renameItemClickedHandler}
-            onClickDeleteMenuItem={deleteItemClickedHandler}
-            onClickSwitchContentWidth={switchContentWidthHandler}
-          />
-        )}
-
-        {isAbleToChangeEditorMode && (
-          <PageEditorModeManager
-            editorMode={editorMode}
-            isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
-            path={path}
-            // grant={grant}
-            // grantUserGroupId={grantUserGroupId}
-          />
-        )}
-
-        { isGuestUser && (
-          <div className="mt-2">
-            <Link href="/login#register" className="btn me-2" prefetch={false}>
-              <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
-            </Link>
-            <Link href="/login#login" className="btn btn-primary" prefetch={false}>
-              <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
-            </Link>
-          </div>
-        ) }
-      </GroundGlassBar>
+      <GroundGlassBar className="py-4 d-block d-md-none d-print-none border-bottom" />
+
+      <Sticky className="z-1" onStateChange={status => setStickyActive(status.status === Sticky.STATUS_FIXED)}>
+        <GroundGlassBar>
+
+          <nav
+            className={`${styles['grw-contextual-sub-navigation']}
+              d-flex align-items-center justify-content-end px-2 px-sm-3 px-md-4 py-1 gap-2 gap-md-4 d-print-none
+            `}
+            data-testid="grw-contextual-sub-nav"
+            id="grw-contextual-sub-nav"
+          >
+
+            {pageId != null && (
+              <PageControls
+                pageId={pageId}
+                revisionId={revisionId}
+                shareLinkId={shareLinkId}
+                path={path ?? currentPathname} // If the page is empty, "path" is undefined
+                expandContentWidth={shouldExpandContent}
+                disableSeenUserInfoPopover={isSharedUser}
+                hideSubControls={hideSubControls}
+                showPageControlDropdown={isAbleToShowPageManagement}
+                additionalMenuItemRenderer={additionalMenuItemsRenderer}
+                onClickDuplicateMenuItem={duplicateItemClickedHandler}
+                onClickRenameMenuItem={renameItemClickedHandler}
+                onClickDeleteMenuItem={deleteItemClickedHandler}
+                onClickSwitchContentWidth={switchContentWidthHandler}
+              />
+            )}
+
+            {isAbleToChangeEditorMode && (
+              <PageEditorModeManager
+                editorMode={editorMode}
+                isBtnDisabled={!!isGuestUser || !!isReadOnlyUser}
+                path={path}
+                // grant={grant}
+                // grantUserGroupId={grantUserGroupId}
+              />
+            )}
+
+            { isGuestUser && (
+              <div className="mt-2">
+                <Link href="/login#register" className="btn me-2" prefetch={false}>
+                  <span className="material-symbols-outlined me-1">person_add</span>{t('Sign up')}
+                </Link>
+                <Link href="/login#login" className="btn btn-primary" prefetch={false}>
+                  <span className="material-symbols-outlined me-1">login</span>{t('Sign in')}
+                </Link>
+              </div>
+            ) }
+          </nav>
+
+        </GroundGlassBar>
+      </Sticky>
 
       {path != null && currentUser != null && !isReadOnlyUser && (
         <CreateTemplateModal

+ 8 - 2
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,4 +1,6 @@
-import React from 'react';
+import React, {
+  useCallback,
+} from 'react';
 
 import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsSearchPage } from '~/stores/context';
@@ -19,6 +21,10 @@ export const GrowiNavbarBottom = (): JSX.Element => {
   const { data: isSearchPage } = useIsSearchPage();
   const { open: openSearchModal } = useSearchModal();
 
+  const searchButtonClickHandler = useCallback(() => {
+    openSearchModal();
+  }, [openSearchModal]);
+
   return (
     <GroundGlassBar className={`
       ${styles['grw-navbar-bottom']}
@@ -54,7 +60,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                 <a
                   role="button"
                   className="nav-link btn-lg"
-                  onClick={openSearchModal}
+                  onClick={searchButtonClickHandler}
                 >
                   <span className="material-symbols-outlined fs-2">search</span>
                 </a>

+ 3 - 3
apps/app/src/components/Page/DisplaySwitcher.tsx

@@ -27,11 +27,11 @@ export const DisplaySwitcher = (props: Props): JSX.Element => {
   usePageUpdatedEffect();
   useHashChangedEffect();
 
-  const isViewMode = editorMode === EditorMode.View;
-
   return (
     <>
-      { isViewMode && pageView }
+      <div className="d-edit-none">
+        {pageView}
+      </div>
 
       <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
         { isLatestRevision

+ 15 - 6
apps/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -8,10 +8,10 @@ import {
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IPageGrantData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus, type IPageGrantData } from '~/interfaces/page';
 import type { PopulatedGrantedGroup, IRecordApplicableGrant, IResIsGrantNormalizedGrantData } from '~/interfaces/page-grant';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxApplicableGrant, useSWRxIsGrantNormalized, useSWRxCurrentPage } from '~/stores/page';
+import { useSWRxApplicableGrant, useSWRxCurrentGrantData, useSWRxCurrentPage } from '~/stores/page';
 
 type ModalProps = {
   isOpen: boolean
@@ -99,10 +99,19 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     }
 
     if (grantData.grant === 5) {
-      if (grantData.userRelatedGrantedGroups == null || grantData.userRelatedGrantedGroups.length === 0) {
-        return t('fix_page_grant.modal.grant_label.isForbidden');
+      const groupGrantData = grantData.groupGrantData;
+      if (groupGrantData != null) {
+        const userRelatedGrantedGroups = groupGrantData.userRelatedGroups.filter(group => group.status === UserGroupPageGrantStatus.isGranted);
+        if (userRelatedGrantedGroups.length > 0) {
+          const grantedGroupNames = [
+            ...userRelatedGrantedGroups.map(group => group.name),
+            ...groupGrantData.nonUserRelatedGrantedGroups.map(group => group.name),
+          ];
+          return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantedGroupNames.join(', ')})`;
+        }
       }
-      return `${t('fix_page_grant.modal.radio_btn.grant_group')} (${grantData.userRelatedGrantedGroups.map(g => g.name).join(', ')})`;
+
+      return t('fix_page_grant.modal.grant_label.isForbidden');
     }
 
     throw Error('cannot get grant label'); // this error can't be throwed
@@ -278,7 +287,7 @@ export const FixPageGrantAlert = (): JSX.Element => {
 
   const [isOpen, setOpen] = useState<boolean>(false);
 
-  const { data: dataIsGrantNormalized } = useSWRxIsGrantNormalized(currentUser != null ? pageId : null);
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(currentUser != null ? pageId : null);
   const { data: dataApplicableGrant } = useSWRxApplicableGrant(currentUser != null ? pageId : null);
 
   // Dependencies

+ 29 - 2
apps/app/src/components/PageComment.module.scss

@@ -9,8 +9,7 @@
 
   // reply button
   .btn-comment-reply {
-    margin-top: 0.5em;
-    border: none;
+    backdrop-filter: blur(10px);
   }
 
   // TODO: Refacotr Soft-coding
@@ -21,3 +20,31 @@
     border: none;
   }
 }
+
+// // Light mode color
+@include bs.color-mode(light) {
+  .page-comment-styles :global {
+    .btn-comment-reply {
+      --bs-btn-color: #{( bs.$gray-600 )};
+      --bs-btn-hover-color:  #{( bs.$gray-700 )};
+      --bs-btn-bg: #{rgba( bs.$gray-200, 0.3 )};
+      --bs-btn-hover-bg: #{( bs.$gray-200 )};
+      --bs-btn-border-color: #{( bs.$gray-200 )};
+      --bs-btn-hover-border-color: #{( bs.$gray-300 )};
+    }
+  }
+}
+
+// dark mode color
+@include bs.color-mode(dark) {
+  .page-comment-styles :global {
+    .btn-comment-reply {
+      --bs-btn-color: #{( bs.$gray-500 )};
+      --bs-btn-hover-color:  #{( bs.$gray-400 )};
+      --bs-btn-bg: #{rgba( bs.$gray-800, 0.3 )};
+      --bs-btn-hover-bg: #{( bs.$gray-800 )};
+      --bs-btn-border-color: #{( bs.$gray-700 )};
+      --bs-btn-hover-border-color: #{( bs.$gray-600 )};
+    }
+  }
+}

+ 18 - 15
apps/app/src/components/PageComment.tsx

@@ -1,17 +1,19 @@
+import type { FC } from 'react';
 import React, {
-  FC, useState, useMemo, memo, useCallback,
+  useState, useMemo, memo, useCallback,
 } from 'react';
 
 import { isPopulated, getIdForRef, type IRevisionHasId } from '@growi/core';
-import { Button } from 'reactstrap';
+import { UserPicture } from '@growi/ui/dist/components';
+import { useTranslation } from 'next-i18next';
 
 import { apiPost } from '~/client/util/apiv1-client';
 import { toastError } from '~/client/util/toastr';
-import { RendererOptions } from '~/interfaces/renderer-options';
+import type { RendererOptions } from '~/interfaces/renderer-options';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 import { useCommentForCurrentPageOptions } from '~/stores/renderer';
 
-import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
+import type { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 import { NotAvailableForGuest } from './NotAvailableForGuest';
@@ -24,7 +26,7 @@ import { ReplyComments } from './PageComment/ReplyComments';
 import styles from './PageComment.module.scss';
 
 
-export type PageCommentProps = {
+type PageCommentProps = {
   rendererOptions?: RendererOptions,
   pageId: string,
   pagePath: string,
@@ -49,6 +51,8 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
   const [errorMessageOnDelete, setErrorMessageOnDelete] = useState<string>('');
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
+  const { t } = useTranslation('');
+
   const commentsFromOldest = useMemo(() => (comments != null ? [...comments].reverse() : null), [comments]);
   const commentsExceptReply: ICommentHasIdList | undefined = useMemo(
     () => commentsFromOldest?.filter(comment => comment.replyTo == null), [commentsFromOldest],
@@ -153,10 +157,10 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
   return (
     <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
       <div className="page-comments">
-        <div className="page-comments-list" id="page-comments-list">
+        <div className="page-comments-list mb-3" id="page-comments-list">
           {commentsExceptReply.map((comment) => {
 
-            const defaultCommentThreadClasses = 'page-comment-thread pb-5';
+            const defaultCommentThreadClasses = 'page-comment-thread mb-2';
             const hasReply: boolean = Object.keys(allReplies).includes(comment._id);
 
             let commentThreadClasses = '';
@@ -170,16 +174,15 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
                   <div className="d-flex flex-row-reverse">
                     <NotAvailableForGuest>
                       <NotAvailableForReadOnlyUser>
-                        <Button
-                          data-testid="comment-reply-button"
-                          outline
-                          color="secondary"
-                          size="sm"
-                          className="btn-comment-reply"
+                        <button
+                          type="button"
+                          id="comment-reply-button"
+                          className="btn btn-secondary btn-comment-reply text-start w-100 ms-5"
                           onClick={() => onReplyButtonClickHandler(comment._id)}
                         >
-                          <span className="material-symbols-outlined">replay</span> Reply
-                        </Button>
+                          <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-2" />
+                          <span className="material-symbols-outlined me-1 fs-5 pb-1">reply</span><small>{t('page_comment.reply')}...</small>
+                        </button>
                       </NotAvailableForReadOnlyUser>
                     </NotAvailableForGuest>
                   </div>

+ 32 - 23
apps/app/src/components/PageComment/Comment.module.scss

@@ -4,11 +4,6 @@
 @use './_comment-inheritance';
 
 .comment-styles :global {
-  .page-comment-writer {
-    @include bs.media-breakpoint-down(xs) {
-      height: 3.5em;
-    }
-  }
 
   .page-comment {
     position: relative;
@@ -16,11 +11,9 @@
 
     scroll-margin-top: var.$grw-scroll-margin-top-in-view;
 
-    // user name
-    .page-comment-creator {
-      margin-top: -0.5em;
-      margin-bottom: 0.5em;
-      font-weight: bold;
+    // background
+    .bg-comment {
+      @extend %bg-comment;
     }
 
     // user icon
@@ -31,14 +24,6 @@
     // comment section
     .page-comment-main {
       @extend %comment-section;
-      @include bs.media-breakpoint-up(sm) {
-        margin-left: 4.5em;
-      }
-      @include bs.media-breakpoint-down(xs) {
-        &:before {
-          content: none;
-        }
-      }
 
       pointer-events: auto;
 
@@ -57,8 +42,10 @@
 
     // comment body
     .page-comment-body {
-      margin-bottom: 0.5em;
       word-wrap: break-word;
+      .wiki p {
+        margin: 8px 0;
+      }
     }
 
     // older comments
@@ -73,6 +60,14 @@
       }
     }
 
+    .page-comment-revision {
+      .material-symbols-outlined {
+        font-size: 16px;
+        vertical-align: middle;
+      }
+    }
+
+
     .page-comment-meta {
       display: flex;
       justify-content: flex-end;
@@ -81,10 +76,6 @@
       color: bs.$gray-400;
     }
 
-    .page-comment-revision svg {
-      width: 16px;
-      height: 16px;
-    }
   }
 
   // TODO: Refacotr Soft-coding
@@ -98,3 +89,21 @@
     }
   }
 }
+
+// // Light mode color
+@include bs.color-mode(light) {
+  .comment-styles :global {
+    .page-comment-revision {
+      color: bs.$gray-500;
+    }
+  }
+}
+
+// // Dark mode color
+@include bs.color-mode(dark) {
+  .comment-styles :global {
+    .page-comment-revision {
+      color: bs.$gray-600;
+    }
+  }
+}

+ 16 - 20
apps/app/src/components/PageComment/Comment.tsx

@@ -111,10 +111,6 @@ export const Comment = (props: CommentProps): JSX.Element => {
     deleteBtnClicked(comment);
   };
 
-  const renderText = (comment: string) => {
-    return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
-  };
-
   const commentBody = useMemo(() => {
     if (rendererOptions == null) {
       return <></>;
@@ -151,24 +147,15 @@ export const Comment = (props: CommentProps): JSX.Element => {
         />
       ) : (
         <div id={commentId} className={rootClassName}>
-          <div className="page-comment-writer">
-            <UserPicture user={creator} />
-          </div>
-          <div className="page-comment-main">
-            <div className="page-comment-creator">
-              <Username user={creator} />
-            </div>
-            <div className="page-comment-body">{commentBody}</div>
-            <div className="page-comment-meta">
-              <Link href={`#${commentId}`} prefetch={false}>
+          <div className="page-comment-main bg-comment rounded mb-2">
+            <div className="d-flex align-items-center">
+              <UserPicture user={creator} additionalClassName="me-2" />
+              <div className="small fw-bold me-3">
+                <Username user={creator} />
+              </div>
+              <Link href={`#${commentId}`} prefetch={false} className="small page-comment-revision">
                 <FormattedDistanceDate id={commentId} date={comment.createdAt} />
               </Link>
-              { isEdited && (
-                <>
-                  <span id={editedDateId}>&nbsp;(edited)</span>
-                  <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
-                </>
-              ) }
               <span className="ms-2">
                 <Link
                   id={`page-comment-revision-${commentId}`}
@@ -183,6 +170,15 @@ export const Comment = (props: CommentProps): JSX.Element => {
                 </UncontrolledTooltip>
               </span>
             </div>
+            <div className="page-comment-body">{commentBody}</div>
+            <div className="page-comment-meta">
+              { isEdited && (
+                <>
+                  <span id={editedDateId}>&nbsp;(edited)</span>
+                  <UncontrolledTooltip placement="bottom" fade={false} target={editedDateId}>{editedDateFormatted}</UncontrolledTooltip>
+                </>
+              ) }
+            </div>
             { (isCurrentUserEqualsToAuthor() && !isReadOnly) && (
               <CommentControl
                 onClickDeleteBtn={deleteBtnClickedHandler}

+ 2 - 2
apps/app/src/components/PageComment/CommentControl.tsx

@@ -13,13 +13,13 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
   return (
     // The page-comment-control class is imported from Comment.module.scss
     <div className="page-comment-control">
-      <button type="button" className="btn btn-link p-2" onClick={onClickEditBtn}>
+      <button type="button" className="btn btn-link p-2 opacity-50" onClick={onClickEditBtn}>
         <span className="material-symbols-outlined">edit</span>
       </button>
       <button
         data-testid="comment-delete-button"
         type="button"
-        className="btn btn-link p-2 me-2"
+        className="btn btn-link p-2 me-2 opacity-50"
         onClick={onClickDeleteBtn}
       >
         <span className="material-symbols-outlined">close</span>

+ 21 - 20
apps/app/src/components/PageComment/CommentEditor.module.scss

@@ -4,34 +4,35 @@
 
 // display cheatsheet for comment form only
 .comment-editor-styles :global {
-  .cm-editor {
-    height: 300px !important;
-  }
-
   .comment-form {
     position: relative;
-    margin-top: 1em;
+
+    // background
+    .bg-comment {
+      @extend %bg-comment
+    }
 
     // user icon
     .picture {
       @extend %picture;
     }
 
-    // seciton
-    .comment-form-main {
-      @extend %comment-section;
-      margin-left: 4.5em;
-      @include bs.media-breakpoint-down(xs) {
-        margin-left: 3.5em;
-      }
-    }
+  }
+}
 
-    // textarea
-    .comment-write {
-      margin-bottom: 0.5em;
-    }
-    .comment-form-preview {
-      padding-top: 0.5em;
-    }
+
+// adjust height
+.comment-editor-styles :global {
+  .cm-editor {
+    min-height: comment-inheritance.$codemirror-default-height !important;
+    max-height: #{2 * comment-inheritance.$codemirror-default-height};
+  }
+  .cm-gutters {
+    min-height: comment-inheritance.$codemirror-default-height !important;
+  }
+  .comment-preview-container {
+    min-height: page-editor-inheritance.$navbar-editor-height
+      + comment-inheritance.$codemirror-default-height;
+    padding-top: 0.5em;
   }
 }

+ 184 - 247
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -1,21 +1,22 @@
+import type { ReactNode } from 'react';
 import React, {
-  useCallback, useState, useRef, useEffect,
+  useCallback, useState, useEffect,
+  useMemo,
 } from 'react';
 
 import {
   CodeMirrorEditorComment, GlobalCodeMirrorEditorKey, useCodeMirrorEditorIsolated, useResolvedThemeForEditor,
 } from '@growi/editor';
 import { UserPicture } from '@growi/ui/dist/components';
+import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
-import { useRouter } from 'next/router';
 import {
-  Button, TabContent, TabPane,
+  TabContent, TabPane,
 } from 'reactstrap';
 
-import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
+import { uploadAttachments } from '~/client/services/upload-attachments';
 import { toastError } from '~/client/util/toastr';
-import type { IEditorMethods } from '~/interfaces/editor-methods';
-import { useSWRxPageComment, useSWRxEditingCommentsNum } from '~/stores/comment';
+import { useSWRxPageComment } from '~/stores/comment';
 import {
   useCurrentUser, useIsSlackConfigured, useAcceptedUploadFileType,
 } from '~/stores/context';
@@ -23,14 +24,16 @@ import {
   useSWRxSlackChannels, useIsSlackEnabled, useIsEnabledUnsavedWarning, useEditorSettings,
 } from '~/stores/editor';
 import { useCurrentPagePath } from '~/stores/page';
+import { useCommentEditorDirtyMap } from '~/stores/ui';
 import { useNextThemes } from '~/stores/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
-import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
 import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
 
 import { CommentPreview } from './CommentPreview';
+import { SwitchingButtonGroup } from './SwitchingButtonGroup';
+
 
 import '@growi/editor/dist/style.css';
 import styles from './CommentEditor.module.scss';
@@ -42,20 +45,21 @@ const logger = loggerFactory('growi:components:CommentEditor');
 const SlackNotification = dynamic(() => import('../SlackNotification').then(mod => mod.SlackNotification), { ssr: false });
 
 
-const navTabMapping = {
-  comment_editor: {
-    Icon: () => <span className="material-symbols-outlined">edit_square</span>,
-    i18n: 'Write',
-  },
-  comment_preview: {
-    Icon: () => <span className="material-symbols-outlined">play_arrow</span>,
-    i18n: 'Preview',
-  },
+const CommentEditorLayout = ({ children }: { children: ReactNode }): JSX.Element => {
+  return (
+    <div className={`${styles['comment-editor-styles']} form`}>
+      <div className="comment-form">
+        <div className="bg-comment rounded">
+          {children}
+        </div>
+      </div>
+    </div>
+  );
 };
 
-export type CommentEditorProps = {
+
+type CommentEditorProps = {
   pageId: string,
-  isForNewComment?: boolean,
   replyTo?: string,
   revisionId: string,
   currentCommentId?: string,
@@ -64,11 +68,10 @@ export type CommentEditorProps = {
   onCommentButtonClicked?: () => void,
 }
 
-
 export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const {
-    pageId, isForNewComment, replyTo, revisionId,
+    pageId, replyTo, revisionId,
     currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
 
@@ -82,39 +85,33 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   const { data: editorSettings } = useEditorSettings();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const {
-    increment: incrementEditingCommentsNum,
-    decrement: decrementEditingCommentsNum,
-  } = useSWRxEditingCommentsNum();
+    evaluate: evaluateEditorDirtyMap,
+    clean: cleanEditorDirtyMap,
+  } = useCommentEditorDirtyMap();
   const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
-  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.COMMENT);
   const { resolvedTheme } = useNextThemes();
   mutateResolvedTheme({ themeData: resolvedTheme });
 
-  const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
-  const [comment, setComment] = useState(commentBody ?? '');
-  const [activeTab, setActiveTab] = useState('comment_editor');
-  const [error, setError] = useState();
-  const [slackChannels, setSlackChannels] = useState<string>('');
-  const [incremented, setIncremented] = useState(false);
+  const editorKey = useMemo(() => {
+    if (replyTo != null) {
+      return `comment_replyTo_${replyTo}`;
+    }
+    if (currentCommentId != null) {
+      return `comment_edit_${currentCommentId}`;
+    }
+    return GlobalCodeMirrorEditorKey.COMMENT_NEW;
+  }, [currentCommentId, replyTo]);
 
-  const editorRef = useRef<IEditorMethods>(null);
+  const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
 
-  const router = useRouter();
+  const [showPreview, setShowPreview] = useState(false);
+  const [error, setError] = useState();
+  const [slackChannels, setSlackChannels] = useState<string>('');
 
-  // UnControlled CodeMirror value is not reset on page transition, so explicitly set the value to the initial value
-  const onRouterChangeComplete = useCallback(() => {
-    editorRef.current?.setValue('');
-  }, []);
+  const { t } = useTranslation('');
 
-  useEffect(() => {
-    router.events.on('routeChangeComplete', onRouterChangeComplete);
-    return () => {
-      router.events.off('routeChangeComplete', onRouterChangeComplete);
-    };
-  }, [onRouterChangeComplete, router.events]);
-
-  const handleSelect = useCallback((activeTab: string) => {
-    setActiveTab(activeTab);
+  const handleSelect = useCallback((showPreview: boolean) => {
+    setShowPreview(showPreview);
   }, []);
 
   // DO NOT dependent on slackChannelsData directly: https://github.com/weseek/growi/pull/7332
@@ -137,47 +134,34 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, []);
 
   const initializeEditor = useCallback(async() => {
-    const editingCommentsNum = comment !== '' ? await decrementEditingCommentsNum() : undefined;
+    const dirtyNum = await cleanEditorDirtyMap(editorKey);
+    mutateIsEnabledUnsavedWarning(dirtyNum > 0);
 
-    setComment('');
-    setActiveTab('comment_editor');
+    setShowPreview(false);
     setError(undefined);
-    initializeSlackEnabled();
-    // reset value
-    if (editorRef.current == null) { return }
-    editorRef.current.setValue('');
 
-    if (editingCommentsNum != null && editingCommentsNum === 0) {
-      mutateIsEnabledUnsavedWarning(false); // must be after clearing comment or else onChange will override bool
-    }
+    initializeSlackEnabled();
 
-  }, [initializeSlackEnabled, comment, decrementEditingCommentsNum, mutateIsEnabledUnsavedWarning]);
+  }, [editorKey, cleanEditorDirtyMap, mutateIsEnabledUnsavedWarning, initializeSlackEnabled]);
 
   const cancelButtonClickedHandler = useCallback(() => {
-    // change state to not ready
-    // when this editor is for the new comment mode
-    if (isForNewComment) {
-      setIsReadyToUse(false);
-    }
-
     initializeEditor();
-
-    if (onCancelButtonClicked != null) {
-      onCancelButtonClicked();
-    }
-  }, [isForNewComment, onCancelButtonClicked, initializeEditor]);
+    onCancelButtonClicked?.();
+  }, [onCancelButtonClicked, initializeEditor]);
 
   const postCommentHandler = useCallback(async() => {
+    const commentBodyToPost = codeMirrorEditor?.getDoc() ?? '';
+
     try {
       if (currentCommentId != null) {
         // update current comment
-        await updateComment(comment, revisionId, currentCommentId);
+        await updateComment(commentBodyToPost, revisionId, currentCommentId);
       }
       else {
         // post new comment
         const postCommentArgs = {
           commentForm: {
-            comment,
+            comment: commentBodyToPost,
             revisionId,
             replyTo,
           },
@@ -191,9 +175,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
       initializeEditor();
 
-      if (onCommentButtonClicked != null) {
-        onCommentButtonClicked();
-      }
+      onCommentButtonClicked?.();
 
       // Insert empty string as new comment editor is opened after comment
       codeMirrorEditor?.initDoc('');
@@ -202,93 +184,32 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
       const errorMessage = err.message || 'An unknown error occured when posting comment';
       setError(errorMessage);
     }
-  }, [
-    currentCommentId, initializeEditor, onCommentButtonClicked, codeMirrorEditor,
-    updateComment, comment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment,
-  ]);
+  // eslint-disable-next-line max-len
+  }, [currentCommentId, initializeEditor, onCommentButtonClicked, codeMirrorEditor, updateComment, revisionId, replyTo, isSlackEnabled, slackChannels, postComment]);
 
   // the upload event handler
   const uploadHandler = useCallback((files: File[]) => {
-    files.forEach(async(file) => {
-      try {
-        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
-
-        if (!resLimit.isUploadable) {
-          throw new Error(resLimit.errorMessage);
-        }
-
-        const formData = new FormData();
-        formData.append('file', file);
-        if (pageId != null) {
-          formData.append('page_id', pageId);
-        }
-
-        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
-
-        const attachment = resAdd.attachment;
+    uploadAttachments(pageId, files, {
+      onUploaded: (attachment) => {
         const fileName = attachment.originalName;
 
-        let insertText = `[${fileName}](${attachment.filePathProxied})\n`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
+        const prefix = attachment.fileFormat.startsWith('image/')
+          ? '!' // use "![fileName](url)" syntax when image
+          : '';
+        const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
 
         codeMirrorEditor?.insertText(insertText);
-      }
-      catch (e) {
-        logger.error('failed to upload', e);
-        toastError(e);
-      }
+      },
+      onError: (error) => {
+        toastError(error);
+      },
     });
-
   }, [codeMirrorEditor, pageId]);
 
-  const getCommentHtml = useCallback(() => {
-    if (currentPagePath == null) {
-      return <></>;
-    }
-
-    return <CommentPreview markdown={comment} />;
-  }, [currentPagePath, comment]);
-
-  const renderBeforeReady = useCallback((): JSX.Element => {
-    return (
-      <div className="text-center">
-        <NotAvailableForGuest>
-          <NotAvailableForReadOnlyUser>
-            <button
-              type="button"
-              className="btn btn-lg btn-link"
-              onClick={() => setIsReadyToUse(true)}
-              data-testid="open-comment-editor-button"
-            >
-              <span className="material-symbols-outlined">comment</span> Add Comment
-            </button>
-          </NotAvailableForReadOnlyUser>
-        </NotAvailableForGuest>
-      </div>
-    );
-  }, []);
-
-  // const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
-  //   setComment(newValue);
-  //   if (!isClean && !incremented) {
-  //     incrementEditingCommentsNum();
-  //     setIncremented(true);
-  //   }
-  //   mutateIsEnabledUnsavedWarning(!isClean);
-  // }, [mutateIsEnabledUnsavedWarning, incrementEditingCommentsNum, incremented]);
-
-  const onChangeHandler = useCallback((newValue: string) => {
-    setComment(newValue);
-
-    if (!incremented) {
-      incrementEditingCommentsNum();
-      setIncremented(true);
-    }
-  }, [incrementEditingCommentsNum, incremented]);
+  const onChangeHandler = useCallback(async(value: string) => {
+    const dirtyNum = await evaluateEditorDirtyMap(editorKey, value);
+    mutateIsEnabledUnsavedWarning(dirtyNum > 0);
+  }, [editorKey, evaluateEditorDirtyMap, mutateIsEnabledUnsavedWarning]);
 
   // initialize CodeMirrorEditor
   useEffect(() => {
@@ -299,116 +220,132 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   }, [codeMirrorEditor, commentBody]);
 
 
-  const renderReady = () => {
-    const commentPreview = getCommentHtml();
-
-    const errorMessage = <span className="text-danger text-end me-2">{error}</span>;
-    const cancelButton = (
-      <Button
-        outline
-        color="danger"
-        size="xs"
-        className="btn btn-outline-danger rounded-pill"
-        onClick={cancelButtonClickedHandler}
-      >
-        Cancel
-      </Button>
-    );
-    const submitButton = (
-      <Button
+  const errorMessage = useMemo(() => <span className="text-danger text-end me-2">{error}</span>, [error]);
+  const cancelButton = useMemo(() => (
+    <button
+      type="button"
+      className="btn btn-outline-neutral-secondary"
+      onClick={cancelButtonClickedHandler}
+    >
+      {t('Cancel')}
+    </button>
+  ), [cancelButtonClickedHandler, t]);
+  const submitButton = useMemo(() => {
+    return (
+      <button
+        type="button"
         data-testid="comment-submit-button"
-        outline
-        color="primary"
-        className="btn btn-outline-primary rounded-pill"
+        className="btn btn-primary"
         onClick={postCommentHandler}
       >
-        Comment
-      </Button>
+        {t('page_comment.comment')}
+      </button>
     );
+  }, [postCommentHandler, t]);
 
-    return (
-      <>
-        <div className="comment-write">
-          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
-          <TabContent activeTab={activeTab}>
-            <TabPane tabId="comment_editor">
-              <CodeMirrorEditorComment
-                acceptedUploadFileType={acceptedUploadFileType}
-                onChange={onChangeHandler}
-                onSave={postCommentHandler}
-                onUpload={uploadHandler}
-                editorSettings={editorSettings}
-              />
-              {/* <Editor
-                ref={editorRef}
-                value={commentBody ?? ''} // DO NOT use state
-                isUploadable={isUploadable}
-                isUploadAllFileAllowed={isUploadAllFileAllowed}
-                onChange={onChangeHandler}
-                onUpload={uploadHandler}
-                onCtrlEnter={ctrlEnterHandler}
-                isComment
-              /> */}
-              {/*
-                Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
-                See a review comment in https://github.com/weseek/growi/pull/3473
-              */}
-            </TabPane>
-            <TabPane tabId="comment_preview">
-              <div className="comment-form-preview">
-                {commentPreview}
-              </div>
-            </TabPane>
-          </TabContent>
-        </div>
-
-        <div className="comment-submit">
+  return (
+    <CommentEditorLayout>
+      <div className="px-4 pt-3 pb-1">
+        <div className="d-flex justify-content-between align-items-center mb-2">
           <div className="d-flex">
-            <span className="flex-grow-1" />
-            <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
-
-            {isSlackConfigured && isSlackEnabled != null
-              && (
-                <div className="align-self-center me-md-2">
-                  <SlackNotification
-                    isSlackEnabled={isSlackEnabled}
-                    slackChannels={slackChannels}
-                    onEnabledFlagChange={isSlackEnabledToggleHandler}
-                    onChannelChange={slackChannelsChangedHandler}
-                    id="idForComment"
-                  />
-                </div>
-              )
-            }
-            <div className="d-none d-sm-block">
-              <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
-          </div>
-          <div className="d-block d-sm-none mt-2">
-            <div className="d-flex justify-content-end">
-              {error && errorMessage}
-              <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
-            </div>
+            <UserPicture user={currentUser} noLink noTooltip />
+            <p className="ms-2 mb-0">{t('page_comment.add_a_comment')}</p>
           </div>
+          <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
         </div>
-      </>
-    );
-  };
+        <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
+          <TabPane tabId="comment_editor">
+            <CodeMirrorEditorComment
+              editorKey={editorKey}
+              acceptedUploadFileType={acceptedUploadFileType}
+              onChange={onChangeHandler}
+              onSave={postCommentHandler}
+              onUpload={uploadHandler}
+              editorSettings={editorSettings}
+            />
+          </TabPane>
+          <TabPane tabId="comment_preview">
+            <div className="comment-preview-container">
+              <CommentPreview markdown={codeMirrorEditor?.getDoc() ?? ''} />
+            </div>
+          </TabPane>
+        </TabContent>
+      </div>
 
-  return (
-    <div className={`${styles['comment-editor-styles']} form page-comment-form`}>
-      <div className="comment-form">
-        <div className="comment-form-user">
-          <UserPicture user={currentUser} noLink noTooltip />
-        </div>
-        <div className="comment-form-main">
-          {isReadyToUse
-            ? renderReady()
-            : renderBeforeReady()
+      <div className="comment-submit px-4 pb-3 mb-2">
+        <div className="d-flex">
+          <span className="flex-grow-1" />
+          <span className="d-none d-sm-inline">{errorMessage && errorMessage}</span>
+
+          {isSlackConfigured && isSlackEnabled != null
+            && (
+              <div className="align-self-center me-md-3">
+                <SlackNotification
+                  isSlackEnabled={isSlackEnabled}
+                  slackChannels={slackChannels}
+                  onEnabledFlagChange={isSlackEnabledToggleHandler}
+                  onChannelChange={slackChannelsChangedHandler}
+                  id="idForComment"
+                />
+              </div>
+            )
           }
+          <div className="d-none d-sm-block">
+            <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
+          </div>
+        </div>
+        <div className="d-block d-sm-none mt-2">
+          <div className="d-flex justify-content-end">
+            {error && errorMessage}
+            <span className="me-2">{cancelButton}</span><span>{submitButton}</span>
+          </div>
         </div>
       </div>
-    </div>
+    </CommentEditorLayout>
   );
 
 };
+
+
+export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
+
+  const { data: currentUser } = useCurrentUser();
+  const { mutate: mutateResolvedTheme } = useResolvedThemeForEditor();
+  const { resolvedTheme } = useNextThemes();
+  mutateResolvedTheme({ themeData: resolvedTheme });
+
+  const [isReadyToUse, setIsReadyToUse] = useState(false);
+
+  const { t } = useTranslation('');
+
+  const render = useCallback((): JSX.Element => {
+    return (
+      <CommentEditorLayout>
+        <NotAvailableForGuest>
+          <NotAvailableForReadOnlyUser>
+            <button
+              type="button"
+              className="btn btn-outline-primary w-100 text-start py-3"
+              onClick={() => setIsReadyToUse(true)}
+              data-testid="open-comment-editor-button"
+            >
+              <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
+              <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
+              <small>{t('page_comment.add_a_comment')}...</small>
+            </button>
+          </NotAvailableForReadOnlyUser>
+        </NotAvailableForGuest>
+      </CommentEditorLayout>
+    );
+  }, [currentUser, t]);
+
+  return isReadyToUse
+    ? (
+      <CommentEditor
+        onCommentButtonClicked={() => setIsReadyToUse(false)}
+        onCancelButtonClicked={() => setIsReadyToUse(false)}
+        {...props}
+      />
+    )
+    : render();
+};

+ 0 - 7
apps/app/src/components/PageComment/CommentPreview.module.scss

@@ -1,9 +1,2 @@
-@use '@growi/core/scss/bootstrap/init' as bs;
-@use './comment-inheritance';
-@use '../PageEditor/page-editor-inheritance';
-
 .grw-comment-preview {
-  min-height: page-editor-inheritance.$navbar-editor-height
-    + comment-inheritance.$codemirror-default-height
-    + bs.$line-height-base;
 }

+ 3 - 1
apps/app/src/components/PageComment/CommentPreview.tsx

@@ -5,6 +5,8 @@ import RevisionRenderer from '../Page/RevisionRenderer';
 
 import styles from './CommentPreview.module.scss';
 
+const moduleClass = styles['grw-comment-preview'] ?? '';
+
 
 type CommentPreviewPorps = {
   markdown: string,
@@ -21,7 +23,7 @@ export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
   }
 
   return (
-    <div className={`grw-comment-preview ${styles['grw-comment-preview']}`}>
+    <div className={moduleClass}>
       <RevisionRenderer
         rendererOptions={rendererOptions}
         markdown={markdown}

+ 9 - 5
apps/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -3,11 +3,13 @@ import React from 'react';
 import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 import { format } from 'date-fns';
+import { t } from 'i18next';
+import { useTranslation } from 'next-i18next';
 import {
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { ICommentHasId } from '../../interfaces/comment';
+import type { ICommentHasId } from '../../interfaces/comment';
 import { Username } from '../User/Username';
 
 import styles from './DeleteCommentModal.module.scss';
@@ -26,6 +28,8 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
     isShown, comment, errorMessage, cancelToDelete, confirmToDelete,
   } = props;
 
+  const { t } = useTranslation();
+
   const headerContent = () => {
     if (comment == null || isShown === false) {
       return <></>;
@@ -33,7 +37,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
     return (
       <span>
         <span className="material-symbols-outlined">delete_forever</span>
-        Delete comment?
+        {t('page_comment.delete_comment')}
       </span>
     );
   };
@@ -58,7 +62,7 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
 
     return (
       <>
-        <UserPicture user={creator} size="xs" /> <strong><Username user={creator}></Username></strong> wrote on {commentDate}:
+        <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
         <p className="card custom-card comment-body mt-2 p-2">{commentBodyElement}</p>
       </>
     );
@@ -71,10 +75,10 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
     return (
       <>
         <span className="text-danger">{errorMessage}</span>&nbsp;
-        <Button onClick={cancelToDelete}>Cancel</Button>
+        <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
         <Button color="danger" onClick={confirmToDelete}>
           <span className="material-symbols-outlined">delete_forever</span>
-          Delete
+          {t('Delete')}
         </Button>
       </>
     );

+ 0 - 6
apps/app/src/components/PageComment/ReplyComments.module.scss

@@ -1,9 +1,3 @@
-/*
-* reply
-*/
-.page-comment-reply :global {
-  margin-top: 1em;
-}
 
 // remove margin after hidden replies
 .page-comments-hidden-replies + .page-comment-reply :global {

+ 1 - 1
apps/app/src/components/PageComment/ReplyComments.tsx

@@ -40,7 +40,7 @@ export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
 
   const renderReply = (reply: ICommentHasId) => {
     return (
-      <div key={reply._id} className={`${styles['page-comment-reply']} ms-4 ms-sm-5 me-3`}>
+      <div key={reply._id} className={`${styles['page-comment-reply']} mt-2 ms-4 ms-sm-5`}>
         <Comment
           rendererOptions={rendererOptions}
           comment={reply}

+ 46 - 0
apps/app/src/components/PageComment/SwitchingButtonGroup.module.scss

@@ -0,0 +1,46 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+
+.btn-group-switching :global {
+  .btn {
+    --bs-btn-border-width: 2px;
+
+    width: 60px;
+    height: 38px;
+
+    @include bs.media-breakpoint-up(sm) {
+      width: auto;
+      height: 30px;
+    }
+  }
+}
+
+
+// == Colors
+@include bs.color-mode(light) {
+  .btn-group-switching :global {
+    .btn {
+      $bg: var(--bs-gray-500);
+
+      --bs-btn-border-color: #{$bg};
+      --bs-btn-hover-bg: var(--bs-gray-100);
+      --bs-btn-hover-border-color: #{$bg};
+      --bs-btn-active-color: white;
+      --bs-btn-active-bg: #{$bg};
+      --bs-btn-active-border-color: #{$bg};
+    }
+  }
+}
+@include bs.color-mode(dark) {
+  .btn-group-switching :global {
+    .btn {
+      $bg: var(--bs-gray-800);
+
+      --bs-btn-border-color: #{$bg};
+      --bs-btn-hover-bg: #{rgba(bs.$gray-600, 0.1)};
+      --bs-btn-hover-border-color: #{$bg};
+      --bs-btn-active-bg: #{$bg};
+      --bs-btn-active-border-color: #{$bg};
+    }
+  }
+}

+ 73 - 0
apps/app/src/components/PageComment/SwitchingButtonGroup.tsx

@@ -0,0 +1,73 @@
+import type { ButtonHTMLAttributes, DetailedHTMLProps } from 'react';
+import { memo } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import styles from './SwitchingButtonGroup.module.scss';
+
+const moduleClass = styles['btn-group-switching'] ?? '';
+
+
+type SwitchingButtonProps = DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
+    active?: boolean,
+}
+
+const SwitchingButton = memo((props: SwitchingButtonProps) => {
+  const {
+    active, className, children, onClick, ...rest
+  } = props;
+
+  return (
+    <button
+      type="button"
+      className={`btn btn-sm py-1 d-flex align-items-center justify-content-center
+        ${className}
+        ${active ? 'active' : ''}
+      `}
+      onClick={onClick}
+      {...rest}
+    >
+      {children}
+    </button>
+  );
+});
+
+
+type Props = {
+  showPreview: boolean,
+  onSelected?: (showPreview: boolean) => void,
+};
+
+export const SwitchingButtonGroup = (props: Props): JSX.Element => {
+
+  const { t } = useTranslation();
+
+  const {
+    showPreview, onSelected,
+  } = props;
+
+  return (
+    <div
+      className={`btn-group ${moduleClass}`}
+      role="group"
+    >
+      <SwitchingButton
+        active={showPreview}
+        className="ps-2 pe-3"
+        onClick={() => onSelected?.(true)}
+      >
+        <span className="material-symbols-outlined me-0">play_arrow</span>
+        <span className="d-none d-sm-inline">{t('page_comment.preview')}</span>
+      </SwitchingButton>
+      <SwitchingButton
+        active={!showPreview}
+        className="px-2"
+        onClick={() => onSelected?.(false)}
+      >
+        <span className="material-symbols-outlined me-1">edit_square</span>
+        <span className="d-none d-sm-inline">{t('page_comment.write')}</span>
+      </SwitchingButton>
+    </div>
+  );
+
+};

+ 19 - 8
apps/app/src/components/PageComment/_comment-inheritance.scss

@@ -22,15 +22,26 @@
 }
 
 %picture {
-  float: left;
-  width: 3em;
-  height: 3em;
-  margin-top: 0.8em;
+  width: 1.2em;
+  height: 1.2em;
+}
+
+$codemirror-default-height: 300px;
 
-  @include bs.media-breakpoint-down(xs) {
-    width: 2em;
-    height: 2em;
+// // Light mode color
+@include bs.color-mode(light) {
+  %bg-comment {
+    background-color: rgba( bs.$gray-200, 0.5 );
+    border: 1px solid bs.$gray-200;
+    backdrop-filter: blur(10px);
   }
 }
 
-$codemirror-default-height: 300px;
+// // Dark mode color
+@include bs.color-mode(dark) {
+  %bg-comment {
+    background-color: rgba( bs.$gray-800, 0.3 );
+    border: 1px solid bs.$gray-700;
+    backdrop-filter: blur(10px);
+  }
+}

+ 68 - 55
apps/app/src/components/PageControls/PageControls.tsx

@@ -1,4 +1,6 @@
-import React, { memo, useCallback, useMemo } from 'react';
+import React, {
+  memo, useCallback, useEffect, useMemo, useRef,
+} from 'react';
 
 import type {
   IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
@@ -6,6 +8,7 @@ import type {
 import {
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
+import { useRect } from '@growi/ui/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { DropdownItem } from 'reactstrap';
 
@@ -15,7 +18,9 @@ import {
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
+import {
+  EditorMode, useEditorMode, useIsDeviceLargerThanMd, usePageControlsX,
+} from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
@@ -49,10 +54,10 @@ const Tags = (props: TagsProps): JSX.Element => {
     <div className="grw-tag-labels-container d-flex align-items-center">
       <button
         type="button"
-        className="btn btn-link btn-edit-tags text-muted border border-secondary p-1 d-flex align-items-center"
+        className="btn btn-sm btn-outline-neutral-secondary"
         onClick={onClickEditTagsButton}
       >
-        <span className="material-symbols-outlined me-2">local_offer</span>
+        <span className="material-symbols-outlined me-1">local_offer</span>
         Tags
       </button>
     </div>
@@ -94,7 +99,13 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 type CommonProps = {
+  pageId: string,
+  shareLinkId?: string | null,
+  revisionId?: string | null,
+  path?: string | null,
+  expandContentWidth?: boolean,
   disableSeenUserInfoPopover?: boolean,
+  hideSubControls?: boolean,
   showPageControlDropdown?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
   additionalMenuItemRenderer?: React.FunctionComponent<AdditionalMenuItemsRendererProps>,
@@ -105,12 +116,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageId: string,
-  shareLinkId?: string | null,
-  revisionId?: string | null,
-  path?: string | null,
   pageInfo: IPageInfoForOperation,
-  expandContentWidth?: boolean,
   onClickEditTagsButton: () => void,
 }
 
@@ -118,7 +124,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const {
     pageInfo,
     pageId, revisionId, path, shareLinkId, expandContentWidth,
-    disableSeenUserInfoPopover, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
+    disableSeenUserInfoPopover, hideSubControls, showPageControlDropdown, forceHideMenuItems, additionalMenuItemRenderer,
     onClickEditTagsButton, onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
   } = props;
 
@@ -132,6 +138,19 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
+  const { mutateAndSave: mutatePageControlsX } = usePageControlsX();
+
+  const pageControlsRef = useRef<HTMLDivElement>(null);
+  const [pageControlsRect] = useRect(pageControlsRef);
+
+  useEffect(() => {
+    if (pageControlsRect?.x == null) {
+      return;
+    }
+    mutatePageControlsX(pageControlsRect.x);
+  }, [pageControlsRect?.x, mutatePageControlsX]);
+
+
   // Put in a mixture of seenUserIds and likerIds data to make the cache work
   const { data: usersList } = useSWRxUsersList([...likerIds, ...seenUserIds]);
   const likers = usersList != null ? usersList.filter(({ _id }) => likerIds.includes(_id)).slice(0, 15) : [];
@@ -253,46 +272,52 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const isViewMode = editorMode === EditorMode.View;
 
   return (
-    <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+    <div className={`${styles['grw-page-controls']} hstack gap-2`} ref={pageControlsRef}>
       { isDeviceLargerThanMd && (
         <SearchButton />
       )}
+
       {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
         />
       )}
-      {revisionId != null && _isIPageInfoForOperation && (
-        <SubscribeButton
-          status={pageInfo.subscriptionStatus}
-          onClick={subscribeClickhandler}
-        />
-      )}
-      {revisionId != null && _isIPageInfoForOperation && (
-        <LikeButtons
-          onLikeClicked={likeClickhandler}
-          sumOfLikers={sumOfLikers}
-          isLiked={isLiked}
-          likers={likers}
-        />
-      )}
-      {revisionId != null && _isIPageInfoForOperation && (
-        <BookmarkButtons
-          pageId={pageId}
-          isBookmarked={pageInfo.isBookmarked}
-          bookmarkCount={pageInfo.bookmarkCount}
-        />
-      )}
-      {revisionId != null && (
-        <SeenUserInfo
-          seenUsers={seenUsers}
-          sumOfSeenUsers={sumOfSeenUsers}
-          disabled={disableSeenUserInfoPopover}
-        />
+
+      { !hideSubControls && (
+        <div className="hstack gap-1">
+          {revisionId != null && _isIPageInfoForOperation && (
+            <SubscribeButton
+              status={pageInfo.subscriptionStatus}
+              onClick={subscribeClickhandler}
+            />
+          )}
+          {revisionId != null && _isIPageInfoForOperation && (
+            <LikeButtons
+              onLikeClicked={likeClickhandler}
+              sumOfLikers={sumOfLikers}
+              isLiked={isLiked}
+              likers={likers}
+            />
+          )}
+          {revisionId != null && _isIPageInfoForOperation && (
+            <BookmarkButtons
+              pageId={pageId}
+              isBookmarked={pageInfo.isBookmarked}
+              bookmarkCount={pageInfo.bookmarkCount}
+            />
+          )}
+          {revisionId != null && (
+            <SeenUserInfo
+              seenUsers={seenUsers}
+              sumOfSeenUsers={sumOfSeenUsers}
+              disabled={disableSeenUserInfoPopover}
+            />
+          ) }
+        </div>
       ) }
+
       { showPageControlDropdown && _isIPageInfoForOperation && (
         <PageItemControl
-          alignEnd
           pageId={pageId}
           pageInfo={pageInfo}
           isEnableActions={!isGuestUser}
@@ -309,18 +334,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   );
 };
 
-type PageControlsProps = CommonProps & {
-  pageId: string,
-  shareLinkId?: string | null,
-  revisionId?: string | null,
-  path?: string | null,
-  expandContentWidth?: boolean,
-};
+type PageControlsProps = CommonProps;
 
 export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const {
-    pageId, revisionId, path, shareLinkId, expandContentWidth,
-    onClickDuplicateMenuItem, onClickRenameMenuItem, onClickDeleteMenuItem, onClickSwitchContentWidth,
+    pageId, revisionId, shareLinkId,
+    ...rest
   } = props;
 
   const { data: pageInfo, error } = useSWRxPageInfo(pageId ?? null, shareLinkId);
@@ -344,17 +363,11 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
 
   return (
     <PageControlsSubstance
-      {...props}
       pageInfo={pageInfo}
       pageId={pageId}
       revisionId={revisionId}
-      path={path}
       onClickEditTagsButton={onClickEditTagsButton}
-      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-      onClickRenameMenuItem={onClickRenameMenuItem}
-      onClickDeleteMenuItem={onClickDeleteMenuItem}
-      onClickSwitchContentWidth={onClickSwitchContentWidth}
-      expandContentWidth={expandContentWidth}
+      {...rest}
     />
   );
 });

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

@@ -17,7 +17,7 @@ const SearchButton = (): JSX.Element => {
   return (
     <button
       type="button"
-      className={`me-3 btn btn-search ${styles['btn-search']}`}
+      className={`btn btn-search ${styles['btn-search']}`}
       onClick={searchButtonClickHandler}
       data-testid="open-search-modal-button"
     >

+ 1 - 1
apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx

@@ -12,7 +12,7 @@ export const EditorNavbar = (): JSX.Element => {
   const { data: editingUsers } = useEditingUsers();
 
   return (
-    <div className={`${moduleClass} d-flex justify-content-between px-4 py-1 ms-3`}>
+    <div className={`${moduleClass} d-flex justify-content-between px-4 py-1`}>
       <PageHeader />
       <EditingUserList
         userList={editingUsers?.userList ?? []}

+ 3 - 3
apps/app/src/components/PageEditor/EditorNavbarBottom.tsx

@@ -10,11 +10,11 @@ const OptionsSelector = dynamic(() => import('~/components/PageEditor/OptionsSel
 const EditorNavbarBottom = (): JSX.Element => {
   return (
     <div className="border-top" data-testid="grw-editor-navbar-bottom">
-      <div className={`flex-expand-horiz align-items-center px-2 py-1 py-md-2 px-md-3 ${moduleClass}`}>
-        <form className="m-2 me-auto">
+      <div className={`flex-expand-horiz align-items-center p-2 ps-md-3 pe-md-4 ${moduleClass}`}>
+        <form className="me-auto">
           <OptionsSelector />
         </form>
-        <form className="m-2">
+        <form>
           <SavePageControls />
         </form>
       </div>

+ 43 - 43
apps/app/src/components/PageEditor/PageEditor.tsx

@@ -21,7 +21,7 @@ import { throttle, debounce } from 'throttle-debounce';
 import { useShouldExpandContent } from '~/client/services/layout';
 import { useUpdateStateAfterSave } from '~/client/services/page-operation';
 import { updatePage, extractRemoteRevisionDataFromErrorObj } from '~/client/services/update-page';
-import { apiv3Get, apiv3PostForm } from '~/client/util/apiv3-client';
+import { uploadAttachments } from '~/client/services/upload-attachments';
 import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
 import {
   useDefaultIndentSize, useCurrentUser,
@@ -36,7 +36,7 @@ import {
   useWaitingSaveProcessing,
 } from '~/stores/editor';
 import {
-  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData,
+  useCurrentPagePath, useSWRxCurrentPage, useCurrentPageId, useIsNotFound, useTemplateBodyData, useSWRxCurrentGrantData,
 } from '~/stores/page';
 import { mutatePageTree } from '~/stores/page-listing';
 import { usePreviewOptions } from '~/stores/renderer';
@@ -66,6 +66,7 @@ declare global {
 }
 
 export type SaveOptions = {
+  wip: boolean,
   slackChannels: string,
   overwriteScopesOfDescendants?: boolean
 }
@@ -92,7 +93,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPathname } = useCurrentPathname();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { data: grantData } = useSelectedGrant();
+  const { data: selectedGrant } = useSelectedGrant();
   const { data: editingMarkdown } = useEditingMarkdown();
   const { data: isEnabledAttachTitleHeader } = useIsEnabledAttachTitleHeader();
   const { data: templateBodyData } = useTemplateBodyData();
@@ -104,6 +105,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const { data: defaultIndentSize } = useDefaultIndentSize();
   const { data: acceptedUploadFileType } = useAcceptedUploadFileType();
   const { data: editorSettings } = useEditorSettings();
+  const { mutate: mutateIsGrantNormalized } = useSWRxCurrentGrantData(currentPage?._id);
   const { data: user } = useCurrentUser();
   const { onEditorsUpdated } = useEditingUsers();
   const onConflict = useConflictResolver();
@@ -165,9 +167,9 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
   const scrollPreviewHandlerThrottle = useMemo(() => throttle(25, scrollPreviewHandler), [scrollPreviewHandler]);
 
   const save: Save = useCallback(async(revisionId, markdown, opts, onConflict) => {
-    if (pageId == null || grantData == null) {
+    if (pageId == null || selectedGrant == null) {
       logger.error('Some materials to save are invalid', {
-        pageId, grantData,
+        pageId, selectedGrant,
       });
       throw new Error('Some materials to save are invalid');
     }
@@ -178,17 +180,18 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
       const { page } = await updatePage({
         pageId,
         revisionId,
+        wip: opts?.wip,
         body: markdown ?? '',
-        grant: grantData?.grant,
+        grant: selectedGrant?.grant,
         origin: Origin.Editor,
-        userRelatedGrantUserGroupIds: grantData?.userRelatedGrantedGroups?.map((group) => {
-          return { item: group.id, type: group.type };
-        }),
+        userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
         ...(opts ?? {}),
       });
 
       // to sync revision id with page tree: https://github.com/weseek/growi/pull/7227
       mutatePageTree();
+      // sync current grant data after update
+      mutateIsGrantNormalized();
 
       return page;
     }
@@ -208,7 +211,7 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     finally {
       mutateWaitingSaveProcessing(false);
     }
-  }, [pageId, grantData, mutateWaitingSaveProcessing, t]);
+  }, [pageId, selectedGrant, mutateWaitingSaveProcessing, t, mutateIsGrantNormalized]);
 
   const saveAndReturnToViewHandler = useCallback(async(opts: SaveOptions) => {
     const markdown = codeMirrorEditor?.getDoc();
@@ -237,47 +240,30 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
 
   // the upload event handler
   const uploadHandler = useCallback((files: File[]) => {
-    files.forEach(async(file) => {
-      try {
-        const { data: resLimit } = await apiv3Get('/attachment/limit', { fileSize: file.size });
-
-        if (!resLimit.isUploadable) {
-          throw new Error(resLimit.errorMessage);
-        }
-
-        const formData = new FormData();
-        formData.append('file', file);
-        if (pageId != null) {
-          formData.append('page_id', pageId);
-        }
-
-        const { data: resAdd } = await apiv3PostForm('/attachment', formData);
+    if (pageId == null) {
+      logger.error('pageId is invalid', {
+        pageId,
+      });
+      throw new Error('pageId is invalid');
+    }
 
-        const attachment = resAdd.attachment;
+    uploadAttachments(pageId, files, {
+      onUploaded: (attachment) => {
         const fileName = attachment.originalName;
 
-        let insertText = `[${fileName}](${attachment.filePathProxied})\n`;
-        // when image
-        if (attachment.fileFormat.startsWith('image/')) {
-          // modify to "![fileName](url)" syntax
-          insertText = `!${insertText}`;
-        }
+        const prefix = attachment.fileFormat.startsWith('image/')
+          ? '!' // use "![fileName](url)" syntax when image
+          : '';
+        const insertText = `${prefix}[${fileName}](${attachment.filePathProxied})\n`;
 
         codeMirrorEditor?.insertText(insertText);
-      }
-      catch (e) {
-        logger.error('failed to upload', e);
-        toastError(e);
-      }
+      },
+      onError: (error) => {
+        toastError(error);
+      },
     });
-
   }, [codeMirrorEditor, pageId]);
 
-  // initial caret line
-  useEffect(() => {
-    codeMirrorEditor?.setCaretLine();
-  }, [codeMirrorEditor]);
-
   // set handler to save and return to View
   useEffect(() => {
     globalEmitter.on('saveAndReturnToView', saveAndReturnToViewHandler);
@@ -287,6 +273,20 @@ export const PageEditor = React.memo((props: Props): JSX.Element => {
     };
   }, [saveAndReturnToViewHandler]);
 
+
+  // TODO: https://redmine.weseek.co.jp/issues/142729
+  // https://regex101.com/r/Wg2Hh6/1
+  // initial caret line
+  useEffect(() => {
+    const untitledPageRegex = /^Untitled-\d+$/;
+    const isNewlyCreatedPage = (
+      currentPage?.wip && currentPage?.latestRevision == null && untitledPageRegex.test(nodePath.basename(currentPage?.path ?? ''))
+    ) ?? false;
+    if (!isNewlyCreatedPage) {
+      codeMirrorEditor?.setCaretLine();
+    }
+  }, [codeMirrorEditor, currentPage]);
+
   // set handler to focus
   useLayoutEffect(() => {
     if (editorMode === EditorMode.Editor) {

+ 1 - 0
apps/app/src/components/PageEditor/Preview.tsx

@@ -32,6 +32,7 @@ const Preview = (props: Props): JSX.Element => {
 
   return (
     <div
+      data-testid="page-editor-preview-body"
       className={`${moduleClass} ${fluidLayoutClass} ${pagePath === '/Sidebar' ? 'preview-sidebar' : ''}`}
       style={style}
     >

+ 22 - 8
apps/app/src/components/PageEditor/ScrollSyncHelper.tsx

@@ -19,12 +19,12 @@ const getDataLine = (element: Element | null): number => {
 
 const getEditorElements = (editorRootElement: HTMLElement): Array<Element> => {
   return Array.from(editorRootElement.getElementsByClassName('cm-line'))
-    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line')) });
+    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN) });
 };
 
 const getPreviewElements = (previewRootElement: HTMLElement): Array<Element> => {
   return Array.from(previewRootElement.getElementsByClassName('has-data-line'))
-    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line')) });
+    .filter((element) => { return !Number.isNaN(element.getAttribute('data-line') ?? Number.NaN) });
 };
 
 // Ref: https://github.com/mikolalysenko/binary-search-bounds/blob/f436a2a8af11bf3208434e18bbac17e18e7a3a30/search-bounds.js
@@ -63,14 +63,14 @@ const findElementIndexFromDataLine = (previewElements: Array<Element>, dataline:
 
 
 type SourceElement = {
-  start: DOMRect,
-  top: DOMRect,
-  next: DOMRect | undefined,
+  start?: DOMRect,
+  top?: DOMRect,
+  next?: DOMRect,
 }
 
 type TargetElement = {
-  start: DOMRect,
-  next: DOMRect | undefined,
+  start?: DOMRect,
+  next?: DOMRect,
 }
 
 const calcScrollElementToTop = (element: Element): number => {
@@ -78,7 +78,13 @@ const calcScrollElementToTop = (element: Element): number => {
 };
 
 const calcScorllElementByRatio = (sourceElement: SourceElement, targetElement: TargetElement): number => {
-  if (sourceElement.start === sourceElement.next || sourceElement.next == null || targetElement.next == null) {
+  if (sourceElement.start === sourceElement.next) {
+    return 0;
+  }
+  if (sourceElement.start == null || sourceElement.top == null || sourceElement.next == null) {
+    return 0;
+  }
+  if (targetElement.start == null || targetElement.next == null) {
     return 0;
   }
   const sourceAllHeight = sourceElement.next.top - sourceElement.start.top;
@@ -107,6 +113,10 @@ const scrollEditor = (editorRootElement: HTMLElement, previewRootElement: HTMLEl
 
   let newScrollTop = previewRootElement.scrollTop;
 
+  if (previewElements[topPreviewElementIndex] == null) {
+    return;
+  }
+
   newScrollTop += calcScrollElementToTop(previewElements[topPreviewElementIndex]);
   newScrollTop += calcScorllElementByRatio(
     {
@@ -136,6 +146,10 @@ const scrollPreview = (editorRootElement: HTMLElement, previewRootElement: HTMLE
   const startEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex]));
   const nextEditorElementIndex = findElementIndexFromDataLine(editorElements, getDataLine(previewElements[topPreviewElementIndex + 1]));
 
+  if (editorElements[startEditorElementIndex] == null) {
+    return;
+  }
+
   let newScrollTop = editorRootElement.scrollTop;
 
   newScrollTop += calcScrollElementToTop(editorElements[startEditorElementIndex]);

+ 1 - 1
apps/app/src/components/PageEditor/_page-editor-inheritance.scss

@@ -1 +1 @@
-$navbar-editor-height: 30px;
+$navbar-editor-height: 32.8px;

+ 1 - 2
apps/app/src/components/PageHeader/PageHeader.tsx

@@ -21,9 +21,8 @@ export const PageHeader: FC = () => {
       <PagePathHeader
         currentPage={currentPage}
       />
-      <div className="row mt-1">
+      <div className="mt-1">
         <PageTitleHeader
-          className="col"
           currentPage={currentPage}
         />
       </div>

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