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

Merge branch 'master' into imprv/140673-143662-search-results-form-improvements

maeshinshin 2 лет назад
Родитель
Сommit
3af1702c5c
87 измененных файлов с 1469 добавлено и 485 удалено
  1. 7 7
      .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 18
      .github/workflows/reusable-app-prod.yml
  13. 4 4
      .github/workflows/reusable-app-reg-suit.yml
  14. 710 0
      apps/app/_obsolete/src/styles/theme/apply-colors.scss
  15. 2 0
      apps/app/public/static/locales/en_US/translation.json
  16. 2 0
      apps/app/public/static/locales/ja_JP/translation.json
  17. 2 0
      apps/app/public/static/locales/zh_CN/translation.json
  18. 7 0
      apps/app/src/client/services/create-page/create-page.ts
  19. 1 0
      apps/app/src/client/services/create-page/index.ts
  20. 3 1
      apps/app/src/client/services/create-page/use-create-page-and-transit.tsx
  21. 0 8
      apps/app/src/client/services/page-operation.ts
  22. 1 0
      apps/app/src/client/services/upload-attachments/index.ts
  23. 39 0
      apps/app/src/client/services/upload-attachments/upload-attachments.ts
  24. 1 1
      apps/app/src/client/util/apiv3-client.ts
  25. 2 1
      apps/app/src/client/util/toastr.ts
  26. 5 2
      apps/app/src/components/Comments.tsx
  27. 3 1
      apps/app/src/components/FontFamily/use-growi-custom-icons.tsx
  28. 1 1
      apps/app/src/components/FontFamily/use-lato.tsx
  29. 2 1
      apps/app/src/components/FontFamily/use-material-symbols-outlined.tsx
  30. 1 1
      apps/app/src/components/FontFamily/use-source-han-code-jp.tsx
  31. 14 0
      apps/app/src/components/Layout/RawLayout.module.scss
  32. 8 3
      apps/app/src/components/Layout/RawLayout.tsx
  33. 29 2
      apps/app/src/components/PageComment.module.scss
  34. 14 14
      apps/app/src/components/PageComment.tsx
  35. 32 23
      apps/app/src/components/PageComment/Comment.module.scss
  36. 16 20
      apps/app/src/components/PageComment/Comment.tsx
  37. 2 2
      apps/app/src/components/PageComment/CommentControl.tsx
  38. 21 20
      apps/app/src/components/PageComment/CommentEditor.module.scss
  39. 50 89
      apps/app/src/components/PageComment/CommentEditor.tsx
  40. 0 7
      apps/app/src/components/PageComment/CommentPreview.module.scss
  41. 3 1
      apps/app/src/components/PageComment/CommentPreview.tsx
  42. 0 6
      apps/app/src/components/PageComment/ReplyComments.module.scss
  43. 1 1
      apps/app/src/components/PageComment/ReplyComments.tsx
  44. 46 0
      apps/app/src/components/PageComment/SwitchingButtonGroup.module.scss
  45. 73 0
      apps/app/src/components/PageComment/SwitchingButtonGroup.tsx
  46. 19 8
      apps/app/src/components/PageComment/_comment-inheritance.scss
  47. 1 1
      apps/app/src/components/PageEditor/EditorNavbar/EditorNavbar.tsx
  48. 3 3
      apps/app/src/components/PageEditor/EditorNavbarBottom.tsx
  49. 31 34
      apps/app/src/components/PageEditor/PageEditor.tsx
  50. 22 8
      apps/app/src/components/PageEditor/ScrollSyncHelper.tsx
  51. 1 1
      apps/app/src/components/PageEditor/_page-editor-inheritance.scss
  52. 1 2
      apps/app/src/components/PageHeader/PageHeader.tsx
  53. 4 3
      apps/app/src/components/PageHeader/PagePathHeader.tsx
  54. 11 1
      apps/app/src/components/PageHeader/PageTitleHeader.module.scss
  55. 35 8
      apps/app/src/components/PageHeader/PageTitleHeader.tsx
  56. 22 7
      apps/app/src/components/ReactMarkdownComponents/LightBox.tsx
  57. 0 1
      apps/app/src/components/Sidebar/PageCreateButton/CreateButton.tsx
  58. 1 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx
  59. 1 0
      apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx
  60. 1 0
      apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx
  61. 6 7
      apps/app/src/components/Sidebar/Tag.tsx
  62. 1 1
      apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx
  63. 2 4
      apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx
  64. 7 4
      apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts
  65. 4 4
      apps/app/src/features/questionnaire/client/components/ProactiveQuestionnaireModal.tsx
  66. 1 2
      apps/app/src/features/search/client/components/SearchModal.tsx
  67. 2 1
      apps/app/src/features/search/client/components/SearchResultMenuItem.tsx
  68. 15 0
      apps/app/src/interfaces/apiv3/attachment.ts
  69. 5 0
      apps/app/src/interfaces/attachment.ts
  70. 4 4
      apps/app/src/server/crowi/index.js
  71. 1 0
      apps/app/src/server/models/attachment.ts
  72. 3 3
      apps/app/src/server/models/page.ts
  73. 11 6
      apps/app/src/server/models/user-group-relation.ts
  74. 11 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  75. 1 1
      apps/app/src/server/routes/apiv3/page/create-page.ts
  76. 1 1
      apps/app/src/server/service/file-uploader/aws.ts
  77. 3 7
      apps/app/src/server/service/file-uploader/file-uploader.ts
  78. 9 3
      apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts
  79. 4 1
      apps/app/src/stores/page-listing.tsx
  80. 1 8
      apps/app/src/styles/organisms/_wiki.scss
  81. 16 20
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  82. 3 3
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  83. 1 1
      package.json
  84. 17 0
      packages/core/scss/bootstrap/_variables.scss
  85. 1 1
      packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx
  86. 0 2
      packages/editor/src/stores/codemirror-editor.ts
  87. 17 37
      yarn.lock

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

@@ -48,13 +48,13 @@ concurrency:
 
 jobs:
 
-  test-prod-node18:
-    uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
-    with:
-      node-version: 18.x
-      skip-cypress: true
-    secrets:
-      SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
+  # test-prod-node18:
+  #   uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+  #   with:
+  #     node-version: 18.x
+  #     skip-cypress: true
+  #   secrets:
+  #     SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
 
 
   test-prod-node20:

+ 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 - 18
.github/workflows/reusable-app-prod.yml

@@ -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 }})
 
@@ -214,7 +214,8 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '22', '23', '30', '40', '50', '60']
+        # spec-group: ['10', '20', '21', '22', '23', '30', '40', '50', '60']
+        spec-group: ['50']
 
     services:
       mongodb:
@@ -229,12 +230,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 +253,7 @@ jobs:
 
     - name: Cache/Restore node_modules
       id: cache-dependencies
-      uses: actions/cache@v3
+      uses: actions/cache@v4
       with:
         path: |
           **/node_modules
@@ -261,7 +262,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 +277,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,7 +324,7 @@ jobs:
 
     - name: Upload results
       if: always()
-      uses: actions/upload-artifact@v3
+      uses: actions/upload-artifact@v4
       with:
         name: ${{ inputs.cypress-report-artifact-name }}
         path: |

+ 4 - 4
.github/workflows/reusable-app-reg-suit.yml

@@ -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,7 +86,7 @@ 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

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

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

@@ -328,6 +328,8 @@
     "changes_not_saved": "Changes you made may not be saved. Are you sure you want to move?"
   },
   "page_comment": {
+    "comments": "Commments",
+    "comment": "Commment",
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
     "no_user_found": "No user found"
   },

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

@@ -361,6 +361,8 @@
     "changes_not_saved": "変更が保存されていない可能性があります。本当に移動しますか?"
   },
   "page_comment": {
+    "comments": "コメント",
+    "comment": "コメント",
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",
     "no_user_found": "ユーザー名が見つかりません"
   },

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

@@ -318,6 +318,8 @@
     "changes_not_saved": "您所做的更改可能不会保存。你真的想继续前进吗?"
   },
   "page_comment": {
+    "comments": "评论",
+    "comment": "评论",
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
     "no_user_found": "未找到用户名"
   },

+ 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');
 
 /**

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

@@ -4,9 +4,6 @@ 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 { useSetRemoteLatestPageData } from '~/stores/remote-latest-page';
@@ -91,11 +88,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,
 }

+ 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

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

+ 5 - 2
apps/app/src/components/Comments.tsx

@@ -2,6 +2,7 @@ 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';
 
@@ -13,7 +14,6 @@ import { useCurrentUser } from '../stores/context';
 
 import type { CommentEditorProps } from './PageComment/CommentEditor';
 
-
 const { isTopPage } = pagePathUtils;
 
 
@@ -33,6 +33,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 +71,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}

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

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

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

+ 14 - 14
apps/app/src/components/PageComment.tsx

@@ -1,17 +1,18 @@
+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 { 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';
@@ -153,10 +154,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 +171,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>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;
   }
 }

+ 50 - 89
apps/app/src/components/PageComment/CommentEditor.tsx

@@ -6,13 +6,14 @@ 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';
@@ -26,11 +27,12 @@ import { useCurrentPagePath } from '~/stores/page';
 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';
@@ -41,18 +43,6 @@ 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',
-  },
-};
-
 export type CommentEditorProps = {
   pageId: string,
   isForNewComment?: boolean,
@@ -92,11 +82,13 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
-  const [activeTab, setActiveTab] = useState('comment_editor');
+  const [showPreview, setShowPreview] = useState(false);
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState<string>('');
   const [incremented, setIncremented] = useState(false);
 
+  const { t } = useTranslation('');
+
   const editorRef = useRef<IEditorMethods>(null);
 
   const router = useRouter();
@@ -113,8 +105,8 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     };
   }, [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
@@ -140,7 +132,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
     const editingCommentsNum = comment !== '' ? await decrementEditingCommentsNum() : undefined;
 
     setComment('');
-    setActiveTab('comment_editor');
+    setShowPreview(false);
     setError(undefined);
     initializeSlackEnabled();
     // reset value
@@ -209,40 +201,21 @@ export const CommentEditor = (props: CommentEditorProps): 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);
-
-        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(() => {
@@ -255,22 +228,24 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const renderBeforeReady = useCallback((): JSX.Element => {
     return (
-      <div className="text-center">
+      <div>
         <NotAvailableForGuest>
           <NotAvailableForReadOnlyUser>
             <button
               type="button"
-              className="btn btn-lg btn-link"
+              className="btn btn-outline-primary w-100 text-start py-3"
               onClick={() => setIsReadyToUse(true)}
               data-testid="open-comment-editor-button"
             >
-              <span className="material-symbols-outlined">comment</span> Add Comment
+              <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-3" />
+              <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
+              <small>Add Comment in markdown...</small>
             </button>
           </NotAvailableForReadOnlyUser>
         </NotAvailableForGuest>
       </div>
     );
-  }, []);
+  }, [currentUser]);
 
   // const onChangeHandler = useCallback((newValue: string, isClean: boolean) => {
   //   setComment(newValue);
@@ -304,33 +279,36 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
     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"
+      <button
+        type="button"
+        className="btn btn-outline-neutral-secondary"
         onClick={cancelButtonClickedHandler}
       >
-        Cancel
-      </Button>
+        {t('Cancel')}
+      </button>
     );
     const submitButton = (
-      <Button
+      <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>
     );
 
     return (
       <>
-        <div className="comment-write">
-          <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
-          <TabContent activeTab={activeTab}>
+        <div className="px-4 pt-3 pb-1">
+          <div className="d-flex justify-content-between align-items-center mb-2">
+            <div className="d-flex">
+              <UserPicture user={currentUser} noLink noTooltip />
+              <p className="ms-2 mb-0">Add a comment</p>
+            </div>
+            <SwitchingButtonGroup showPreview={showPreview} onSelected={handleSelect} />
+          </div>
+          <TabContent activeTab={showPreview ? 'comment_preview' : 'comment_editor'}>
             <TabPane tabId="comment_editor">
               <CodeMirrorEditorComment
                 acceptedUploadFileType={acceptedUploadFileType}
@@ -339,37 +317,23 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
                 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">
+              <div className="comment-preview-container">
                 {commentPreview}
               </div>
             </TabPane>
           </TabContent>
         </div>
 
-        <div className="comment-submit">
+        <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-2">
+                <div className="align-self-center me-md-3">
                   <SlackNotification
                     isSlackEnabled={isSlackEnabled}
                     slackChannels={slackChannels}
@@ -398,10 +362,7 @@ export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
   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">
+        <div className="comment-form-main bg-comment rounded">
           {isReadyToUse
             ? renderReady()
             : renderBeforeReady()

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

+ 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: 90px;
+      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('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('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);
+  }
+}

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

+ 31 - 34
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,
@@ -237,47 +237,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 +270,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) {

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

+ 4 - 3
apps/app/src/components/PageHeader/PagePathHeader.tsx

@@ -23,12 +23,13 @@ const moduleClass = styles['page-path-header'];
 
 
 type Props = {
-  currentPage: IPagePopulatedToShowRevision
+  currentPage: IPagePopulatedToShowRevision,
+  className?: string,
 }
 
 export const PagePathHeader: FC<Props> = memo((props: Props) => {
   const { t } = useTranslation();
-  const { currentPage } = props;
+  const { currentPage, className } = props;
 
   const dPagePath = new DevidedPagePath(currentPage.path, true);
   const parentPagePath = dPagePath.former;
@@ -102,7 +103,7 @@ export const PagePathHeader: FC<Props> = memo((props: Props) => {
   return (
     <div
       id="page-path-header"
-      className={`d-flex ${moduleClass} small position-relative`}
+      className={`d-flex ${moduleClass} ${className ?? ''} small position-relative ms-2`}
       onMouseEnter={() => setHover(true)}
       onMouseLeave={() => setHover(false)}
     >

+ 11 - 1
apps/app/src/components/PageHeader/PageTitleHeader.module.scss

@@ -5,6 +5,16 @@
     min-height: unset;
     padding: 0 0.5rem;
     line-height: 1em;
-    transform: translateX(-0.55rem) translateY(-0.05rem);
+    transform: translateX(0.05rem) translateY(0.05rem);
+  }
+}
+
+.page-title-header-border-color {
+  --bs-border-color: transparent;
+
+  &:global {
+    &:hover {
+      --bs-border-color: var(--bs-primary-border-subtle);
+    }
   }
 }

+ 35 - 8
apps/app/src/components/PageHeader/PageTitleHeader.tsx

@@ -1,11 +1,12 @@
 import type { FC } from 'react';
-import { useState, useCallback } from 'react';
+import { useState, useCallback, useEffect } from 'react';
 
 import nodePath from 'path';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
+import { isMovablePage } from '@growi/core/dist/utils/page-path-utils';
 import { useTranslation } from 'next-i18next';
 
 import { ValidationTarget } from '~/client/util/input-validator';
@@ -14,9 +15,11 @@ import ClosableTextInput from '../Common/ClosableTextInput';
 import { CopyDropdown } from '../Common/CopyDropdown';
 import { usePagePathRenameHandler } from '../PageEditor/page-path-rename-utils';
 
+
 import styles from './PageTitleHeader.module.scss';
 
-const moduleClass = styles['page-title-header'];
+const moduleClass = styles['page-title-header'] ?? '';
+const borderColorClass = styles['page-title-header-border-color'] ?? '';
 
 type Props = {
   currentPage: IPagePopulatedToShowRevision,
@@ -29,6 +32,8 @@ export const PageTitleHeader: FC<Props> = (props) => {
 
   const currentPagePath = currentPage.path;
 
+  const isMovable = isMovablePage(currentPagePath);
+
   const dPagePath = new DevidedPagePath(currentPage.path, true);
   const pageTitle = dPagePath.latter;
 
@@ -39,6 +44,12 @@ export const PageTitleHeader: FC<Props> = (props) => {
 
   const editedPageTitle = nodePath.basename(editedPagePath);
 
+  // TODO: https://redmine.weseek.co.jp/issues/142729
+  // https://regex101.com/r/Wg2Hh6/1
+  const untitledPageRegex = /^Untitled-\d+$/;
+
+  const isNewlyCreatedPage = (currentPage.wip && currentPage.latestRevision == null && untitledPageRegex.test(editedPageTitle)) ?? false;
+
   const onRenameFinish = useCallback(() => {
     setRenameInputShown(false);
   }, []);
@@ -48,8 +59,9 @@ export const PageTitleHeader: FC<Props> = (props) => {
   }, []);
 
   const onInputChange = useCallback((inputText: string) => {
+    const newPageTitle = pathUtils.removeHeadingSlash(inputText);
     const parentPagePath = pathUtils.addTrailingSlash(nodePath.dirname(currentPage.path));
-    const newPagePath = nodePath.resolve(parentPagePath, inputText);
+    const newPagePath = nodePath.resolve(parentPagePath, newPageTitle);
 
     setEditedPagePath(newPagePath);
   }, [currentPage?.path, setEditedPagePath]);
@@ -64,10 +76,19 @@ export const PageTitleHeader: FC<Props> = (props) => {
   }, [currentPagePath]);
 
   const onClickPageTitle = useCallback(() => {
+    if (!isMovable) {
+      return;
+    }
+
     setEditedPagePath(currentPagePath);
     setRenameInputShown(true);
-  }, [currentPagePath]);
+  }, [currentPagePath, isMovable]);
 
+  useEffect(() => {
+    if (isNewlyCreatedPage) {
+      setRenameInputShown(true);
+    }
+  }, [currentPage._id, isNewlyCreatedPage]);
 
   return (
     <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
@@ -75,18 +96,24 @@ export const PageTitleHeader: FC<Props> = (props) => {
         { isRenameInputShown && (
           <div className="position-absolute w-100">
             <ClosableTextInput
-              value={editedPageTitle}
+              value={isNewlyCreatedPage ? '' : editedPageTitle}
               placeholder={t('Input page name')}
               inputClassName="fs-4"
               onPressEnter={onPressEnter}
               onPressEscape={onPressEscape}
               onChange={onInputChange}
-              onClickOutside={() => setRenameInputShown(false)}
+              onClickOutside={() => { setRenameInputShown(false) }}
               validationTarget={ValidationTarget.PAGE}
             />
           </div>
         ) }
-        <h1 className={`mb-0 fs-4 ${isRenameInputShown ? 'invisible' : ''} text-truncate`} onClick={onClickPageTitle}>
+        <h1
+          className={`mb-0 px-2 fs-4
+            ${isRenameInputShown ? 'invisible' : ''} text-truncate
+            ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
+          `}
+          onClick={onClickPageTitle}
+        >
           {pageTitle}
         </h1>
       </div>
@@ -100,7 +127,7 @@ export const PageTitleHeader: FC<Props> = (props) => {
           pageId={currentPage._id}
           pagePath={currentPage.path}
           dropdownToggleId={`copydropdown-${currentPage._id}`}
-          dropdownToggleClassName="ms-2 p-1"
+          dropdownToggleClassName="p-1"
         >
           <span className="material-symbols-outlined fs-6">content_paste</span>
         </CopyDropdown>

+ 22 - 7
apps/app/src/components/ReactMarkdownComponents/LightBox.tsx

@@ -1,19 +1,34 @@
-import React, { useState } from 'react';
+import type { DetailedHTMLProps, ImgHTMLAttributes } from 'react';
+import React, { useMemo, useState } from 'react';
 
 import FsLightbox from 'fslightbox-react';
+import { createPortal } from 'react-dom';
 
-export const LightBox = (props) => {
+type Props = DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
+
+export const LightBox = (props: Props): JSX.Element => {
   const [toggler, setToggler] = useState(false);
-  const { node, ...rest } = props;
+  const { alt, ...rest } = props;
 
-  return (
-    <>
-      <img {...rest} onClick={() => setToggler(!toggler)} />
+  const lightboxPortal = useMemo(() => {
+    return createPortal(
       <FsLightbox
         toggler={toggler}
         sources={[props.src]}
+        alt={alt}
         type="image"
-      />
+        exitFullscreenOnClose
+      />,
+      document.body,
+    );
+  }, [alt, props.src, toggler]);
+
+  return (
+    <>
+      {/* eslint-disable-next-line @next/next/no-img-element */}
+      <img alt={alt} {...rest} onClick={() => setToggler(!toggler)} />
+
+      {lightboxPortal}
     </>
   );
 };

+ 0 - 1
apps/app/src/components/Sidebar/PageCreateButton/CreateButton.tsx

@@ -15,7 +15,6 @@ export const CreateButton = (props: Props): JSX.Element => {
       type="button"
       {...props}
       className={`${moduleClass} btn btn-primary ${props.className ?? ''}`}
-      data-testid="grw-sidebar-nav-page-create-button"
     >
       <Hexagon />
       <span className="icon material-symbols-outlined position-absolute">edit</span>

+ 1 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendMenu.tsx

@@ -26,6 +26,7 @@ export const DropendMenu = React.memo((props: DropendMenuProps): JSX.Element =>
   return (
     <DropdownMenu
       container="body"
+      data-testid="grw-page-create-button-dropend-menu"
     >
       <DropdownItem
         onClick={onClickCreateNewPage}

+ 1 - 0
apps/app/src/components/Sidebar/PageCreateButton/DropendToggle.tsx

@@ -14,6 +14,7 @@ export const DropendToggle = (): JSX.Element => {
       color="primary"
       className={`position-absolute z-1 ${moduleClass}`}
       aria-expanded={false}
+      data-testid="grw-page-create-button-dropend-toggle"
     >
       <Hexagon />
       <div className="hitarea position-absolute" />

+ 1 - 0
apps/app/src/components/Sidebar/PageCreateButton/PageCreateButton.tsx

@@ -45,6 +45,7 @@ export const PageCreateButton = React.memo((): JSX.Element => {
       className="d-flex flex-row mt-2"
       onMouseEnter={onMouseEnterHandler}
       onMouseLeave={onMouseLeaveHandler}
+      data-testid="grw-page-create-button"
     >
       <div className="btn-group flex-grow-1">
         <CreateButton

+ 6 - 7
apps/app/src/components/Sidebar/Tag.tsx

@@ -2,7 +2,7 @@ import type { FC } from 'react';
 import React, { useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
+import Link from 'next/link';
 
 import type { IDataTagCount } from '~/interfaces/tag';
 import { useSWRxTagsList } from '~/stores/tag';
@@ -19,8 +19,6 @@ const TAG_CLOUD_LIMIT = 20;
 
 const Tag: FC = () => {
 
-  const router = useRouter();
-
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
 
@@ -71,13 +69,14 @@ const Tag: FC = () => {
       }
 
       <div className="d-flex justify-content-center my-5">
-        <button
+        <Link
+          href="/tags"
           className="btn btn-primary rounded px-4"
-          type="button"
-          onClick={() => router.push('/tags')}
+          role="button"
+          prefetch={false}
         >
           {t('Check All tags')}
-        </button>
+        </Link>
       </div>
 
       <h6 className="my-3 pb-1 border-bottom">{t('popular_tags')}</h6>

+ 1 - 1
apps/app/src/components/TreeItem/NewPageInput/NewPageInput.tsx

@@ -40,7 +40,7 @@ export const NewPageInput: FC<Props> = (props) => {
     }
 
     try {
-      onSubmit?.(newPagePath);
+      await onSubmit?.(newPagePath);
       toastSuccess(t('successfully_saved_the_page'));
     }
     catch (err) {

+ 2 - 4
apps/app/src/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -2,8 +2,8 @@ import React, { useState, type FC, useCallback } from 'react';
 
 import { Origin } from '@growi/core';
 
-import { createPage } from '~/client/services/page-operation';
-import { useSWRxPageChildren, mutatePageTree } from '~/stores/page-listing';
+import { createPage } from '~/client/services/create-page';
+import { mutatePageTree } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 
 import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
@@ -58,8 +58,6 @@ export const useNewPageInput = (): UseNewPageInput => {
     const { itemNode, stateHandlers } = props;
     const { page, children } = itemNode;
 
-    const { mutate: mutateChildren } = useSWRxPageChildren(stateHandlers?.isOpen ? page._id : null);
-
     const { getDescCount } = usePageTreeDescCountMap();
     const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 

+ 7 - 4
apps/app/src/features/external-user-group/server/models/external-user-group-relation.ts

@@ -1,12 +1,13 @@
-import { Schema, Model, Document } from 'mongoose';
+import type { Model, Document } from 'mongoose';
+import { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import UserGroupRelation from '~/server/models/user-group-relation';
 
 import { getOrCreateModel } from '../../../../server/util/mongoose-utils';
-import { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
+import type { IExternalUserGroupRelation } from '../../interfaces/external-user-group';
 
-import { ExternalUserGroupDocument } from './external-user-group';
+import type { ExternalUserGroupDocument } from './external-user-group';
 
 export interface ExternalUserGroupRelationDocument extends IExternalUserGroupRelation, Document {}
 
@@ -24,6 +25,8 @@ export interface ExternalUserGroupRelationModel extends Model<ExternalUserGroupR
   countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
 
   findAllGroupsForUser: (user) => Promise<ExternalUserGroupDocument[]>
+
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<string[]>
 }
 
 const schema = new Schema<ExternalUserGroupRelationDocument, ExternalUserGroupRelationModel>({

+ 4 - 4
apps/app/src/features/questionnaire/client/components/ProactiveQuestionnaireModal.tsx

@@ -24,8 +24,8 @@ const QuestionnaireCompletionModal = (props: ModalProps): JSX.Element => {
       toggle={onClose}
       centered
     >
-      <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
-        <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+      <ModalBody className="overflow-hidden p-0" style={{ borderRadius: 8 }}>
+        <div className="m-2 p-4" style={{ borderRadius: 8 }}>
           <div className="text-center">
             <h2 className="my-4">{t('questionnaire_modal.title')}</h2>
             <p className="mb-1">{t('questionnaire_modal.successfully_submitted')}</p>
@@ -83,8 +83,8 @@ const ProactiveQuestionnaireModal = (props: ModalProps): JSX.Element => {
         toggle={onClose}
         centered
       >
-        <ModalBody className="bg-primary overflow-hidden p-0" style={{ borderRadius: 8 }}>
-          <div className="bg-white m-2 p-4" style={{ borderRadius: 8 }}>
+        <ModalBody className="overflow-hidden p-0" style={{ borderRadius: 8 }}>
+          <div className="m-2 p-4" style={{ borderRadius: 8 }}>
             <div className="text-center">
               <h2 className="my-4">{t('questionnaire_modal.title')}</h2>
               <p className="mb-1">{t('questionnaire_modal.more_satisfied_services')}</p>

+ 1 - 2
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -91,13 +91,12 @@ const SearchModal = (): JSX.Element => {
               </div>
 
               <ul {...getMenuProps()} className="list-unstyled m-0">
-                <div className="border-top mt-3 mb-2" />
+                <div className="border-top mt-2 mb-2" />
                 <SearchMethodMenuItem
                   activeIndex={highlightedIndex}
                   searchKeyword={searchKeyword}
                   getItemProps={getItemProps}
                 />
-                <div className="border-top mt-2 mb-2" />
                 <SearchResultMenuItem
                   activeIndex={highlightedIndex}
                   searchKeyword={searchKeyword}

+ 2 - 1
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -35,8 +35,8 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
   if (isLoading) {
     return (
       <>
+        <div className="border-top mt-2 mb-2" />
         Searching...
-        <div className="border-top mt-3" />
       </>
     );
   }
@@ -47,6 +47,7 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
 
   return (
     <div>
+      <div className="border-top mt-2 mb-2" />
       {searchResult?.data
         .map((item, index) => (
           <SearchMenuItem

+ 15 - 0
apps/app/src/interfaces/apiv3/attachment.ts

@@ -0,0 +1,15 @@
+import type { IAttachment, IPage, IRevision } from '@growi/core';
+
+import type { ICheckLimitResult } from '../attachment';
+
+export type IApiv3GetAttachmentLimitParams = {
+  fileSize: number,
+};
+
+export type IApiv3GetAttachmentLimitResponse = ICheckLimitResult;
+
+export type IApiv3PostAttachmentResponse = {
+  page: IPage,
+  revision: IRevision,
+  attachment: IAttachment,
+}

+ 5 - 0
apps/app/src/interfaces/attachment.ts

@@ -6,3 +6,8 @@ import type { PaginateResult } from './mongoose-utils';
 export type IResAttachmentList = {
   paginateResult: PaginateResult<IAttachmentHasId>
 };
+
+export type ICheckLimitResult = {
+  isUploadable: boolean,
+  errorMessage?: string,
+}

+ 4 - 4
apps/app/src/server/crowi/index.js

@@ -176,8 +176,6 @@ Crowi.prototype.init = async function() {
     this.setupExternalUserGroupSyncService(),
   ]);
 
-  await this.autoInstall();
-
   await normalizeData();
 };
 
@@ -414,7 +412,7 @@ Crowi.prototype.setupMailer = async function() {
   }
 };
 
-Crowi.prototype.autoInstall = function() {
+Crowi.prototype.autoInstall = async function() {
   const isInstalled = this.configManager.getConfig('crowi', 'app:installed');
   const username = this.configManager.getConfig('crowi', 'autoInstall:adminUsername');
 
@@ -438,7 +436,7 @@ Crowi.prototype.autoInstall = function() {
   const installerService = new InstallerService(this);
 
   try {
-    installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', {
+    await installerService.install(firstAdminUserToSave, globalLang ?? 'en_US', {
       allowGuestMode,
       serverDate,
     });
@@ -485,6 +483,8 @@ Crowi.prototype.start = async function() {
   instantiateYjsConnectionManager(this.socketIoService.io);
   this.socketIoService.setupYjsConnection();
 
+  await this.autoInstall();
+
   // listen
   const serverListening = httpServer.listen(this.port, () => {
     logger.info(`[${this.node_env}] Express server is listening on port ${this.port}`);

+ 1 - 0
apps/app/src/server/models/attachment.ts

@@ -38,6 +38,7 @@ export interface IAttachmentModel extends Model<IAttachmentDocument> {
 const attachmentSchema = new Schema({
   page: { type: Types.ObjectId, ref: 'Page', index: true },
   creator: { type: Types.ObjectId, ref: 'User', index: true },
+  filePath: { type: String }, // DEPRECATED: remains for backward compatibility for v3.3.x or below
   fileName: { type: String, required: true, unique: true },
   fileFormat: { type: String, required: true },
   fileSize: { type: Number, default: 0 },

+ 3 - 3
apps/app/src/server/models/page.ts

@@ -75,7 +75,7 @@ export interface PageModel extends Model<PageDocument> {
   findTargetAndAncestorsByPathOrId(pathOrId: string): Promise<TargetAndAncestorsResult>
   findRecentUpdatedPages(path: string, user, option, includeEmpty?: boolean): Promise<PaginatedPages>
   generateGrantCondition(
-    user, userGroups, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
+    user, userGroups: string[] | null, includeAnyoneWithTheLink?: boolean, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
   ): { $or: any[] }
   findNonEmptyClosestAncestor(path: string): Promise<PageDocument | undefined>
   findNotEmptyParentByPathRecursively(path: string): Promise<PageDocument | undefined>
@@ -417,7 +417,7 @@ export class PageQueryBuilder {
   }
 
   addConditionToFilteringByViewer(
-      user, userGroups, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+      user, userGroups: string[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
   ): PageQueryBuilder {
     const condition = generateGrantCondition(user, userGroups, includeAnyoneWithTheLink, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
 
@@ -962,7 +962,7 @@ schema.statics.findParent = async function(pageId): Promise<PageDocument | null>
 schema.statics.PageQueryBuilder = PageQueryBuilder as any; // mongoose does not support constructor type as statics attrs type
 
 export function generateGrantCondition(
-    user, userGroups, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
+    user, userGroups: string[] | null, includeAnyoneWithTheLink = false, showPagesRestrictedByOwner = false, showPagesRestrictedByGroup = false,
 ): { $or: any[] } {
   const grantConditions: AnyObject[] = [
     { grant: null },

+ 11 - 6
apps/app/src/server/models/user-group-relation.ts

@@ -1,10 +1,13 @@
-import { isPopulated, type IUserGroupHasId, type IUserGroupRelation } from '@growi/core';
-import mongoose, { Model, Schema, Document } from 'mongoose';
+import {
+  getIdForRef, isPopulated, type IUserGroupHasId, type IUserGroupRelation,
+} from '@growi/core';
+import type { Model, Document } from 'mongoose';
+import mongoose, { Schema } from 'mongoose';
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
-import { UserGroupDocument } from './user-group';
+import type { UserGroupDocument } from './user-group';
 
 const debug = require('debug')('growi:models:userGroupRelation');
 const mongoosePaginate = require('mongoose-paginate-v2');
@@ -28,6 +31,8 @@ export interface UserGroupRelationModel extends Model<UserGroupRelationDocument>
   countByGroupIdsAndUser: (userGroupIds: ObjectIdLike[], userData) => Promise<number>
 
   findAllGroupsForUser: (user) => Promise<UserGroupDocument[]>
+
+  findAllUserGroupIdsRelatedToUser: (user) => Promise<string[]>
 }
 
 /*
@@ -138,12 +143,12 @@ schema.statics.findAllGroupsForUser = async function(user): Promise<UserGroupDoc
  * @param {User} user
  * @returns {Promise<ObjectId[]>}
  */
-schema.statics.findAllUserGroupIdsRelatedToUser = async function(user) {
+schema.statics.findAllUserGroupIdsRelatedToUser = async function(user): Promise<string[]> {
   const relations = await this.find({ relatedUser: user._id })
     .select('relatedGroup')
     .exec();
 
-  return relations.map((relation) => { return relation.relatedGroup });
+  return relations.map((relation) => { return getIdForRef(relation.relatedGroup) });
 };
 
 /**

+ 11 - 5
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -5,7 +5,7 @@ import { isIPageInfoForEntity } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import express from 'express';
-import { query, oneOf } from 'express-validator';
+import { query, oneOf, validationResult } from 'express-validator';
 import mongoose from 'mongoose';
 
 
@@ -38,10 +38,16 @@ const validator = {
     query('id').isMongoId(),
     query('path').isString(),
   ], 'id or path is required'),
-  pageIdsOrPathRequired: oneOf([
-    query('pageIds').isArray(),
-    query('path').isString(),
-  ], 'pageIds or path is required'),
+  pageIdsOrPathRequired: [
+    // type check independent of existence check
+    query('pageIds').isArray().optional(),
+    query('path').isString().optional(),
+    // existence check
+    oneOf([
+      query('pageIds').exists(),
+      query('path').exists(),
+    ], 'pageIds or path is required'),
+  ],
   infoParams: [
     query('attachBookmarkCount').isBoolean().optional(),
     query('attachShortBody').isBoolean().optional(),

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

@@ -43,7 +43,7 @@ async function generateUntitledPath(parentPath: string, basePathname: string, in
 }
 
 async function determinePath(_parentPath?: string, _path?: string, optionalParentPath?: string): Promise<string> {
-  // TODO: i18n
+  // TODO: https://redmine.weseek.co.jp/issues/142729
   const basePathname = 'Untitled';
 
   if (_path != null) {

+ 1 - 1
apps/app/src/server/service/file-uploader/aws.ts

@@ -79,7 +79,7 @@ const S3Factory = (): S3Client => {
 };
 
 const getFilePathOnStorage = (attachment) => {
-  if (attachment.filePath != null) {
+  if (attachment.filePath != null) { // DEPRECATED: remains for backward compatibility for v3.3.x or below
     return attachment.filePath;
   }
 

+ 3 - 7
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
 
 import type { Response } from 'express';
 
+import type { ICheckLimitResult } from '~/interfaces/attachment';
 import { type RespondOptions, ResponseMode } from '~/server/interfaces/attachment';
 import { Attachment, type IAttachmentDocument } from '~/server/models';
 import loggerFactory from '~/utils/logger';
@@ -17,11 +18,6 @@ export type SaveFileParam = {
   data,
 }
 
-export type CheckLimitResult = {
-  isUploadable: boolean,
-  errorMessage?: string,
-}
-
 export type TemporaryUrl = {
   url: string,
   lifetimeSec: number,
@@ -38,7 +34,7 @@ export interface FileUploader {
   deleteFiles(): void,
   getFileUploadTotalLimit(): number,
   getTotalFileSize(): Promise<number>,
-  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
+  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult>,
   determineResponseMode(): ResponseMode,
   respond(res: Response, attachment: IAttachmentDocument, opts?: RespondOptions): void,
   findDeliveryFile(attachment: IAttachmentDocument): Promise<NodeJS.ReadableStream>,
@@ -135,7 +131,7 @@ export abstract class AbstractFileUploader implements FileUploader {
    * Check files size limits for all uploaders
    *
    */
-  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult> {
+  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<ICheckLimitResult> {
     if (uploadFileSize > maxFileSize) {
       return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
     }

+ 9 - 3
apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts

@@ -1,11 +1,11 @@
-import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Element } from 'hast-util-select/lib/types';
 import type { Plugin } from 'unified';
-import { visit } from 'unist-util-visit';
+import { visit, EXIT, CONTINUE } from 'unist-util-visit';
 
 import { addClassToProperties } from './add-class';
 
-const REGEXP_TARGET_TAGNAMES = new RegExp(/^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul|table|tr)$/);
+const REGEXP_TARGET_TAGNAMES = new RegExp(/^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul|table)$/);
 
 export const rehypePlugin: Plugin = () => {
   return (tree) => {
@@ -13,6 +13,11 @@ export const rehypePlugin: Plugin = () => {
       if (REGEXP_TARGET_TAGNAMES.test(node.tagName as string)) {
         const properties = node.properties ?? {};
 
+        // skip footnotes node
+        if (properties?.id === 'footnote-label') {
+          return EXIT;
+        }
+
         // add class
         addClassToProperties(properties, 'has-data-line');
         // add attribute
@@ -20,6 +25,7 @@ export const rehypePlugin: Plugin = () => {
 
         node.properties = properties;
       }
+      return CONTINUE;
     });
   };
 };

+ 4 - 1
apps/app/src/stores/page-listing.tsx

@@ -111,7 +111,10 @@ export const useSWRxPageInfoForList = (
     shouldFetch ? ['/page-listing/info', pageIds, path, attachBookmarkCount, attachShortBody] : null,
     ([endpoint, pageIds, path, attachBookmarkCount, attachShortBody]) => {
       return apiv3Get(endpoint, {
-        pageIds, path, attachBookmarkCount, attachShortBody,
+        pageIds: pageIds != null ? pageIds : undefined, // Do not pass null to avoid empty query parameter
+        path: path != null ? path : undefined, // Do not pass null to avoid empty query parameter
+        attachBookmarkCount,
+        attachShortBody,
       }).then(response => response.data);
     },
   );

+ 1 - 8
apps/app/src/styles/organisms/_wiki.scss

@@ -177,14 +177,7 @@
     border-top: 1px solid bs.$border-color;
     /* Hide the section label for visual users. */
     #footnote-label {
-      position: absolute;
-      width: 1px;
-      height: 1px;
-      padding: 0;
-      overflow: hidden;
-      clip: rect(0, 0, 0, 0);
-      word-wrap: normal;
-      border: 0;
+      display: none;
     }
   }
   /* Place `[` and `]` around footnote references. */

+ 16 - 20
apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts

@@ -1,4 +1,4 @@
-context('PageCreateModal', () => {
+context('PageCreateButton', () => {
 
   const ssPrefix = 'page-create-modal-';
 
@@ -9,41 +9,37 @@ context('PageCreateModal', () => {
     });
   });
 
-  it("PageCreateModal is shown and closed successfully", () => {
+  it("DropendMenu is shown successfully", () => {
     cy.visit('/');
     cy.collapseSidebar(true, true);
 
+    cy.getByTestid('grw-page-create-button').trigger('mouseover');
+
     cy.waitUntil(() => {
       // do
-      cy.getByTestid('newPageBtn').click({force: true});
+      cy.getByTestid('grw-page-create-button-dropend-toggle').click({force: true});
       // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}new-page-modal-opened`);
-      cy.get('button.close').click();
+      return cy.getByTestid('grw-page-create-button-dropend-menu').then($elem => $elem.is(':visible'));
     });
 
-    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
+    cy.screenshot(`${ssPrefix}page-create-button-dropend-menu-shown`);
   });
 
   it("Successfully Create Today's page", () => {
-    const pageName = "Today's page";
     cy.visit('/');
     cy.collapseSidebar(true);
 
+    cy.getByTestid('grw-page-create-button').trigger('mouseover');
+
     cy.waitUntil(() => {
       // do
-      cy.getByTestid('newPageBtn').click({force: true});
+      cy.getByTestid('grw-page-create-button-dropend-toggle').click({force: true});
       // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+      return cy.getByTestid('grw-page-create-button-dropend-menu').then($elem => $elem.is(':visible'));
     });
 
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('.page-today-input2').type(pageName);
-      cy.screenshot(`${ssPrefix}today-add-page-name`);
-      cy.getByTestid('btn-create-memo').click();
+    cy.getByTestid('grw-page-create-button-dropend-menu').should('be.visible').within(() => {
+      cy.get('button').eq(1).click();
     });
 
     cy.getByTestid('page-editor').should('be.visible');
@@ -60,7 +56,7 @@ context('PageCreateModal', () => {
     cy.screenshot(`${ssPrefix}create-today-page`);
   });
 
-  it('Successfully create page under specific path', () => {
+  it.skip('Successfully create page under specific path', () => {
     const pageName = 'child';
 
     cy.visit('/foo/bar');
@@ -98,7 +94,7 @@ context('PageCreateModal', () => {
     cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
   });
 
-  it('Trying to create template page under the root page fail', () => {
+  it.skip('Trying to create template page under the root page fail', () => {
     cy.visit('/');
     cy.collapseSidebar(true);
 
@@ -137,7 +133,7 @@ context('PageCreateModal', () => {
 });
 
 
-context('Shortcuts', () => {
+context.skip('Shortcuts', () => {
   const ssPrefix = 'shortcuts';
 
   beforeEach(() => {

+ 3 - 3
apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts

@@ -22,7 +22,7 @@ describe('Access to sidebar', () => {
         cy.visit('/');
 
         // Since this is a sidebar test, call collapseSidebar in beforeEach.
-        cy.collapseSidebar(false);
+        cy.collapseSidebar(false, true);
       });
 
       describe('Test show/collapse button', () => {
@@ -197,7 +197,7 @@ describe('Access to sidebar', () => {
 
         it('Successfully access to custom sidebar', () => {
           cy.getByTestid('grw-sidebar-contents').within(() => {
-            cy.get('.grw-sidebar-content-header > h3').find('a');
+            cy.get('.grw-sidebar-content-header > h4').find('a');
 
             cy.waitUntilSkeletonDisappear();
             cy.screenshot(`${ssPrefix}custom-sidebar-1-access-to-custom-sidebar`, { blackout: blackoutOverride });
@@ -270,7 +270,7 @@ describe('Access to sidebar', () => {
 
         it('Succesfully click all tags button', () => {
           cy.getByTestid('grw-sidebar-content-tags').within(() => {
-            cy.get('.btn-primary').click({force: true});
+            cy.get('.btn-primary').click();
           });
           cy.collapseSidebar(true);
           cy.getByTestid('grw-tags-list').should('be.visible');

+ 1 - 1
package.json

@@ -89,7 +89,7 @@
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~5.0.0",
-    "vite": "^4.5.2",
+    "vite": "^4.5.3",
     "vite-plugin-dts": "^2.3.0",
     "vite-tsconfig-paths": "^4.2.0",
     "vitest": "^0.34.6",

+ 17 - 0
packages/core/scss/bootstrap/_variables.scss

@@ -25,12 +25,29 @@ $warning-border-subtle:       mix(#fff, $warning, 70%) !default;
 $danger-border-subtle:        mix(#fff, $danger, 70%) !default;
 
 
+// Options
+//
+// Quickly modify global styling by enabling or disabling optional features.
+
+$enable-shadows: true;
+$box-shadow-inset: inset 0 0 rgba(black, 0); // set invisible value for inset
+
+
+// Buttons
+//
+// For each of Bootstrap's buttons, define text, background, and border color.
+
+$btn-box-shadow: 0;
+$btn-active-box-shadow: 0;
+
+
 // Links
 //
 // Style anchor elements.
 
 $link-decoration: none;
 
+
 //== Typography
 //
 //## Font, line-height, and color for body text, headings, and more.

+ 1 - 1
packages/editor/src/components/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -205,7 +205,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
   }, [isUploading, isDragAccept, isDragReject, acceptedUploadFileType]);
 
   return (
-    <div className={`${style['codemirror-editor']} flex-expand-vert overflow-y-hidden`}>
+    <div className={`${style['codemirror-editor']} flex-expand-vert overflow-y-hidden rounded`}>
       <div {...getRootProps()} className={`dropzone ${fileUploadState} flex-expand-vert`}>
         <input {...getInputProps()} />
         <FileDropzoneOverlay isEnabled={isDragActive} />

+ 0 - 2
packages/editor/src/stores/codemirror-editor.ts

@@ -45,8 +45,6 @@ export const useCodeMirrorEditorIsolated = (
 
   if (shouldUpdate) {
     ref.current = newData;
-    // eslint-disable-next-line no-console
-    console.info('Initializing codemirror for main');
   }
 
   return useSWRStatic(swrKey, shouldUpdate ? newData : undefined);

+ 17 - 37
yarn.lock

@@ -1780,6 +1780,11 @@
   resolved "https://registry.yarnpkg.com/@exodus/schemasafe/-/schemasafe-1.1.1.tgz#006ab8b33b1aec6d2992c75e5918c65197388aa2"
   integrity sha512-Pd7+aGvWIaTDL5ecV4ZBEtBrjXnk8/ly5xyHbikxVhgcq7qhihzHWHbcYmFupQBT2A5ggNZGvT7Bpj0M6AKHjA==
 
+"@fastify/busboy@^2.0.0":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d"
+  integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==
+
 "@gar/promisify@^1.1.3":
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@@ -5646,7 +5651,7 @@ bunyan@^1.8.12, bunyan@^1.8.15:
     mv "~2"
     safe-json-stringify "~1"
 
-busboy@1.6.0, busboy@^1.6.0:
+busboy@1.6.0:
   version "1.6.0"
   resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
   integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
@@ -16443,7 +16448,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -16461,15 +16466,6 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
 
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -16552,7 +16548,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -16566,13 +16562,6 @@ strip-ansi@^3.0.0:
   dependencies:
     ansi-regex "^2.0.0"
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -17608,11 +17597,11 @@ uncontrollable@^7.2.1:
     react-lifecycles-compat "^3.0.4"
 
 undici@^5.5.1:
-  version "5.21.2"
-  resolved "https://registry.yarnpkg.com/undici/-/undici-5.21.2.tgz#329f628aaea3f1539a28b9325dccc72097d29acd"
-  integrity sha512-f6pTQ9RF4DQtwoWSaC42P/NKlUjvezVvd9r155ohqkwFNRyBKM3f3pcty3ouusefNRyM25XhIQEbeQ46sZDJfQ==
+  version "5.28.4"
+  resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.4.tgz#6b280408edb6a1a604a9b20340f45b422e373068"
+  integrity sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==
   dependencies:
-    busboy "^1.6.0"
+    "@fastify/busboy" "^2.0.0"
 
 unified@^10.0.0, unified@^10.1.2, unified@~10.1.1:
   version "10.1.2"
@@ -18065,10 +18054,10 @@ vite-tsconfig-paths@^4.2.0:
     globrex "^0.1.2"
     tsconfck "^2.1.0"
 
-"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.2:
-  version "4.5.2"
-  resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.2.tgz#d6ea8610e099851dad8c7371599969e0f8b97e82"
-  integrity sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==
+"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0", vite@^4.5.3:
+  version "4.5.3"
+  resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.3.tgz#d88a4529ea58bae97294c7e2e6f0eab39a50fb1a"
+  integrity sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==
   dependencies:
     esbuild "^0.18.10"
     postcss "^8.4.27"
@@ -18281,7 +18270,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -18299,15 +18288,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"